diff --git a/docs/01-architektur.md b/docs/01-architektur.md index 1cb956e..9684537 100644 --- a/docs/01-architektur.md +++ b/docs/01-architektur.md @@ -90,8 +90,8 @@ sofort draußen, ohne auf den Ablauf des Tokens zu warten. ```mermaid flowchart TD L["Anmeldung"] --> R{"Rolle"} - R -->|admin| A["Akquise, Opportunities, Interviews, Kontakte, Mails, Scanner
Einstellungen: Nutzerverwaltung, Mein Konto, Postfach, Scanner, Whisper"] - R -->|user| U["Akquise, Opportunities, Interviews, Kontakte, Mails, Scanner
Einstellungen: Mein Konto, Whisper"] + R -->|admin| A["Dashboard, Akquise, Opportunities, Interviews, Kontakte, Mails, Scanner
Einstellungen: Nutzerverwaltung, Mein Konto, Akquise, Postfach, Scanner, Whisper"] + R -->|user| U["Dashboard, Akquise, Opportunities, Interviews, Kontakte, Mails, Scanner
Einstellungen: Mein Konto, Akquise, Whisper"] ``` Bei den Mails darf jede Rolle lesen und zuordnen - `crm@perinco.de` ist ein diff --git a/docs/02-datenmodell.md b/docs/02-datenmodell.md index ed819ea..651fe4a 100644 --- a/docs/02-datenmodell.md +++ b/docs/02-datenmodell.md @@ -71,6 +71,7 @@ erDiagram date last_interaction_at varchar next_step int owner_id FK + datetime won_at "Wechsel nach Beauftragt" datetime deleted_at "Papierkorb; nach 90 Tagen weg" } crm_opportunity_contacts { @@ -95,6 +96,11 @@ erDiagram enum field "sender | channel | consultant" varchar value "Inhalt des Dropdowns" } + crm_acquisition_history { + date found_on PK + varchar consultant PK + int applications "verdichtet vor dem Loeschen" + } crm_events { int id PK varchar title @@ -110,7 +116,7 @@ erDiagram } ``` -Sieben Eigenheiten, die man kennen muss: +Acht Eigenheiten, die man kennen muss: - **`crm_leads` führt zwei Bestände.** `mail_pool = 0` ist der gepflegte Kontaktbestand, `mail_pool = 1` der Vorbereich „Aus Mails" mit den Funden des @@ -127,6 +133,15 @@ Sieben Eigenheiten, die man kennen muss: - **`avatar` ist eine Data-URL in der Datenbank.** Der Browser verkleinert jedes Bild vor dem Upload auf 256 px (`src/lib/imageCompress.ts`), aus 2,9 MB werden wenige Kilobyte. +- **Das Dashboard rechnet aus zwei Quellen.** Jeder Eintrag in + `crm_acquisitions` ist eine versendete Bewerbung. Weil die Akquise nach 90 + Tagen löscht, sichert der Aufräumlauf vorher Tag, Berater und Anzahl in + `crm_acquisition_history` - in derselben Transaktion, sonst würde ein Abbruch + zwischen Sichern und Löschen denselben Tag doppelt zählen. Die Auswertung liest + beide Quellen und addiert sie; überschneiden können sie sich nicht, weil genau + das gesichert wird, was verschwindet. `crm_opportunities.won_at` hält den + Zeitpunkt des Wechsels nach *Beauftragt* - die Phase allein sagt nur, wie es + jetzt steht, nicht wann es passiert ist. - **Gelöschte Opportunities bleiben stehen.** `crm_opportunities.deleted_at` setzt sie in den Papierkorb; alle Abfragen blenden sie aus. An einer Opportunity hängen Ansprechpartner, Verlauf und Mailzuordnungen - ein Fehlklick diff --git a/docs/05-frontend.md b/docs/05-frontend.md index 1db5720..453f155 100644 --- a/docs/05-frontend.md +++ b/docs/05-frontend.md @@ -9,10 +9,11 @@ sitzt und keine tiefen Links braucht. ```mermaid flowchart TD SB["Seitenleiste"] --> K["Kategorien"] + K --> D["Dashboard
(Startansicht, auch über das Logo)"] K --> AQ["Akquise"] AQ --> AQ1["Eingang
Funde der letzten 14 Tage"] AQ --> AQ2["Archiv
älter; nach 90 Tagen gelöscht"] - K --> O["Opportunities
(Startansicht)"] + K --> O["Opportunities"] K --> C["Interviews"] K --> L["Kontakte"] L --> L1["Alle Kontakte"] diff --git a/docs/07-anleitung-benutzer.md b/docs/07-anleitung-benutzer.md index 09b91dc..c52fc2a 100644 --- a/docs/07-anleitung-benutzer.md +++ b/docs/07-anleitung-benutzer.md @@ -9,6 +9,7 @@ Ein CRM für die Projektvermittlung. Es beantwortet vier Fragen: | Frage | Bereich | | --- | --- | +| Wie läuft es gerade? | **Dashboard** | | Welche Projekte haben wir gefunden? | **Akquise** | | Welche Geschäfte sind in Arbeit und was sind sie wert? | **Opportunities** | | Wer ist wann wo, und wer ist beteiligt? | **Interviews** | @@ -32,6 +33,7 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich. ``` ┌──────────────┬───────────────────────────────────────────┐ │ Kategorien │ Titel der Ansicht [Hell/Dunkel] │ +│ Dashboard │ │ │ Akquise ▾ │ │ │ Eingang │ │ │ Archiv 3│ │ @@ -57,6 +59,8 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich. └──────────────┴───────────────────────────────────────────┘ ``` +* Der Klick auf das **perinco-Logo** oben links führt zurück zum **Dashboard** - + das ist auch die Ansicht, mit der die Anwendung startet. * Der Knopf mit den drei Strichen oben links klappt das Menü auf Symbolbreite zusammen - hilfreich auf kleinen Bildschirmen. * Rechts oben schaltet zwischen hellem und dunklem Design. Ein **Doppelklick** @@ -78,6 +82,48 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich. > Alle Abbildungen in dieser Anleitung zeigen die Anwendung mit **erfundenen > Beispieldaten**. Firmen, Personen und Zahlen darin gibt es nicht. +## Dashboard + +Die Startansicht: wie der Vertrieb läuft, ohne dass man irgendwo hineinklicken +muss. Alle sehen dieselben Zahlen. + +Oben steht der **Zeitraum**. Vier Vorgaben (30 Tage, 12 Monate, 8 Quartale, +5 Jahre) setzen Anfang, Ende und die passende Einteilung; *von* und *bis* lassen +sich frei setzen, und rechts wählt **Einteilung** aus, wie die Zeitachse +geschnitten wird - Tag, Monat, Quartal oder Jahr. + +### Die vier Kennzahlen + +| Kachel | Was gezählt wird | +| --- | --- | +| **Bewerbungen** | Einträge in der Akquise im Zeitraum. Jeder Eintrag ist eine versendete Bewerbung | +| **Zu Opportunity** | Bewerbungen, aus denen eine Opportunity geworden ist - dazu die Quote | +| **Gewonnen** | Opportunities, die in die Phase *Beauftragt* gekommen sind | +| **Beim Kunden** | Opportunities, die **jetzt** in *Kundenvorstellung* oder *Interview Kunde* stehen - unabhängig vom Zeitraum | + +### Verlauf + +Das Säulendiagramm zeigt dieselben drei Reihen über der Zeit: Bewerbungen, daraus +entstandene Opportunities und gewonnene Projekte. Leere Abschnitte bleiben +sichtbar - eine Woche ohne Bewerbung ist eine Aussage. Der Mauszeiger auf einer +Säule nennt Abschnitt, Reihe und Wert. + +Rechts daneben steht **Bewerbungen je Berater** für denselben Zeitraum. Einträge +ohne hinterlegten Berater erscheinen als *Ohne Berater*. + +### Anstehende Interviews und „Beim Kunden" + +Unten links die nächsten acht Termine aus dem gemeinsamen Kalender mit Datum, +Uhrzeit, Ort, Teilnehmern und dem Abstand („morgen", „in 3 Tagen"). Unten rechts +alle Opportunities in *Kundenvorstellung* und *Interview Kunde* mit Firma, +Volumen und Zuständigem - also alles, wo gerade der Kunde am Zug ist. + +> **Zur Genauigkeit der Zahlen:** Die Akquise löscht Einträge nach 90 Tagen. +> Damit die Auswertung über Monate und Jahre trotzdem stimmt, sichert das Backend +> vor dem Löschen Tag, Berater und Anzahl - die Projektdaten sind dann weg, die +> Zahl bleibt. „Gewonnen" wird ab dem 18.08.2026 mitgeschrieben (Zeitpunkt des +> Wechsels nach *Beauftragt*); für die Zeit davor gibt es diesen Zeitpunkt nicht. + ## Akquise Der Eingang für gefundene Projekte - alles, was per Mail, Telefon oder über ein diff --git a/docs/09-quellcode.md b/docs/09-quellcode.md index 901a0f3..6c1b91a 100644 --- a/docs/09-quellcode.md +++ b/docs/09-quellcode.md @@ -94,6 +94,7 @@ Alle unter `router.use(requireAuth)`. | `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 | +| `dashboard.js` | `GET /?from=&to=&bucket=tag\|monat\|quartal\|jahr` | drei Reihen über der Zeit plus Bewerbungen je Berater. Liest Akquise **und** `crm_acquisition_history`; Termine und Phasenlisten stehen bewusst nicht drin - die lädt die App ohnehin | | `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: @@ -268,6 +269,8 @@ Warum genau diese Konstanten festgelegt sind, steht in | --- | --- | | `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/dashboard/Dashboard.tsx` | Startansicht: Zeitraumwahl, vier Kennzahlen, Verlauf, Berater, Termine, „Beim Kunden". `buildAxis()` erzeugt auch leere Abschnitte | +| `components/dashboard/dashboardUi.tsx` | `useCountUp()` (Zahl läuft hoch, respektiert `prefers-reduced-motion`), `StatCard`, `SeriesChart`, `BarList`, `Panel` - von Hand statt Diagrammbibliothek | | `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 224d7d1..2ae19ed 100644 --- a/docs/10-glossar.md +++ b/docs/10-glossar.md @@ -22,6 +22,7 @@ Quelltext. | **Match** / Treffer | Die Bewertung **eines** Projekts für **einen** Agenten: Prozentwert, Pro-Liste, Contra-Liste, Status. Tabelle `matches`. Ein Projekt erzeugt so viele Treffer, wie es aktive Agenten gibt. | | **Opportunity** | Geschäft in Arbeit: Phase, Tagessatz und Laufzeit oder Projektpreis, Wahrscheinlichkeit, Ansprechpartner. Tabelle `crm_opportunities`. | | **Akquise** | Eingang für gefundene Projekte, bevor daraus eine Opportunity wird. Tabelle `crm_acquisitions`. Nach 14 Tagen im **Archiv**, nach 90 Tagen gelöscht. | +| **Bewerbung** | Ein Eintrag in der Akquise. Jeder Eintrag bedeutet, dass ein Beraterprofil raus ist - so zählt es das Dashboard. | | **Auswahllisten** (Akquise) | Inhalte der Dropdowns *Von*, *Kanal*, *Berater*. Tabelle `crm_acquisition_options`, gepflegt unter *Einstellungen → Akquise*. *Anbieter* ist Freitext mit Vorschlägen. | | **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. | diff --git a/server/src/routes/acquisitions.js b/server/src/routes/acquisitions.js index dfb3177..3c07791 100644 --- a/server/src/routes/acquisitions.js +++ b/server/src/routes/acquisitions.js @@ -175,16 +175,50 @@ async function purgeOldEntries() { if (Date.now() - lastPurgeAt < 3_600_000) return; lastPurgeAt = Date.now(); - const [result] = await pool.query( - `DELETE FROM crm_acquisitions - WHERE converted_at IS NULL - AND found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)`, - [DELETE_AFTER_DAYS] - ); - if (result.affectedRows > 0) { - console.log( - `[Akquise] ${result.affectedRows} Eintrag/Eintraege aelter als ${DELETE_AFTER_DAYS} Tage geloescht.` + /* + * Vor dem Loeschen die Zahlen sichern: jeder Eintrag ist eine versendete + * Bewerbung, und das Dashboard rechnet ueber Jahre. Gesichert wird nur Tag, + * Berater und Anzahl - die Projektdaten sind danach wirklich weg. + * + * Sichern und Loeschen gehoeren in eine Transaktion: die Anzahl wird + * aufaddiert (ein Tag kann aus einem frueheren Lauf schon in der Historie + * stehen), und ein Abbruch zwischen beiden Schritten wuerde denselben Tag beim + * naechsten Lauf ein zweites Mal dazurechnen. + */ + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + await conn.query( + `INSERT INTO crm_acquisition_history (found_on, consultant, applications) + SELECT found_at, COALESCE(consultant, ''), COUNT(*) + FROM crm_acquisitions + WHERE converted_at IS NULL + AND found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY) + GROUP BY found_at, COALESCE(consultant, '') + ON DUPLICATE KEY UPDATE + applications = crm_acquisition_history.applications + VALUES(applications)`, + [DELETE_AFTER_DAYS] ); + + const [result] = await conn.query( + `DELETE FROM crm_acquisitions + WHERE converted_at IS NULL + AND found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)`, + [DELETE_AFTER_DAYS] + ); + await conn.commit(); + + if (result.affectedRows > 0) { + console.log( + `[Akquise] ${result.affectedRows} Eintrag/Eintraege aelter als ${DELETE_AFTER_DAYS} Tage geloescht (Anzahl im Verlauf gesichert).` + ); + } + } catch (err) { + await conn.rollback().catch(() => {}); + // Nicht weiterwerfen: das Aufraeumen darf die Liste nicht blockieren. + console.warn(`[Akquise] Aufraeumen fehlgeschlagen: ${err.message}`); + } finally { + conn.release(); } } diff --git a/server/src/routes/dashboard.js b/server/src/routes/dashboard.js new file mode 100644 index 0000000..9db5a56 --- /dev/null +++ b/server/src/routes/dashboard.js @@ -0,0 +1,143 @@ +import express from 'express'; +import { pool } from '../db.js'; +import { requireAuth } from '../middleware/auth.js'; + +const router = express.Router(); +router.use(requireAuth); + +/* + * Zahlen fuer das Dashboard. Drei Reihen ueber der Zeit: + * + * Bewerbungen - Eintraege in der Akquise. Jeder Eintrag ist eine versendete + * Bewerbung (so vom Nutzer festgelegt), gezaehlt nach `found_at`. + * Gewandelt - daraus wurde eine Opportunity, gezaehlt nach `converted_at`. + * Gewonnen - Opportunity in der Phase "Beauftragt", gezaehlt nach `won_at`. + * + * Die Akquise loescht Eintraege nach 90 Tagen, sichert vorher aber Tag, Berater + * und Anzahl in crm_acquisition_history. Die Bewerbungsreihe liest deshalb aus + * beiden Quellen - lebende Eintraege und Verlauf - und zaehlt sie zusammen. + * Ueberschneiden koennen sie sich nicht: gesichert wird genau, was verschwindet. + * + * Termine und die Listen zu den Phasen stehen absichtlich nicht hier: Termine und + * Opportunities laedt die App ohnehin komplett, das Dashboard filtert sie selbst. + * Ein zweiter Weg zu denselben Daten waere doppelte Pflege. + */ + +/** Wie die Zeitachse geschnitten wird. Der Ausdruck geht in GROUP BY. */ +const BUCKETS = { + tag: (column) => `DATE_FORMAT(${column}, '%Y-%m-%d')`, + monat: (column) => `DATE_FORMAT(${column}, '%Y-%m')`, + quartal: (column) => `CONCAT(YEAR(${column}), '-Q', QUARTER(${column}))`, + jahr: (column) => `YEAR(${column})`, +}; + +/** `YYYY-MM-DD` oder null. */ +function normalizeDate(value) { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value ?? '').trim()); + if (!match) return null; + const probe = new Date(`${match[0]}T00:00:00Z`); + return Number.isNaN(probe.getTime()) ? null : match[0]; +} + +/** Zeilen [{ bucket, count }] als Objekt - das Frontend legt die Achse selbst. */ +const toMap = (rows) => + Object.fromEntries(rows.map((row) => [String(row.bucket), Number(row.count)])); + +// GET /api/dashboard?from=2026-01-01&to=2026-12-31&bucket=monat +router.get('/', async (req, res, next) => { + const bucket = String(req.query.bucket ?? 'monat'); + if (!BUCKETS[bucket]) return res.status(400).json({ error: 'Unbekannte Zeiteinteilung.' }); + + const from = normalizeDate(req.query.from); + const to = normalizeDate(req.query.to); + if (!from || !to) return res.status(400).json({ error: 'Zeitraum fehlt (erwartet JJJJ-MM-TT).' }); + if (to < from) return res.status(400).json({ error: 'Das Ende darf nicht vor dem Anfang liegen.' }); + + // `to` ist inklusive: bis 31.12. heisst bis zum Ende des 31.12. + const toExclusive = `${to} 23:59:59`; + + try { + const [applicationRows] = await pool.query( + `SELECT ${BUCKETS[bucket]('found_at')} AS bucket, COUNT(*) AS count + FROM crm_acquisitions + WHERE found_at BETWEEN ? AND ? + GROUP BY bucket`, + [from, to] + ); + + const [historyRows] = await pool.query( + `SELECT ${BUCKETS[bucket]('found_on')} AS bucket, SUM(applications) AS count + FROM crm_acquisition_history + WHERE found_on BETWEEN ? AND ? + GROUP BY bucket`, + [from, to] + ); + + const [convertedRows] = await pool.query( + `SELECT ${BUCKETS[bucket]('converted_at')} AS bucket, COUNT(*) AS count + FROM crm_acquisitions + WHERE converted_at IS NOT NULL AND converted_at BETWEEN ? AND ? + GROUP BY bucket`, + [from, toExclusive] + ); + + const [wonRows] = await pool.query( + `SELECT ${BUCKETS[bucket]('won_at')} AS bucket, COUNT(*) AS count + FROM crm_opportunities + WHERE won_at IS NOT NULL AND deleted_at IS NULL AND won_at BETWEEN ? AND ? + GROUP BY bucket`, + [from, toExclusive] + ); + + // Bewerbungen je Berater - lebende Eintraege und Verlauf zusammen. + const [consultantRows] = await pool.query( + `SELECT consultant, SUM(count) AS count FROM ( + SELECT COALESCE(NULLIF(TRIM(consultant), ''), '') AS consultant, COUNT(*) AS count + FROM crm_acquisitions + WHERE found_at BETWEEN ? AND ? + GROUP BY 1 + UNION ALL + SELECT consultant, SUM(applications) + FROM crm_acquisition_history + WHERE found_on BETWEEN ? AND ? + GROUP BY 1 + ) AS beide + GROUP BY consultant + ORDER BY count DESC, consultant ASC`, + [from, to, from, to] + ); + + const live = toMap(applicationRows); + const history = toMap(historyRows); + const applications = { ...live }; + for (const [key, value] of Object.entries(history)) { + applications[key] = (applications[key] ?? 0) + value; + } + + const sum = (map) => Object.values(map).reduce((total, value) => total + value, 0); + const converted = toMap(convertedRows); + const won = toMap(wonRows); + + res.json({ + from, + to, + bucket, + series: { applications, converted, won }, + totals: { + applications: sum(applications), + converted: sum(converted), + won: sum(won), + }, + by_consultant: consultantRows.map((row) => ({ + // Leerer Text heisst "kein Berater hinterlegt" - das Frontend + // beschriftet es, damit hier kein Anzeigetext entsteht. + consultant: row.consultant === '' ? null : row.consultant, + applications: Number(row.count), + })), + }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/server/src/routes/opportunities.js b/server/src/routes/opportunities.js index 6c5dec3..5c32dc2 100644 --- a/server/src/routes/opportunities.js +++ b/server/src/routes/opportunities.js @@ -310,11 +310,13 @@ router.post('/', async (req, res, next) => { try { await conn.beginTransaction(); const [result] = await conn.query( + // Wer direkt in 'Beauftragt' anlegt, hat gerade gewonnen - dann steht das + // Datum sofort. `INSERT INTO crm_opportunities - (title, company, description, stage, probability, daily_rate, duration_days, + (title, company, description, stage, won_at, probability, daily_rate, duration_days, fixed_price, start_date, end_date, last_interaction_at, last_interaction_note, next_step, tags, notes, owner_id, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ${stage === 'Beauftragt' ? 'UTC_TIMESTAMP()' : 'NULL'}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ title, emptyToNull(req.body?.company), @@ -387,6 +389,18 @@ router.patch('/:id', async (req, res, next) => { if (!STAGES.has(req.body.stage)) return res.status(400).json({ error: 'Ungültige Phase.' }); fields.push('stage = ?'); values.push(req.body.stage); + + /* + * Zeitpunkt des Gewinns mitschreiben. Der erste Wechsel nach 'Beauftragt' + * zaehlt; ein zweites Speichern derselben Phase darf das Datum nicht nach + * vorn schieben. Geht die Opportunity wieder heraus, wird es geleert - + * sonst zaehlt das Dashboard einen Gewinn, den es nicht gibt. + */ + if (req.body.stage === 'Beauftragt' && existing.stage !== 'Beauftragt') { + fields.push('won_at = UTC_TIMESTAMP()'); + } else if (req.body.stage !== 'Beauftragt' && existing.stage === 'Beauftragt') { + fields.push('won_at = NULL'); + } } if (req.body?.probability !== undefined) { diff --git a/server/src/schema.sql b/server/src/schema.sql index 13bf416..2d9a012 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -158,6 +158,22 @@ ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS fixed_price DECIMAL(12,2) * `deleted_by` traegt bewusst keinen Fremdschluessel: Spalten lassen sich per * ADD COLUMN IF NOT EXISTS idempotent nachziehen, ein Fremdschluessel nicht. */ +/* + * Zeitpunkt des Gewinns. Ohne ihn liesse sich "wie viele Projekte haben wir im + * Mai gewonnen" nicht beantworten - die Phase allein sagt nur, wie es jetzt + * steht. Gesetzt beim Wechsel nach 'Beauftragt', geleert beim Wechsel heraus. + */ +ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS won_at DATETIME NULL AFTER stage; +ALTER TABLE crm_opportunities ADD INDEX IF NOT EXISTS idx_crm_opp_won (won_at); + +/* + * Zeitpunkt des Gewinns. Ohne ihn liesse sich "wie viele Projekte haben wir im + * Mai gewonnen" nicht beantworten - die Phase allein sagt nur, wie es jetzt + * steht. Gesetzt beim Wechsel nach 'Beauftragt', geleert beim Wechsel heraus. + */ +ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS won_at DATETIME NULL AFTER stage; +ALTER TABLE crm_opportunities ADD INDEX IF NOT EXISTS idx_crm_opp_won (won_at); + ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS deleted_at DATETIME NULL AFTER created_by; ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS deleted_by INT UNSIGNED NULL AFTER deleted_at; ALTER TABLE crm_opportunities ADD INDEX IF NOT EXISTS idx_crm_opp_deleted (deleted_at); @@ -432,6 +448,40 @@ CREATE TABLE IF NOT EXISTS crm_acquisitions ( * Der Unique-Schluessel greift ueber die Kollation utf8mb4_unicode_ci auch bei * abweichender Gross-/Kleinschreibung: "mail" ist neben "Mail" kein neuer Wert. */ +/* + * Verdichteter Verlauf der Akquise. Jeder Eintrag in crm_acquisitions ist eine + * versendete Bewerbung; nach 90 Tagen wird er geloescht (siehe dort). Damit das + * Dashboard trotzdem ueber Jahre rechnen kann, wird vor dem Loeschen genau das + * gesichert, was die Auswertung braucht: Tag, Berater, Anzahl. + * + * Bewusst nur Zahlen, keine Projektdaten - "geloescht" bleibt damit auch + * geloescht. Der Primaerschluessel erlaubt kein NULL, deshalb steht fuer "ohne + * Berater" der leere Text. + */ +CREATE TABLE IF NOT EXISTS crm_acquisition_history ( + found_on DATE NOT NULL, + consultant VARCHAR(190) NOT NULL DEFAULT '', + applications INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (found_on, consultant) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +/* + * Verdichteter Verlauf der Akquise. Jeder Eintrag in crm_acquisitions ist eine + * versendete Bewerbung; nach 90 Tagen wird er geloescht (siehe dort). Damit das + * Dashboard trotzdem ueber Jahre rechnen kann, wird vor dem Loeschen genau das + * gesichert, was die Auswertung braucht: Tag, Berater, Anzahl. + * + * Bewusst nur Zahlen, keine Projektdaten - "geloescht" bleibt damit auch + * geloescht. Der Primaerschluessel erlaubt kein NULL, deshalb steht fuer "ohne + * Berater" der leere Text. + */ +CREATE TABLE IF NOT EXISTS crm_acquisition_history ( + found_on DATE NOT NULL, + consultant VARCHAR(190) NOT NULL DEFAULT '', + applications INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (found_on, consultant) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS crm_acquisition_options ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, field ENUM('sender','channel','consultant') NOT NULL, diff --git a/server/src/server.js b/server/src/server.js index 064f0df..8e74a6f 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -10,6 +10,7 @@ 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 dashboardRoutes from './routes/dashboard.js'; import scannerRoutes from './routes/scanner.js'; import mailRoutes from './routes/mail.js'; import { requireAuth } from './middleware/auth.js'; @@ -54,6 +55,9 @@ app.use('/api/acquisitions', acquisitionRoutes); // Zahlen fuer die Seitenleiste - eine Anfrage fuer alle Zaehler. app.use('/api/badges', badgeRoutes); +// Auswertungen des Dashboards. +app.use('/api/dashboard', dashboardRoutes); + // 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 aa8a0fa..6467b1a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { Header } from './components/Header'; import { LoginScreen } from './components/LoginScreen'; import { Leads } from './components/leads/Leads'; import { MailPoolLeads } from './components/leads/MailPoolLeads'; +import { Dashboard } from './components/dashboard/Dashboard'; import { Acquisitions } from './components/acquisition/Acquisitions'; import { Opportunities } from './components/opportunities/Opportunities'; import { OpportunityTrash } from './components/opportunities/OpportunityTrash'; @@ -50,7 +51,7 @@ const Splash: React.FC = () => ( ); /** Erster Reiter im Menue - hier startet die App. */ -const DEFAULT_VIEW: ViewState = 'Opportunities'; +const DEFAULT_VIEW: ViewState = 'Dashboard'; const App: React.FC = () => { const { user, isLoading } = useAuth(); @@ -160,6 +161,7 @@ const App: React.FC = () => { // Untertitel der Kopfzeile. Die Scanner-Reiter bringen ihre Kennzahlen selbst // mit, deshalb steht hier nur, worum es auf der Seite geht. const STATIC_SUBTITLES: Partial> = { + Dashboard: 'Wie der Vertrieb läuft - Bewerbungen, Opportunities, gewonnene Projekte', Akquise: 'Gefundene Projekte - eintragen, prüfen, zur Opportunity machen', 'Opportunity-Papierkorb': 'Gelöschte Opportunities - nach 90 Tagen endgültig entfernt', Archiv: 'Akquise-Einträge älter als 14 Tage - nach 90 Tagen werden sie gelöscht', @@ -216,6 +218,9 @@ const App: React.FC = () => { />
+ {currentView === 'Dashboard' && ( + + )} {currentView === 'Akquise' && loadData(true)} />} {currentView === 'Archiv' && ( loadData(true)} /> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 12f24fd..472cda1 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -62,6 +62,9 @@ const CONTACTS_GROUP: MenuGroup = { ], }; +const DASHBOARD_ICON = + 'M4 5a1 1 0 011-1h4a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1V5zm10 0a1 1 0 011-1h4a1 1 0 011 1v3a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 16a1 1 0 011-1h4a1 1 0 011 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-3zm10-4a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1v-7z'; + const ACQUISITION_ICON = 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01'; const ARCHIVE_ICON = @@ -146,6 +149,7 @@ const SCANNER_GROUP: MenuGroup = { // Kategorien in Anzeigereihenfolge, einzelne Punkte und Gruppen gemischt. // Einstellungen bleibt separat am unteren Rand. const CATEGORY_ENTRIES: (MenuItem | MenuGroup)[] = [ + { name: 'Dashboard', icon: DASHBOARD_ICON }, ACQUISITION_GROUP, OPPORTUNITIES_GROUP, { @@ -351,8 +355,14 @@ export const Sidebar: React.FC = ({ }`} > {/* Logo */} -
{ + onViewChange('Dashboard'); + if (isMobile) onToggle(); + }} + title="Zum Dashboard" + aria-label="Zum Dashboard" + className={`flex shrink-0 items-center border-b border-ink-100 transition hover:bg-ink-50 ${ showLabels ? 'px-5 py-5' : 'justify-center px-0 py-5' }`} > @@ -363,7 +373,7 @@ export const Sidebar: React.FC = ({ p
)} -
+ {/* Kategorien */}