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 = () => {
/>