feat(opportunities): Phasen des Vermittlungsprozesses, Kanban zuerst, Zahlen im Menue

Drei Dinge:

- Die Phasen sind jetzt die des Vermittlungsprozesses: Neu, Beworben,
  Erstgespraech Berater, Kundenvorstellung, Interview Kunde, Zusage, Beauftragt,
  Absage. Offen ist alles bis einschliesslich Zusage - dort ist der Einsatz
  bestaetigt, die Beauftragung steht aber noch aus. migrateOpportunityStages()
  stellt bestehende Datenbanken um und setzt alle vorhandenen Opportunities auf
  "Neu": in welcher der neuen Phasen ein Projekt steckt, sagt der alte Trichter
  nicht (so mit dem Nutzer entschieden).

  Der Datenbankwert heisst 'Erstgespraech Berater' ohne Umlaut - Projektkonvention
  fuer Datenbankwerte. Angezeigt wird er ueber STAGE_LABELS; dazu gibt es
  STAGE_HINTS mit der Erlaeuterung je Phase als Titelhinweis an Auswahl, Badge
  und Spaltenkopf.

- Kanban ist die erste Ansicht der Opportunities. Die Pipeline ist die
  Arbeitsansicht, Kachel und Liste sind zum Nachschauen.

- Hinter "Archiv", beiden "Papierkorb"-Punkten und "Unpassend" steht die Anzahl,
  wie schon bei "Aus Mails". Die Zahlen kommen aus einem eigenen Endpunkt
  (/api/badges), weil sie alle ins Menue gehoeren und zusammen kommen sollen;
  Inhalte laedt weiter jede Ansicht selbst. Der Scanner liegt in einer eigenen
  Datenbank und kann fehlen - sein Zaehler ist dann null und die Leiste zeigt
  dort einfach keine Zahl. Der Aufruf haengt bewusst hinter den Daten und nicht
  im Promise.all: faellt er aus, bleibt das Menue ohne Zahlen statt die Ansicht
  mit einem Fehler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-08-18 14:34:41 +02:00
