diff --git a/docs/02-datenmodell.md b/docs/02-datenmodell.md index be6d670..ed819ea 100644 --- a/docs/02-datenmodell.md +++ b/docs/02-datenmodell.md @@ -63,7 +63,7 @@ erDiagram crm_opportunities { int id PK varchar title - enum stage "Neu .. Verloren" + enum stage "Neu .. Beauftragt | Absage" tinyint probability "0-100" decimal daily_rate decimal duration_days "Personentage, halbe möglich" diff --git a/docs/07-anleitung-benutzer.md b/docs/07-anleitung-benutzer.md index 0aa695a..09b91dc 100644 --- a/docs/07-anleitung-benutzer.md +++ b/docs/07-anleitung-benutzer.md @@ -34,20 +34,20 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich. │ Kategorien │ Titel der Ansicht [Hell/Dunkel] │ │ Akquise ▾ │ │ │ Eingang │ │ -│ Archiv │ │ +│ Archiv 3│ │ │ Opportuni…▾ │───────────────────────────────────────────┤ │ Alle │ │ -│ Papierkorb │ │ +│ Papierkorb2│ │ │ Interviews │ │ │ Kontakte ▾ │ Inhalt │ │ Alle Kont. │ │ │ Aus Mails 5│ │ │ Mails ▾ │ │ │ Postfach │ │ -│ Papierkorb │ │ +│ Papierkorb7│ │ │ Scanner ▾ │ │ │ Dashboard │ │ -│ Unpassend │ │ +│ Unpassend 4│ │ │ Suchagenten│ │ │ Börsen-… │ │ ├──────────────┤ │ @@ -63,9 +63,12 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich. auf den Schalter folgt wieder der Systemeinstellung. ![Dieselbe Ansicht im dunklen Design](bilder/aus-mails-dunkel.png) -* Die Menüpunkte **Kontakte** und **Scanner** klappen beim Anklicken ihre - Unterpunkte auf. Die Zahl neben *Aus Mails* sagt, wie viele Funde aus dem - Postfach noch auf eine Entscheidung warten. +* Die Menüpunkte **Akquise**, **Opportunities**, **Kontakte**, **Mails** und + **Scanner** klappen beim Anklicken ihre Unterpunkte auf. Hinter einigen steht + eine Zahl: *Aus Mails* (Funde, die auf eine Entscheidung warten), *Archiv* + (Akquise-Einträge älter als 14 Tage), beide *Papierkörbe* und *Unpassend* + (verworfene Scanner-Treffer). Bei 0 steht dort nichts - eine Null wäre nur + Rauschen. * Jedes Fenster, das sich in den Vordergrund legt, schließt auf drei Wege: das **X** oben rechts, die Taste **Esc** oder ein **Klick daneben** in den abgedunkelten Hintergrund. Liegen zwei Fenster übereinander, betrifft das @@ -170,14 +173,40 @@ wechselt man die Darstellung: | --- | --- | | **Kachel** | Überblick mit Volumen, Tagessatz oder Projektpreis, Laufzeit, letzter Interaktion | | **Liste** | viele Einträge vergleichen, Spaltenköpfe sortieren | -| **Pipeline** | Fortschritt durch die Phasen, Karten per Maus verschieben | +| **Pipeline** (Kanban) | Fortschritt durch die Phasen, Karten per Maus verschieben. **Standardansicht** | ![Opportunities in der Kachelansicht](bilder/opportunities-kacheln.png) Die **Pipeline** zeigt dieselben Opportunities nach Phasen sortiert. Über jeder Spalte steht ihr Volumen; eine Karte wird per Maus in die nächste Phase gezogen. -![Pipeline-Ansicht mit den Phasen von Neu bis Verloren](bilder/opportunities-pipeline.png) +![Pipeline-Ansicht mit den Phasen von Neu bis Absage](bilder/opportunities-pipeline.png) + +### Die Phasen + +Sie folgen dem Vermittlungsprozess. Der Mauszeiger auf einer Phase zeigt dieselbe +Erläuterung: + +| Phase | Bedeutung | +| --- | --- | +| **Neu** | Projekt wurde identifiziert und noch nicht aktiv bearbeitet. | +| **Beworben** | Beraterprofil wurde an Vermittler oder Direktkunden übermittelt. | +| **Erstgespräch Berater** | Erstes qualifizierendes Gespräch mit dem Berater und Vermittler bzw. Direktkunden hat stattgefunden; ein reines Gespräch mit unserem Vertrieb zählt nicht. | +| **Kundenvorstellung** | Beraterprofil wurde dem tatsächlichen Endkunden bzw. Entscheider zur Prüfung vorgestellt. | +| **Interview Kunde** | Berater führt ein konkretes fachliches oder persönliches Gespräch mit dem Endkunden. | +| **Zusage** | Einsatz des Beraters wurde bestätigt, die formale Beauftragung steht ggf. noch aus. | +| **Beauftragt** | Vertrag bzw. Bestellung liegt vor und der Deal ist gewonnen. | +| **Absage** | Projekt wird mit diesem Berater nicht weiterverfolgt. | + +Als **offen** zählt alles bis einschließlich *Zusage* - dort ist der Einsatz +bestätigt, aber noch nicht beauftragt. *Beauftragt* und *Absage* sind das Ende. +Der Filter *Nur offene* nutzt genau diese Grenze, ebenso die Pipeline-Summe in +der Kopfzeile. + +> Bei der Umstellung am 18.08.2026 wurden **alle vorhandenen Opportunities auf +> „Neu" gesetzt**. Der alte Trichter (Qualifizierung, Angebot, Verhandlung, +> Gewonnen, Verloren) ließ nicht erkennen, in welcher der neuen Phasen ein +> Projekt steckt. **Volumen** rechnet die Anwendung selbst: Tagessatz x Personentage. Das **gewichtete** Volumen multipliziert das noch mit der Abschlusswahrscheinlichkeit. diff --git a/docs/09-quellcode.md b/docs/09-quellcode.md index 1230628..901a0f3 100644 --- a/docs/09-quellcode.md +++ b/docs/09-quellcode.md @@ -93,6 +93,7 @@ Alle unter `router.use(requireAuth)`. | `events.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | anlegen jeder; ändern/löschen nur Ersteller oder Admin | | `opportunities.js` | `GET /?trash=true`, `POST /`, `PATCH /:id`, `DELETE /:id` (Papierkorb), `POST /:id/restore`, `POST /trash/empty` | ändern, löschen und wiederherstellen nur Ersteller, Zuständiger oder Admin; Leeren jeder Angemeldete | | `acquisitions.js` | `GET /?view=eingang\|archiv\|alle`, `POST /`, `PATCH /:id`, `DELETE /:id`, `POST /:id/opportunity`; Auswahllisten: `GET/POST /options`, `PATCH/DELETE /options/:id` | Einträge ändern/löschen nur Ersteller oder Admin; Auswahllisten jeder Angemeldete | +| `badges.js` | `GET /` | Zahlen für die Seitenleiste: Akquise-Archiv, beide Papierkörbe, verworfene Scanner-Treffer. Der Scanner-Zähler ist `null`, wenn dessen Datenbank fehlt | | `mail.js` | `GET /status`, `/messages`, `/messages/:id`, `/candidates`; `POST /messages/:id/links`, `/messages/:id/restore`, `/candidates/:id/adopt`, `/candidates/:id/reject`, `/trash/empty`; `DELETE /links/:id`, `/messages/:id`; `requireAdmin`: `GET/POST /settings`, `POST /test`, `POST /sync` | lesen, zuordnen und löschen jeder; einrichten Admin | Wiederkehrende Muster in diesen Routern: @@ -266,6 +267,7 @@ Warum genau diese Konstanten festgelegt sind, steht in | Datei | Inhalt | | --- | --- | | `components/opportunities/OpportunityTrash.tsx` | Papierkorb der Opportunities: wiederherstellen, leeren, Restlaufzeit je Eintrag | +| `components/opportunities/opportunityUi.tsx` | `STAGE_LABELS` (Anzeige, mit Umlaut), `STAGE_HINTS` (Erläuterung als Titelhinweis), `STAGE_STYLES`, `STAGE_COLORS`, `StageBadge` | | `components/acquisition/Acquisitions.tsx` | Akquise und Archiv (`view`); Suche, Sortierung, Übernahme, Löschen | | `components/acquisition/AcquisitionForm.tsx` | Eingabe über der Liste; behält Datum, Anbieter, Kanal und Berater für den nächsten Eintrag | | `components/acquisition/AcquisitionTable.tsx` | die Liste; `safeLink()` lässt nur http(s) zum Link werden | diff --git a/docs/10-glossar.md b/docs/10-glossar.md index 942202d..224d7d1 100644 --- a/docs/10-glossar.md +++ b/docs/10-glossar.md @@ -26,7 +26,7 @@ Quelltext. | **Art** (eines Kontakts) | Recruiter, Partner oder Endkunde. Spalte `crm_leads.kind`; hieß bis 18.08.2026 „Status" mit den Werten Neu .. Verloren. | | **Papierkorb** (Mails) | Im CRM gelöschte Mails. Umkehrbar, bis er geleert wird. Im Postfach crm@perinco.de bleiben die Mails in jedem Fall; der Abgleich holt gelöschte nicht wieder herein. | | **Papierkorb** (Opportunities) | Gelöschte Opportunities, `crm_opportunities.deleted_at`. Wiederherstellbar; endgültig weg nach 90 Tagen oder beim Leeren. | -| **Phase** / Stage | Fortschritt einer Opportunity: Neu, Qualifizierung, Angebot, Verhandlung, Gewonnen, Verloren. | +| **Phase** / Stage | Fortschritt einer Opportunity im Vermittlungsprozess: Neu, Beworben, Erstgespräch Berater, Kundenvorstellung, Interview Kunde, Zusage, Beauftragt, Absage. Offen ist alles bis *Zusage*. In der Datenbank steht `Erstgespraech Berater` ohne Umlaut; angezeigt wird er (`STAGE_LABELS`). | | **Pipeline** | Zwei Bedeutungen, je nach Zusammenhang: die Kanban-Ansicht der Opportunities **oder** der Suchlauf des Scanners (`services/pipeline.js`). | | **Projekt** | Ein von einer Börse gescrapter Auftrag. Tabelle `projects`, Aufbewahrung 7 Tage. | | **Scraper** | Der Teil, der mit einem echten Browser (Playwright/Chromium) die Suchseiten der Börsen abruft. | diff --git a/server/src/db.js b/server/src/db.js index 1ab5146..ede2b44 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -49,6 +49,7 @@ export async function initDatabase() { } await migrateLeadKind(); + await migrateOpportunityStages(); await migrateMailPool(); await seedAcquisitionOptions(); await dropProviderOptions(); @@ -100,6 +101,48 @@ async function migrateLeadKind() { ); } +/* + * Einmalige Umstellung: aus dem allgemeinen Vertriebstrichter der Opportunities + * (Neu, Qualifizierung, Angebot, Verhandlung, Gewonnen, Verloren) werden die + * Phasen des Vermittlungsprozesses (Neu .. Beauftragt, Absage). + * + * Erkannt am Spaltentyp: steht 'Qualifizierung' noch im ENUM, ist die Umstellung + * offen. Reihenfolge wie bei der Kontaktart - erst die Auswahl auf alt plus neu + * erweitern, dann die Altwerte setzen, dann verengen. Direkt zu verengen wuerde + * jeden Altwert zu '' machen. + * + * Alle vorhandenen Opportunities kommen auf 'Neu': welche Phase des neuen + * Prozesses sie haben, sagt der alte Trichter nicht (so mit dem Nutzer + * entschieden am 2026-08-18). + */ +async function migrateOpportunityStages() { + const [rows] = await pool.query( + `SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'crm_opportunities' + AND COLUMN_NAME = 'stage' LIMIT 1` + ); + if (!rows[0] || !String(rows[0].COLUMN_TYPE).includes("'Qualifizierung'")) return; + + await pool.query( + `ALTER TABLE crm_opportunities MODIFY stage + ENUM('Neu','Qualifizierung','Angebot','Verhandlung','Gewonnen','Verloren', + 'Beworben','Erstgespraech Berater','Kundenvorstellung','Interview Kunde', + 'Zusage','Beauftragt','Absage') NOT NULL DEFAULT 'Neu'` + ); + const [result] = await pool.query( + `UPDATE crm_opportunities SET stage = 'Neu' + WHERE stage IN ('Qualifizierung','Angebot','Verhandlung','Gewonnen','Verloren')` + ); + await pool.query( + `ALTER TABLE crm_opportunities MODIFY stage + ENUM('Neu','Beworben','Erstgespraech Berater','Kundenvorstellung','Interview Kunde','Zusage','Beauftragt','Absage') NOT NULL DEFAULT 'Neu'` + ); + + console.log( + `[Opportunities] Phasen auf den Vermittlungsprozess umgestellt - ${result.affectedRows} Eintrag/Eintraege auf "Neu" gesetzt.` + ); +} + /* * Einmalige Umstellung: die vom Postfachabgleich bereits angelegten Kontakte * (source = 'Mail') wandern in den Vorbereich "Aus Mails" (mail_pool = 1). diff --git a/server/src/routes/badges.js b/server/src/routes/badges.js new file mode 100644 index 0000000..531026a --- /dev/null +++ b/server/src/routes/badges.js @@ -0,0 +1,60 @@ +import express from 'express'; +import { pool } from '../db.js'; +import { requireAuth } from '../middleware/auth.js'; +import { Match } from '../scanner/models/index.js'; + +const router = express.Router(); +router.use(requireAuth); + +/* + * Zaehler fuer die Seitenleiste: Akquise-Archiv, Papierkorb der Opportunities, + * Papierkorb der Mails und die unpassenden Scanner-Treffer. + * + * Ein Endpunkt statt vier: die Zahlen stehen alle im Menue und sollen zusammen + * kommen. Die Ansichten selbst laden weiter ihre eigenen Daten - hier gibt es + * ausschliesslich Zahlen, keine Inhalte. + * + * Der Scanner liegt in einer eigenen Datenbank und kann fehlen; sein Zaehler ist + * dann null und die Leiste zeigt an der Stelle einfach keine Zahl. Ein Menue + * ohne Zahl ist besser als ein Menue mit Fehlermeldung. + */ + +/** Grenze zwischen Eingang und Archiv der Akquise - wie in routes/acquisitions.js. */ +const ACQUISITION_ARCHIVE_AFTER_DAYS = 14; +const ACQUISITION_DELETE_AFTER_DAYS = 90; + +// GET /api/badges +router.get('/', async (req, res, next) => { + try { + const [[counts]] = await pool.query( + `SELECT + (SELECT COUNT(*) FROM crm_acquisitions + WHERE converted_at IS NULL + AND found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY) + AND found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)) AS acquisition_archive, + (SELECT COUNT(*) FROM crm_opportunities + WHERE deleted_at IS NOT NULL) AS opportunity_trash, + (SELECT COUNT(*) FROM crm_mail_messages + WHERE crm_deleted_at IS NOT NULL AND crm_purged = 0) AS mail_trash`, + [ACQUISITION_ARCHIVE_AFTER_DAYS, ACQUISITION_DELETE_AFTER_DAYS] + ); + + let scannerRejected = null; + try { + scannerRejected = await Match.count({ where: { status: 'rejected' } }); + } catch { + // Scanner-Datenbank nicht erreichbar - dann eben keine Zahl. + } + + res.json({ + acquisition_archive: Number(counts.acquisition_archive), + opportunity_trash: Number(counts.opportunity_trash), + mail_trash: Number(counts.mail_trash), + scanner_rejected: scannerRejected, + }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/server/src/routes/opportunities.js b/server/src/routes/opportunities.js index 2b4e7e7..6c5dec3 100644 --- a/server/src/routes/opportunities.js +++ b/server/src/routes/opportunities.js @@ -5,13 +5,17 @@ import { requireAuth } from '../middleware/auth.js'; const router = express.Router(); router.use(requireAuth); +// Phasen des Vermittlungsprozesses - dieselbe Reihenfolge wie im Frontend +// (OPPORTUNITY_STAGES in src/types.ts) und in der Spalte crm_opportunities.stage. const STAGES = new Set([ 'Neu', - 'Qualifizierung', - 'Angebot', - 'Verhandlung', - 'Gewonnen', - 'Verloren', + 'Beworben', + 'Erstgespraech Berater', + 'Kundenvorstellung', + 'Interview Kunde', + 'Zusage', + 'Beauftragt', + 'Absage', ]); const TEXT_FIELDS = ['title', 'company', 'description', 'last_interaction_note', 'next_step', 'notes']; diff --git a/server/src/schema.sql b/server/src/schema.sql index 6b4db4c..13bf416 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -114,7 +114,10 @@ CREATE TABLE IF NOT EXISTS crm_opportunities ( title VARCHAR(190) NOT NULL, company VARCHAR(190) NULL, description TEXT NULL, - stage ENUM('Neu','Qualifizierung','Angebot','Verhandlung','Gewonnen','Verloren') + -- Phasen des Vermittlungsprozesses. Bis zum 18.08.2026 stand hier ein + -- allgemeiner Vertriebstrichter (Neu .. Verloren); die Umstellung bestehender + -- Datenbanken macht migrateOpportunityStages() in db.js. + stage ENUM('Neu','Beworben','Erstgespraech Berater','Kundenvorstellung','Interview Kunde','Zusage','Beauftragt','Absage') NOT NULL DEFAULT 'Neu', probability TINYINT UNSIGNED NOT NULL DEFAULT 10, -- 0-100 % daily_rate DECIMAL(10,2) NULL, -- Tagessatz in Euro diff --git a/server/src/server.js b/server/src/server.js index 58e4dfb..064f0df 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -9,6 +9,7 @@ import connectionRoutes from './routes/connections.js'; import eventRoutes from './routes/events.js'; import opportunityRoutes from './routes/opportunities.js'; import acquisitionRoutes from './routes/acquisitions.js'; +import badgeRoutes from './routes/badges.js'; import scannerRoutes from './routes/scanner.js'; import mailRoutes from './routes/mail.js'; import { requireAuth } from './middleware/auth.js'; @@ -50,6 +51,9 @@ app.use('/api/events', eventRoutes); app.use('/api/opportunities', opportunityRoutes); app.use('/api/acquisitions', acquisitionRoutes); +// Zahlen fuer die Seitenleiste - eine Anfrage fuer alle Zaehler. +app.use('/api/badges', badgeRoutes); + // Projekt Scanner. Komplett hinter der CRM-Anmeldung - der urspruengliche // HTTP-Basic-Auth-Zugang des Scanners entfaellt damit. app.use('/api/scanner', requireAuth, scannerRoutes); diff --git a/src/App.tsx b/src/App.tsx index 3355d64..aa8a0fa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,7 +18,15 @@ import { ScannerPortals } from './components/scanner/ScannerPortals'; import { useToast } from './components/Toast'; import { NavigationProvider } from './lib/navigation'; import { useAuth } from './lib/auth'; -import { connectionsApi, eventsApi, leadsApi, opportunitiesApi, usersApi } from './lib/api'; +import { + badgesApi, + connectionsApi, + eventsApi, + leadsApi, + opportunitiesApi, + usersApi, + type SidebarBadges, +} from './lib/api'; import { OPEN_STAGES, formatEuro, @@ -71,6 +79,7 @@ const App: React.FC = () => { const [users, setUsers] = useState([]); const [events, setEvents] = useState([]); const [opportunities, setOpportunities] = useState([]); + const [badges, setBadges] = useState(null); const [isDataLoading, setIsDataLoading] = useState(false); /** @@ -104,6 +113,16 @@ const App: React.FC = () => { setEvents(eventRows); setUsers(userRows); setOpportunities(opportunityRows); + + /* + * Die Zahlen der Seitenleiste haengen bewusst hinter den Daten und nicht + * im Promise.all: sie sind Beiwerk. Faellt der Aufruf aus, bleibt das + * Menue ohne Zahlen statt die ganze Ansicht mit einem Fehler. + */ + badgesApi + .get() + .then(setBadges) + .catch(() => setBadges(null)); } catch (err) { addToast(err instanceof Error ? err.message : 'Daten konnten nicht geladen werden.', 'error'); } finally { @@ -180,7 +199,13 @@ const App: React.FC = () => { onViewChange={goTo} isOpen={isSidebarOpen} onToggle={() => setIsSidebarOpen((open) => !open)} - mailPoolCount={mailPoolLeads.length} + counts={{ + 'Aus Mails': mailPoolLeads.length, + Archiv: badges?.acquisition_archive, + 'Opportunity-Papierkorb': badges?.opportunity_trash, + Papierkorb: badges?.mail_trash, + Unpassend: badges?.scanner_rejected, + }} />
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 6bb6e33..12f24fd 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -15,8 +15,12 @@ interface SidebarProps { onViewChange: (view: ViewState) => void; isOpen: boolean; onToggle: () => void; - /** Anzahl der Eintraege in "Aus Mails" - als Zaehler am Unterpunkt. */ - mailPoolCount?: number; + /** + * Zahlen an den Unterpunkten. Was auf eine Entscheidung wartet oder sich + * ansammelt, soll man sehen, ohne die Ansicht zu oeffnen. 0 zeigt nichts an - + * eine Null waere nur Rauschen. + */ + counts?: Partial>; } interface MenuItem { @@ -185,7 +189,7 @@ export const Sidebar: React.FC = ({ onViewChange, isOpen, onToggle, - mailPoolCount = 0, + counts = {}, }) => { const { user, logout } = useAuth(); const isMobile = useIsMobile(); @@ -208,7 +212,10 @@ export const Sidebar: React.FC = ({ setOpenGroups((open) => (open.includes(active.item.name) ? open : [...open, active.item.name])); }, [currentView]); - const renderItem = (item: MenuItem, options: { indented?: boolean; badge?: number } = {}) => { + const renderItem = ( + item: MenuItem, + options: { indented?: boolean; badge?: number | null } = {} + ) => { const isActive = currentView === item.name; return (