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:
co-authored by
Claude Opus 5
parent
1ac0e2d2c7
commit
42639c65fa
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||

|

|
||||||
* 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** |
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|||||||
@@ -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
@@ -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. |
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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'];
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user