co-authored by Claude Opus 5
parent 1ac0e2d2c7
commit 42639c65fa
17 changed files with 313 additions and 55 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ erDiagram
crm_opportunities { crm_opportunities {
int id PK int id PK
varchar title varchar title
enum stage "Neu .. Verloren" enum stage "Neu .. Beauftragt | Absage"
tinyint probability "0-100" tinyint probability "0-100"
decimal daily_rate decimal daily_rate
decimal duration_days "Personentage, halbe möglich" decimal duration_days "Personentage, halbe möglich"
+38 -9
View File
@@ -34,20 +34,20 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
│ Kategorien │ Titel der Ansicht [Hell/Dunkel] │ │ Kategorien │ Titel der Ansicht [Hell/Dunkel] │
│ Akquise ▾ │ │ │ Akquise ▾ │ │
│ Eingang │ │ │ Eingang │ │
│ Archiv │ │ │ Archiv 3│ │
│ Opportuni…▾ │───────────────────────────────────────────┤ │ Opportuni…▾ │───────────────────────────────────────────┤
│ Alle │ │ │ Alle │ │
│ Papierkorb │ │ │ Papierkorb2│ │
│ Interviews │ │ │ Interviews │ │
│ Kontakte ▾ │ Inhalt │ │ Kontakte ▾ │ Inhalt │
│ Alle Kont. │ │ │ Alle Kont. │ │
│ Aus Mails 5│ │ │ Aus Mails 5│ │
│ Mails ▾ │ │ │ Mails ▾ │ │
│ Postfach │ │ │ Postfach │ │
│ Papierkorb │ │ │ Papierkorb7│ │
│ Scanner ▾ │ │ │ Scanner ▾ │ │
│ Dashboard │ │ │ Dashboard │ │
│ Unpassend │ │ │ Unpassend 4│ │
│ Suchagenten│ │ │ Suchagenten│ │
│ Börsen-… │ │ │ Börsen-… │ │
├──────────────┤ │ ├──────────────┤ │
@@ -63,9 +63,12 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
auf den Schalter folgt wieder der Systemeinstellung. auf den Schalter folgt wieder der Systemeinstellung.
![Dieselbe Ansicht im dunklen Design](bilder/aus-mails-dunkel.png) ![Dieselbe Ansicht im dunklen Design](bilder/aus-mails-dunkel.png)
* Die Menüpunkte **Kontakte** und **Scanner** klappen beim Anklicken ihre * Die Menüpunkte **Akquise**, **Opportunities**, **Kontakte**, **Mails** und
Unterpunkte auf. Die Zahl neben *Aus Mails* sagt, wie viele Funde aus dem **Scanner** klappen beim Anklicken ihre Unterpunkte auf. Hinter einigen steht
Postfach noch auf eine Entscheidung warten. 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 * 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 **X** oben rechts, die Taste **Esc** oder ein **Klick daneben** in den
abgedunkelten Hintergrund. Liegen zwei Fenster übereinander, betrifft das 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 | | **Kachel** | Überblick mit Volumen, Tagessatz oder Projektpreis, Laufzeit, letzter Interaktion |
| **Liste** | viele Einträge vergleichen, Spaltenköpfe sortieren | | **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) ![Opportunities in der Kachelansicht](bilder/opportunities-kacheln.png)
Die **Pipeline** zeigt dieselben Opportunities nach Phasen sortiert. Über jeder 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. 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 **Volumen** rechnet die Anwendung selbst: Tagessatz x Personentage. Das
**gewichtete** Volumen multipliziert das noch mit der Abschlusswahrscheinlichkeit. **gewichtete** Volumen multipliziert das noch mit der Abschlusswahrscheinlichkeit.
+2
View File
@@ -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 | | `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 | | `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 | | `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 | | `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: Wiederkehrende Muster in diesen Routern:
@@ -266,6 +267,7 @@ Warum genau diese Konstanten festgelegt sind, steht in
| Datei | Inhalt | | Datei | Inhalt |
| --- | --- | | --- | --- |
| `components/opportunities/OpportunityTrash.tsx` | Papierkorb der Opportunities: wiederherstellen, leeren, Restlaufzeit je Eintrag | | `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/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/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 | | `components/acquisition/AcquisitionTable.tsx` | die Liste; `safeLink()` lässt nur http(s) zum Link werden |
+1 -1
View File
@@ -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. | | **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** (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. | | **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`). | | **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. | | **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. | | **Scraper** | Der Teil, der mit einem echten Browser (Playwright/Chromium) die Suchseiten der Börsen abruft. |
+43
View File
@@ -49,6 +49,7 @@ export async function initDatabase() {
} }
await migrateLeadKind(); await migrateLeadKind();
await migrateOpportunityStages();
await migrateMailPool(); await migrateMailPool();
await seedAcquisitionOptions(); await seedAcquisitionOptions();
await dropProviderOptions(); 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 * Einmalige Umstellung: die vom Postfachabgleich bereits angelegten Kontakte
* (source = 'Mail') wandern in den Vorbereich "Aus Mails" (mail_pool = 1). * (source = 'Mail') wandern in den Vorbereich "Aus Mails" (mail_pool = 1).
+60
View File
@@ -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;
+9 -5
View File
@@ -5,13 +5,17 @@ import { requireAuth } from '../middleware/auth.js';
const router = express.Router(); const router = express.Router();
router.use(requireAuth); 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([ const STAGES = new Set([
'Neu', 'Neu',
'Qualifizierung', 'Beworben',
'Angebot', 'Erstgespraech Berater',
'Verhandlung', 'Kundenvorstellung',
'Gewonnen', 'Interview Kunde',
'Verloren', 'Zusage',
'Beauftragt',
'Absage',
]); ]);
const TEXT_FIELDS = ['title', 'company', 'description', 'last_interaction_note', 'next_step', 'notes']; const TEXT_FIELDS = ['title', 'company', 'description', 'last_interaction_note', 'next_step', 'notes'];
+4 -1
View File
@@ -114,7 +114,10 @@ CREATE TABLE IF NOT EXISTS crm_opportunities (
title VARCHAR(190) NOT NULL, title VARCHAR(190) NOT NULL,
company VARCHAR(190) NULL, company VARCHAR(190) NULL,
description TEXT 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', NOT NULL DEFAULT 'Neu',
probability TINYINT UNSIGNED NOT NULL DEFAULT 10, -- 0-100 % probability TINYINT UNSIGNED NOT NULL DEFAULT 10, -- 0-100 %
daily_rate DECIMAL(10,2) NULL, -- Tagessatz in Euro daily_rate DECIMAL(10,2) NULL, -- Tagessatz in Euro
+4
View File
@@ -9,6 +9,7 @@ import connectionRoutes from './routes/connections.js';
import eventRoutes from './routes/events.js'; import eventRoutes from './routes/events.js';
import opportunityRoutes from './routes/opportunities.js'; import opportunityRoutes from './routes/opportunities.js';
import acquisitionRoutes from './routes/acquisitions.js'; import acquisitionRoutes from './routes/acquisitions.js';
import badgeRoutes from './routes/badges.js';
import scannerRoutes from './routes/scanner.js'; import scannerRoutes from './routes/scanner.js';
import mailRoutes from './routes/mail.js'; import mailRoutes from './routes/mail.js';
import { requireAuth } from './middleware/auth.js'; import { requireAuth } from './middleware/auth.js';
@@ -50,6 +51,9 @@ app.use('/api/events', eventRoutes);
app.use('/api/opportunities', opportunityRoutes); app.use('/api/opportunities', opportunityRoutes);
app.use('/api/acquisitions', acquisitionRoutes); 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 // Projekt Scanner. Komplett hinter der CRM-Anmeldung - der urspruengliche
// HTTP-Basic-Auth-Zugang des Scanners entfaellt damit. // HTTP-Basic-Auth-Zugang des Scanners entfaellt damit.
app.use('/api/scanner', requireAuth, scannerRoutes); app.use('/api/scanner', requireAuth, scannerRoutes);
+27 -2
View File
@@ -18,7 +18,15 @@ import { ScannerPortals } from './components/scanner/ScannerPortals';
import { useToast } from './components/Toast'; import { useToast } from './components/Toast';
import { NavigationProvider } from './lib/navigation'; import { NavigationProvider } from './lib/navigation';
import { useAuth } from './lib/auth'; 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 { import {
OPEN_STAGES, OPEN_STAGES,
formatEuro, formatEuro,
@@ -71,6 +79,7 @@ const App: React.FC = () => {
const [users, setUsers] = useState<AppUser[]>([]); const [users, setUsers] = useState<AppUser[]>([]);
const [events, setEvents] = useState<CalendarEvent[]>([]); const [events, setEvents] = useState<CalendarEvent[]>([]);
const [opportunities, setOpportunities] = useState<Opportunity[]>([]); const [opportunities, setOpportunities] = useState<Opportunity[]>([]);
const [badges, setBadges] = useState<SidebarBadges | null>(null);
const [isDataLoading, setIsDataLoading] = useState(false); const [isDataLoading, setIsDataLoading] = useState(false);
/** /**
@@ -104,6 +113,16 @@ const App: React.FC = () => {
setEvents(eventRows); setEvents(eventRows);
setUsers(userRows); setUsers(userRows);
setOpportunities(opportunityRows); 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) { } catch (err) {
addToast(err instanceof Error ? err.message : 'Daten konnten nicht geladen werden.', 'error'); addToast(err instanceof Error ? err.message : 'Daten konnten nicht geladen werden.', 'error');
} finally { } finally {
@@ -180,7 +199,13 @@ const App: React.FC = () => {
onViewChange={goTo} onViewChange={goTo}
isOpen={isSidebarOpen} isOpen={isSidebarOpen}
onToggle={() => setIsSidebarOpen((open) => !open)} 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,
}}
/> />
<main className="flex min-w-0 flex-1 flex-col overflow-hidden"> <main className="flex min-w-0 flex-1 flex-col overflow-hidden">
+12 -8
View File
@@ -15,8 +15,12 @@ interface SidebarProps {
onViewChange: (view: ViewState) => void; onViewChange: (view: ViewState) => void;
isOpen: boolean; isOpen: boolean;
onToggle: () => void; 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<Record<ViewState, number | null>>;
} }
interface MenuItem { interface MenuItem {
@@ -185,7 +189,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
onViewChange, onViewChange,
isOpen, isOpen,
onToggle, onToggle,
mailPoolCount = 0, counts = {},
}) => { }) => {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -208,7 +212,10 @@ export const Sidebar: React.FC<SidebarProps> = ({
setOpenGroups((open) => (open.includes(active.item.name) ? open : [...open, active.item.name])); setOpenGroups((open) => (open.includes(active.item.name) ? open : [...open, active.item.name]));
}, [currentView]); }, [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; const isActive = currentView === item.name;
return ( return (
<button <button
@@ -324,10 +331,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
{showLabels && isGroupOpen && ( {showLabels && isGroupOpen && (
<div className="mt-1 space-y-1"> <div className="mt-1 space-y-1">
{children.map((child) => {children.map((child) =>
renderItem(child, { renderItem(child, { indented: true, badge: counts[child.name] ?? undefined })
indented: true,
badge: child.name === 'Aus Mails' ? mailPoolCount : undefined,
})
)} )}
</div> </div>
)} )}
@@ -9,6 +9,7 @@ import {
} from './OpportunityList'; } from './OpportunityList';
import { OpportunityPipeline } from './OpportunityPipeline'; import { OpportunityPipeline } from './OpportunityPipeline';
import { OpportunityModal } from './OpportunityModal'; import { OpportunityModal } from './OpportunityModal';
import { STAGE_LABELS } from './opportunityUi';
import { ConfirmDialog } from '../ui/Modal'; import { ConfirmDialog } from '../ui/Modal';
import { useToast } from '../Toast'; import { useToast } from '../Toast';
import { opportunitiesApi } from '../../lib/api'; import { opportunitiesApi } from '../../lib/api';
@@ -100,7 +101,9 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
reload, reload,
}) => { }) => {
const { addToast } = useToast(); const { addToast } = useToast();
const [viewMode, setViewMode] = useState<OpportunityViewMode>('grid'); // Kanban zuerst: die Pipeline ist die Arbeitsansicht, Kachel und Liste sind
// zum Nachschauen.
const [viewMode, setViewMode] = useState<OpportunityViewMode>('pipeline');
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [stageFilter, setStageFilter] = useState<OpportunityStage | 'Alle' | 'Offen'>('Alle'); const [stageFilter, setStageFilter] = useState<OpportunityStage | 'Alle' | 'Offen'>('Alle');
const [sortConfig, setSortConfig] = useState<OpportunitySortConfig>({ const [sortConfig, setSortConfig] = useState<OpportunitySortConfig>({
@@ -191,7 +194,7 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
const changeStage = async (opportunity: Opportunity, stage: OpportunityStage) => { const changeStage = async (opportunity: Opportunity, stage: OpportunityStage) => {
try { try {
await opportunitiesApi.update(opportunity.id, { stage }); await opportunitiesApi.update(opportunity.id, { stage });
addToast(`"${opportunity.title}" ist jetzt in Phase ${stage}.`, 'success'); addToast(`„${opportunity.title}" ist jetzt in Phase ${STAGE_LABELS[stage]}.`, 'success');
reload(); reload();
} catch (err) { } catch (err) {
addToast(err instanceof Error ? err.message : 'Phase konnte nicht geändert werden.', 'error'); addToast(err instanceof Error ? err.message : 'Phase konnte nicht geändert werden.', 'error');
@@ -255,7 +258,7 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
<option value="Offen">Nur offene</option> <option value="Offen">Nur offene</option>
{OPPORTUNITY_STAGES.map((stage) => ( {OPPORTUNITY_STAGES.map((stage) => (
<option key={stage} value={stage}> <option key={stage} value={stage}>
{stage} {STAGE_LABELS[stage]}
</option> </option>
))} ))}
</select> </select>
@@ -3,7 +3,7 @@ import { Modal } from '../ui/Modal';
import { LeadModal } from '../leads/LeadModal'; import { LeadModal } from '../leads/LeadModal';
import { DictateTextarea, DictateButton, DictationHint } from '../ui/DictationField'; import { DictateTextarea, DictateButton, DictationHint } from '../ui/DictationField';
import { Avatar, inputClass, labelClass } from '../leads/leadUi'; import { Avatar, inputClass, labelClass } from '../leads/leadUi';
import { STAGE_COLORS } from './opportunityUi'; import { STAGE_COLORS, STAGE_HINTS, STAGE_LABELS } from './opportunityUi';
import { useToast } from '../Toast'; import { useToast } from '../Toast';
import { opportunitiesApi } from '../../lib/api'; import { opportunitiesApi } from '../../lib/api';
import { import {
@@ -274,11 +274,12 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
value={form.stage} value={form.stage}
onChange={(event) => set('stage', event.target.value as OpportunityStage)} onChange={(event) => set('stage', event.target.value as OpportunityStage)}
className={inputClass} className={inputClass}
title={STAGE_HINTS[form.stage]}
style={{ borderLeft: `4px solid ${STAGE_COLORS[form.stage]}` }} style={{ borderLeft: `4px solid ${STAGE_COLORS[form.stage]}` }}
> >
{OPPORTUNITY_STAGES.map((stage) => ( {OPPORTUNITY_STAGES.map((stage) => (
<option key={stage} value={stage}> <option key={stage} value={stage}>
{stage} {STAGE_LABELS[stage]}
</option> </option>
))} ))}
</select> </select>
@@ -1,6 +1,14 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { UserAvatar } from '../ui/UserAvatar'; import { UserAvatar } from '../ui/UserAvatar';
import { STAGE_COLORS, StageBadge, formatDate, formatDays, relativeDays } from './opportunityUi'; import {
STAGE_COLORS,
STAGE_HINTS,
STAGE_LABELS,
StageBadge,
formatDate,
formatDays,
relativeDays,
} from './opportunityUi';
import { import {
OPPORTUNITY_STAGES, OPPORTUNITY_STAGES,
contactFullName, contactFullName,
@@ -71,7 +79,7 @@ export const OpportunityPipeline: React.FC<OpportunityPipelineProps> = ({
<div className="flex items-center gap-2 border-b border-ink-100 px-3 py-2.5"> <div className="flex items-center gap-2 border-b border-ink-100 px-3 py-2.5">
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: color }} /> <span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: color }} />
<span className="text-xs font-extrabold uppercase tracking-wider text-ink-600"> <span className="text-xs font-extrabold uppercase tracking-wider text-ink-600">
{stage} {STAGE_LABELS[stage]}
</span> </span>
<span className="ml-auto rounded-md bg-ink-100 px-1.5 py-0.5 text-[10px] font-extrabold text-ink-500"> <span className="ml-auto rounded-md bg-ink-100 px-1.5 py-0.5 text-[10px] font-extrabold text-ink-500">
{items.length} {items.length}
@@ -194,7 +202,7 @@ export const OpportunityPipeline: React.FC<OpportunityPipelineProps> = ({
> >
{OPPORTUNITY_STAGES.map((option) => ( {OPPORTUNITY_STAGES.map((option) => (
<option key={option} value={option}> <option key={option} value={option}>
{option} {STAGE_LABELS[option]}
</option> </option>
))} ))}
</select> </select>
+48 -12
View File
@@ -1,24 +1,59 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import type { OpportunityStage } from '../../types'; import type { OpportunityStage } from '../../types';
/** Phasenfarben - dieselbe Logik wie die Statusfarben der Kontakte. */ /*
* Beschriftung der Phasen. Der gespeicherte Wert bleibt ASCII (Projektkonvention
* fuer Datenbankwerte), gelesen wird "Erstgespraech" aber mit ae als aa - deshalb
* diese Zuordnung. Ueberall, wo eine Phase angezeigt wird, laeuft sie darueber.
*/
export const STAGE_LABELS: Record<OpportunityStage, string> = {
Neu: 'Neu',
Beworben: 'Beworben',
'Erstgespraech Berater': 'Erstgespräch Berater',
Kundenvorstellung: 'Kundenvorstellung',
'Interview Kunde': 'Interview Kunde',
Zusage: 'Zusage',
Beauftragt: 'Beauftragt',
Absage: 'Absage',
};
/** Kurze Erlaeuterung je Phase - als Titelhinweis an Auswahl und Spaltenkopf. */
export const STAGE_HINTS: Record<OpportunityStage, string> = {
Neu: 'Projekt wurde identifiziert und noch nicht aktiv bearbeitet.',
Beworben: 'Beraterprofil wurde an Vermittler oder Direktkunden übermittelt.',
'Erstgespraech 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.',
};
/** Phasenfarben - der Weg von grau ueber die Zwischenschritte ins CI-Gruen. */
export const STAGE_STYLES: Record<OpportunityStage, string> = { export const STAGE_STYLES: Record<OpportunityStage, string> = {
Neu: 'bg-ink-100 text-ink-600 border-ink-200', Neu: 'bg-ink-100 text-ink-600 border-ink-200',
Qualifizierung: 'bg-sky-50 text-sky-700 border-sky-200', Beworben: 'bg-sky-50 text-sky-700 border-sky-200',
Angebot: 'bg-amber-50 text-amber-700 border-amber-200', 'Erstgespraech Berater': 'bg-cyan-50 text-cyan-700 border-cyan-200',
Verhandlung: 'bg-violet-50 text-violet-700 border-violet-200', Kundenvorstellung: 'bg-amber-50 text-amber-700 border-amber-200',
Gewonnen: 'bg-brand-50 text-brand-700 border-brand-200', 'Interview Kunde': 'bg-orange-50 text-orange-700 border-orange-200',
Verloren: 'bg-red-50 text-red-700 border-red-200', Zusage: 'bg-violet-50 text-violet-700 border-violet-200',
Beauftragt: 'bg-brand-50 text-brand-700 border-brand-200',
Absage: 'bg-red-50 text-red-700 border-red-200',
}; };
/** Akzentfarbe fuer Kartenrand und Pipeline-Spalten. */ /** Akzentfarbe fuer Kartenrand und Pipeline-Spalten. */
export const STAGE_COLORS: Record<OpportunityStage, string> = { export const STAGE_COLORS: Record<OpportunityStage, string> = {
Neu: '#9a9a9a', Neu: '#9a9a9a',
Qualifizierung: '#0284c7', Beworben: '#0284c7',
Angebot: '#d97706', 'Erstgespraech Berater': '#0891b2',
Verhandlung: '#7c3aed', Kundenvorstellung: '#d97706',
Gewonnen: '#6ba04a', 'Interview Kunde': '#ea580c',
Verloren: '#dc2626', Zusage: '#7c3aed',
Beauftragt: '#6ba04a',
Absage: '#dc2626',
}; };
export const StageBadge: React.FC<{ stage: OpportunityStage; className?: string }> = ({ export const StageBadge: React.FC<{ stage: OpportunityStage; className?: string }> = ({
@@ -26,9 +61,10 @@ export const StageBadge: React.FC<{ stage: OpportunityStage; className?: string
className = '', className = '',
}) => ( }) => (
<span <span
title={STAGE_HINTS[stage]}
className={`inline-flex items-center rounded-md border px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider ${STAGE_STYLES[stage]} ${className}`} className={`inline-flex items-center rounded-md border px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider ${STAGE_STYLES[stage]} ${className}`}
> >
{stage} {STAGE_LABELS[stage]}
</span> </span>
); );
+17
View File
@@ -209,6 +209,23 @@ export const opportunitiesApi = {
request<{ ok: true; purged: number }>('/opportunities/trash/empty', { method: 'POST' }), request<{ ok: true; purged: number }>('/opportunities/trash/empty', { method: 'POST' }),
}; };
// ---------- Zaehler der Seitenleiste ----------
/**
* Zahlen hinter den Menuepunkten. `scanner_rejected` ist null, wenn die
* Scanner-Datenbank nicht erreichbar ist - dann zeigt die Leiste dort keine Zahl.
*/
export interface SidebarBadges {
acquisition_archive: number;
opportunity_trash: number;
mail_trash: number;
scanner_rejected: number | null;
}
export const badgesApi = {
get: () => request<SidebarBadges>('/badges'),
};
// ---------- Akquise ---------- // ---------- Akquise ----------
/** /**
+27 -8
View File
@@ -204,20 +204,39 @@ export type LeadViewMode = 'grid' | 'list' | 'network';
// ---------- Opportunities ---------- // ---------- Opportunities ----------
/** Phasen der Vertriebspipeline, in dieser Reihenfolge. */ /**
* Phasen des Vermittlungsprozesses, in dieser Reihenfolge - vom identifizierten
* Projekt bis zur Beauftragung, plus die Absage am Ende.
*
* "Erstgespraech Berater" steht bewusst ohne Umlaut: der Wert liegt so in der
* Datenbank (Projektkonvention fuer Datenbankwerte). Gelesen wird er ueber
* STAGE_LABELS in opportunityUi.tsx.
*/
export const OPPORTUNITY_STAGES = [ export const OPPORTUNITY_STAGES = [
'Neu', 'Neu',
'Qualifizierung', 'Beworben',
'Angebot', 'Erstgespraech Berater',
'Verhandlung', 'Kundenvorstellung',
'Gewonnen', 'Interview Kunde',
'Verloren', 'Zusage',
'Beauftragt',
'Absage',
] as const; ] as const;
export type OpportunityStage = (typeof OPPORTUNITY_STAGES)[number]; export type OpportunityStage = (typeof OPPORTUNITY_STAGES)[number];
/** Phasen, in denen die Chance noch offen ist. */ /**
export const OPEN_STAGES: OpportunityStage[] = ['Neu', 'Qualifizierung', 'Angebot', 'Verhandlung']; * Phasen, in denen die Chance noch offen ist - alles bis auf das Ende. "Zusage"
* gehoert dazu: der Einsatz ist bestaetigt, die Beauftragung steht aber noch aus.
*/
export const OPEN_STAGES: OpportunityStage[] = [
'Neu',
'Beworben',
'Erstgespraech Berater',
'Kundenvorstellung',
'Interview Kunde',
'Zusage',
];
export interface OpportunityContact { export interface OpportunityContact {
lead_id: number; lead_id: number;