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 {
|
||||
int id PK
|
||||
varchar title
|
||||
enum stage "Neu .. Verloren"
|
||||
enum stage "Neu .. Beauftragt | Absage"
|
||||
tinyint probability "0-100"
|
||||
decimal daily_rate
|
||||
decimal duration_days "Personentage, halbe möglich"
|
||||
|
||||
@@ -34,20 +34,20 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
|
||||
│ Kategorien │ Titel der Ansicht [Hell/Dunkel] │
|
||||
│ Akquise ▾ │ │
|
||||
│ Eingang │ │
|
||||
│ Archiv │ │
|
||||
│ Archiv 3│ │
|
||||
│ Opportuni…▾ │───────────────────────────────────────────┤
|
||||
│ Alle │ │
|
||||
│ Papierkorb │ │
|
||||
│ Papierkorb2│ │
|
||||
│ Interviews │ │
|
||||
│ Kontakte ▾ │ Inhalt │
|
||||
│ Alle Kont. │ │
|
||||
│ Aus Mails 5│ │
|
||||
│ Mails ▾ │ │
|
||||
│ Postfach │ │
|
||||
│ Papierkorb │ │
|
||||
│ Papierkorb7│ │
|
||||
│ Scanner ▾ │ │
|
||||
│ Dashboard │ │
|
||||
│ Unpassend │ │
|
||||
│ Unpassend 4│ │
|
||||
│ Suchagenten│ │
|
||||
│ Börsen-… │ │
|
||||
├──────────────┤ │
|
||||
@@ -63,9 +63,12 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
|
||||
auf den Schalter folgt wieder der Systemeinstellung.
|
||||
|
||||

|
||||
* Die Menüpunkte **Kontakte** und **Scanner** klappen beim Anklicken ihre
|
||||
Unterpunkte auf. Die Zahl neben *Aus Mails* sagt, wie viele Funde aus dem
|
||||
Postfach noch auf eine Entscheidung warten.
|
||||
* Die Menüpunkte **Akquise**, **Opportunities**, **Kontakte**, **Mails** und
|
||||
**Scanner** klappen beim Anklicken ihre Unterpunkte auf. Hinter einigen steht
|
||||
eine Zahl: *Aus Mails* (Funde, die auf eine Entscheidung warten), *Archiv*
|
||||
(Akquise-Einträge älter als 14 Tage), beide *Papierkörbe* und *Unpassend*
|
||||
(verworfene Scanner-Treffer). Bei 0 steht dort nichts - eine Null wäre nur
|
||||
Rauschen.
|
||||
* Jedes Fenster, das sich in den Vordergrund legt, schließt auf drei Wege: das
|
||||
**X** oben rechts, die Taste **Esc** oder ein **Klick daneben** in den
|
||||
abgedunkelten Hintergrund. Liegen zwei Fenster übereinander, betrifft das
|
||||
@@ -170,14 +173,40 @@ wechselt man die Darstellung:
|
||||
| --- | --- |
|
||||
| **Kachel** | Überblick mit Volumen, Tagessatz oder Projektpreis, Laufzeit, letzter Interaktion |
|
||||
| **Liste** | viele Einträge vergleichen, Spaltenköpfe sortieren |
|
||||
| **Pipeline** | Fortschritt durch die Phasen, Karten per Maus verschieben |
|
||||
| **Pipeline** (Kanban) | Fortschritt durch die Phasen, Karten per Maus verschieben. **Standardansicht** |
|
||||
|
||||

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

|
||||

|
||||
|
||||
### Die Phasen
|
||||
|
||||
Sie folgen dem Vermittlungsprozess. Der Mauszeiger auf einer Phase zeigt dieselbe
|
||||
Erläuterung:
|
||||
|
||||
| Phase | Bedeutung |
|
||||
| --- | --- |
|
||||
| **Neu** | Projekt wurde identifiziert und noch nicht aktiv bearbeitet. |
|
||||
| **Beworben** | Beraterprofil wurde an Vermittler oder Direktkunden übermittelt. |
|
||||
| **Erstgespräch Berater** | Erstes qualifizierendes Gespräch mit dem Berater und Vermittler bzw. Direktkunden hat stattgefunden; ein reines Gespräch mit unserem Vertrieb zählt nicht. |
|
||||
| **Kundenvorstellung** | Beraterprofil wurde dem tatsächlichen Endkunden bzw. Entscheider zur Prüfung vorgestellt. |
|
||||
| **Interview Kunde** | Berater führt ein konkretes fachliches oder persönliches Gespräch mit dem Endkunden. |
|
||||
| **Zusage** | Einsatz des Beraters wurde bestätigt, die formale Beauftragung steht ggf. noch aus. |
|
||||
| **Beauftragt** | Vertrag bzw. Bestellung liegt vor und der Deal ist gewonnen. |
|
||||
| **Absage** | Projekt wird mit diesem Berater nicht weiterverfolgt. |
|
||||
|
||||
Als **offen** zählt alles bis einschließlich *Zusage* - dort ist der Einsatz
|
||||
bestätigt, aber noch nicht beauftragt. *Beauftragt* und *Absage* sind das Ende.
|
||||
Der Filter *Nur offene* nutzt genau diese Grenze, ebenso die Pipeline-Summe in
|
||||
der Kopfzeile.
|
||||
|
||||
> Bei der Umstellung am 18.08.2026 wurden **alle vorhandenen Opportunities auf
|
||||
> „Neu" gesetzt**. Der alte Trichter (Qualifizierung, Angebot, Verhandlung,
|
||||
> Gewonnen, Verloren) ließ nicht erkennen, in welcher der neuen Phasen ein
|
||||
> Projekt steckt.
|
||||
|
||||
**Volumen** rechnet die Anwendung selbst: Tagessatz x Personentage. Das
|
||||
**gewichtete** Volumen multipliziert das noch mit der Abschlusswahrscheinlichkeit.
|
||||
|
||||
@@ -93,6 +93,7 @@ Alle unter `router.use(requireAuth)`.
|
||||
| `events.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | anlegen jeder; ändern/löschen nur Ersteller oder Admin |
|
||||
| `opportunities.js` | `GET /?trash=true`, `POST /`, `PATCH /:id`, `DELETE /:id` (Papierkorb), `POST /:id/restore`, `POST /trash/empty` | ändern, löschen und wiederherstellen nur Ersteller, Zuständiger oder Admin; Leeren jeder Angemeldete |
|
||||
| `acquisitions.js` | `GET /?view=eingang\|archiv\|alle`, `POST /`, `PATCH /:id`, `DELETE /:id`, `POST /:id/opportunity`; Auswahllisten: `GET/POST /options`, `PATCH/DELETE /options/:id` | Einträge ändern/löschen nur Ersteller oder Admin; Auswahllisten jeder Angemeldete |
|
||||
| `badges.js` | `GET /` | Zahlen für die Seitenleiste: Akquise-Archiv, beide Papierkörbe, verworfene Scanner-Treffer. Der Scanner-Zähler ist `null`, wenn dessen Datenbank fehlt |
|
||||
| `mail.js` | `GET /status`, `/messages`, `/messages/:id`, `/candidates`; `POST /messages/:id/links`, `/messages/:id/restore`, `/candidates/:id/adopt`, `/candidates/:id/reject`, `/trash/empty`; `DELETE /links/:id`, `/messages/:id`; `requireAdmin`: `GET/POST /settings`, `POST /test`, `POST /sync` | lesen, zuordnen und löschen jeder; einrichten Admin |
|
||||
|
||||
Wiederkehrende Muster in diesen Routern:
|
||||
@@ -266,6 +267,7 @@ Warum genau diese Konstanten festgelegt sind, steht in
|
||||
| Datei | Inhalt |
|
||||
| --- | --- |
|
||||
| `components/opportunities/OpportunityTrash.tsx` | Papierkorb der Opportunities: wiederherstellen, leeren, Restlaufzeit je Eintrag |
|
||||
| `components/opportunities/opportunityUi.tsx` | `STAGE_LABELS` (Anzeige, mit Umlaut), `STAGE_HINTS` (Erläuterung als Titelhinweis), `STAGE_STYLES`, `STAGE_COLORS`, `StageBadge` |
|
||||
| `components/acquisition/Acquisitions.tsx` | Akquise und Archiv (`view`); Suche, Sortierung, Übernahme, Löschen |
|
||||
| `components/acquisition/AcquisitionForm.tsx` | Eingabe über der Liste; behält Datum, Anbieter, Kanal und Berater für den nächsten Eintrag |
|
||||
| `components/acquisition/AcquisitionTable.tsx` | die Liste; `safeLink()` lässt nur http(s) zum Link werden |
|
||||
|
||||
+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. |
|
||||
| **Papierkorb** (Mails) | Im CRM gelöschte Mails. Umkehrbar, bis er geleert wird. Im Postfach crm@perinco.de bleiben die Mails in jedem Fall; der Abgleich holt gelöschte nicht wieder herein. |
|
||||
| **Papierkorb** (Opportunities) | Gelöschte Opportunities, `crm_opportunities.deleted_at`. Wiederherstellbar; endgültig weg nach 90 Tagen oder beim Leeren. |
|
||||
| **Phase** / Stage | Fortschritt einer Opportunity: Neu, Qualifizierung, Angebot, Verhandlung, Gewonnen, Verloren. |
|
||||
| **Phase** / Stage | Fortschritt einer Opportunity im Vermittlungsprozess: Neu, Beworben, Erstgespräch Berater, Kundenvorstellung, Interview Kunde, Zusage, Beauftragt, Absage. Offen ist alles bis *Zusage*. In der Datenbank steht `Erstgespraech Berater` ohne Umlaut; angezeigt wird er (`STAGE_LABELS`). |
|
||||
| **Pipeline** | Zwei Bedeutungen, je nach Zusammenhang: die Kanban-Ansicht der Opportunities **oder** der Suchlauf des Scanners (`services/pipeline.js`). |
|
||||
| **Projekt** | Ein von einer Börse gescrapter Auftrag. Tabelle `projects`, Aufbewahrung 7 Tage. |
|
||||
| **Scraper** | Der Teil, der mit einem echten Browser (Playwright/Chromium) die Suchseiten der Börsen abruft. |
|
||||
|
||||
@@ -49,6 +49,7 @@ export async function initDatabase() {
|
||||
}
|
||||
|
||||
await migrateLeadKind();
|
||||
await migrateOpportunityStages();
|
||||
await migrateMailPool();
|
||||
await seedAcquisitionOptions();
|
||||
await dropProviderOptions();
|
||||
@@ -100,6 +101,48 @@ async function migrateLeadKind() {
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Einmalige Umstellung: aus dem allgemeinen Vertriebstrichter der Opportunities
|
||||
* (Neu, Qualifizierung, Angebot, Verhandlung, Gewonnen, Verloren) werden die
|
||||
* Phasen des Vermittlungsprozesses (Neu .. Beauftragt, Absage).
|
||||
*
|
||||
* Erkannt am Spaltentyp: steht 'Qualifizierung' noch im ENUM, ist die Umstellung
|
||||
* offen. Reihenfolge wie bei der Kontaktart - erst die Auswahl auf alt plus neu
|
||||
* erweitern, dann die Altwerte setzen, dann verengen. Direkt zu verengen wuerde
|
||||
* jeden Altwert zu '' machen.
|
||||
*
|
||||
* Alle vorhandenen Opportunities kommen auf 'Neu': welche Phase des neuen
|
||||
* Prozesses sie haben, sagt der alte Trichter nicht (so mit dem Nutzer
|
||||
* entschieden am 2026-08-18).
|
||||
*/
|
||||
async function migrateOpportunityStages() {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'crm_opportunities'
|
||||
AND COLUMN_NAME = 'stage' LIMIT 1`
|
||||
);
|
||||
if (!rows[0] || !String(rows[0].COLUMN_TYPE).includes("'Qualifizierung'")) return;
|
||||
|
||||
await pool.query(
|
||||
`ALTER TABLE crm_opportunities MODIFY stage
|
||||
ENUM('Neu','Qualifizierung','Angebot','Verhandlung','Gewonnen','Verloren',
|
||||
'Beworben','Erstgespraech Berater','Kundenvorstellung','Interview Kunde',
|
||||
'Zusage','Beauftragt','Absage') NOT NULL DEFAULT 'Neu'`
|
||||
);
|
||||
const [result] = await pool.query(
|
||||
`UPDATE crm_opportunities SET stage = 'Neu'
|
||||
WHERE stage IN ('Qualifizierung','Angebot','Verhandlung','Gewonnen','Verloren')`
|
||||
);
|
||||
await pool.query(
|
||||
`ALTER TABLE crm_opportunities MODIFY stage
|
||||
ENUM('Neu','Beworben','Erstgespraech Berater','Kundenvorstellung','Interview Kunde','Zusage','Beauftragt','Absage') NOT NULL DEFAULT 'Neu'`
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[Opportunities] Phasen auf den Vermittlungsprozess umgestellt - ${result.affectedRows} Eintrag/Eintraege auf "Neu" gesetzt.`
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Einmalige Umstellung: die vom Postfachabgleich bereits angelegten Kontakte
|
||||
* (source = 'Mail') wandern in den Vorbereich "Aus Mails" (mail_pool = 1).
|
||||
|
||||
@@ -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();
|
||||
router.use(requireAuth);
|
||||
|
||||
// Phasen des Vermittlungsprozesses - dieselbe Reihenfolge wie im Frontend
|
||||
// (OPPORTUNITY_STAGES in src/types.ts) und in der Spalte crm_opportunities.stage.
|
||||
const STAGES = new Set([
|
||||
'Neu',
|
||||
'Qualifizierung',
|
||||
'Angebot',
|
||||
'Verhandlung',
|
||||
'Gewonnen',
|
||||
'Verloren',
|
||||
'Beworben',
|
||||
'Erstgespraech Berater',
|
||||
'Kundenvorstellung',
|
||||
'Interview Kunde',
|
||||
'Zusage',
|
||||
'Beauftragt',
|
||||
'Absage',
|
||||
]);
|
||||
|
||||
const TEXT_FIELDS = ['title', 'company', 'description', 'last_interaction_note', 'next_step', 'notes'];
|
||||
|
||||
@@ -114,7 +114,10 @@ CREATE TABLE IF NOT EXISTS crm_opportunities (
|
||||
title VARCHAR(190) NOT NULL,
|
||||
company VARCHAR(190) NULL,
|
||||
description TEXT NULL,
|
||||
stage ENUM('Neu','Qualifizierung','Angebot','Verhandlung','Gewonnen','Verloren')
|
||||
-- Phasen des Vermittlungsprozesses. Bis zum 18.08.2026 stand hier ein
|
||||
-- allgemeiner Vertriebstrichter (Neu .. Verloren); die Umstellung bestehender
|
||||
-- Datenbanken macht migrateOpportunityStages() in db.js.
|
||||
stage ENUM('Neu','Beworben','Erstgespraech Berater','Kundenvorstellung','Interview Kunde','Zusage','Beauftragt','Absage')
|
||||
NOT NULL DEFAULT 'Neu',
|
||||
probability TINYINT UNSIGNED NOT NULL DEFAULT 10, -- 0-100 %
|
||||
daily_rate DECIMAL(10,2) NULL, -- Tagessatz in Euro
|
||||
|
||||
@@ -9,6 +9,7 @@ import connectionRoutes from './routes/connections.js';
|
||||
import eventRoutes from './routes/events.js';
|
||||
import opportunityRoutes from './routes/opportunities.js';
|
||||
import acquisitionRoutes from './routes/acquisitions.js';
|
||||
import badgeRoutes from './routes/badges.js';
|
||||
import scannerRoutes from './routes/scanner.js';
|
||||
import mailRoutes from './routes/mail.js';
|
||||
import { requireAuth } from './middleware/auth.js';
|
||||
@@ -50,6 +51,9 @@ app.use('/api/events', eventRoutes);
|
||||
app.use('/api/opportunities', opportunityRoutes);
|
||||
app.use('/api/acquisitions', acquisitionRoutes);
|
||||
|
||||
// Zahlen fuer die Seitenleiste - eine Anfrage fuer alle Zaehler.
|
||||
app.use('/api/badges', badgeRoutes);
|
||||
|
||||
// Projekt Scanner. Komplett hinter der CRM-Anmeldung - der urspruengliche
|
||||
// HTTP-Basic-Auth-Zugang des Scanners entfaellt damit.
|
||||
app.use('/api/scanner', requireAuth, scannerRoutes);
|
||||
|
||||
+27
-2
@@ -18,7 +18,15 @@ import { ScannerPortals } from './components/scanner/ScannerPortals';
|
||||
import { useToast } from './components/Toast';
|
||||
import { NavigationProvider } from './lib/navigation';
|
||||
import { useAuth } from './lib/auth';
|
||||
import { connectionsApi, eventsApi, leadsApi, opportunitiesApi, usersApi } from './lib/api';
|
||||
import {
|
||||
badgesApi,
|
||||
connectionsApi,
|
||||
eventsApi,
|
||||
leadsApi,
|
||||
opportunitiesApi,
|
||||
usersApi,
|
||||
type SidebarBadges,
|
||||
} from './lib/api';
|
||||
import {
|
||||
OPEN_STAGES,
|
||||
formatEuro,
|
||||
@@ -71,6 +79,7 @@ const App: React.FC = () => {
|
||||
const [users, setUsers] = useState<AppUser[]>([]);
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [opportunities, setOpportunities] = useState<Opportunity[]>([]);
|
||||
const [badges, setBadges] = useState<SidebarBadges | null>(null);
|
||||
const [isDataLoading, setIsDataLoading] = useState(false);
|
||||
|
||||
/**
|
||||
@@ -104,6 +113,16 @@ const App: React.FC = () => {
|
||||
setEvents(eventRows);
|
||||
setUsers(userRows);
|
||||
setOpportunities(opportunityRows);
|
||||
|
||||
/*
|
||||
* Die Zahlen der Seitenleiste haengen bewusst hinter den Daten und nicht
|
||||
* im Promise.all: sie sind Beiwerk. Faellt der Aufruf aus, bleibt das
|
||||
* Menue ohne Zahlen statt die ganze Ansicht mit einem Fehler.
|
||||
*/
|
||||
badgesApi
|
||||
.get()
|
||||
.then(setBadges)
|
||||
.catch(() => setBadges(null));
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Daten konnten nicht geladen werden.', 'error');
|
||||
} finally {
|
||||
@@ -180,7 +199,13 @@ const App: React.FC = () => {
|
||||
onViewChange={goTo}
|
||||
isOpen={isSidebarOpen}
|
||||
onToggle={() => setIsSidebarOpen((open) => !open)}
|
||||
mailPoolCount={mailPoolLeads.length}
|
||||
counts={{
|
||||
'Aus Mails': mailPoolLeads.length,
|
||||
Archiv: badges?.acquisition_archive,
|
||||
'Opportunity-Papierkorb': badges?.opportunity_trash,
|
||||
Papierkorb: badges?.mail_trash,
|
||||
Unpassend: badges?.scanner_rejected,
|
||||
}}
|
||||
/>
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
|
||||
@@ -15,8 +15,12 @@ interface SidebarProps {
|
||||
onViewChange: (view: ViewState) => void;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
/** Anzahl der Eintraege in "Aus Mails" - als Zaehler am Unterpunkt. */
|
||||
mailPoolCount?: number;
|
||||
/**
|
||||
* Zahlen an den Unterpunkten. Was auf eine Entscheidung wartet oder sich
|
||||
* ansammelt, soll man sehen, ohne die Ansicht zu oeffnen. 0 zeigt nichts an -
|
||||
* eine Null waere nur Rauschen.
|
||||
*/
|
||||
counts?: Partial<Record<ViewState, number | null>>;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
@@ -185,7 +189,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
onViewChange,
|
||||
isOpen,
|
||||
onToggle,
|
||||
mailPoolCount = 0,
|
||||
counts = {},
|
||||
}) => {
|
||||
const { user, logout } = useAuth();
|
||||
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]));
|
||||
}, [currentView]);
|
||||
|
||||
const renderItem = (item: MenuItem, options: { indented?: boolean; badge?: number } = {}) => {
|
||||
const renderItem = (
|
||||
item: MenuItem,
|
||||
options: { indented?: boolean; badge?: number | null } = {}
|
||||
) => {
|
||||
const isActive = currentView === item.name;
|
||||
return (
|
||||
<button
|
||||
@@ -324,10 +331,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
{showLabels && isGroupOpen && (
|
||||
<div className="mt-1 space-y-1">
|
||||
{children.map((child) =>
|
||||
renderItem(child, {
|
||||
indented: true,
|
||||
badge: child.name === 'Aus Mails' ? mailPoolCount : undefined,
|
||||
})
|
||||
renderItem(child, { indented: true, badge: counts[child.name] ?? undefined })
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from './OpportunityList';
|
||||
import { OpportunityPipeline } from './OpportunityPipeline';
|
||||
import { OpportunityModal } from './OpportunityModal';
|
||||
import { STAGE_LABELS } from './opportunityUi';
|
||||
import { ConfirmDialog } from '../ui/Modal';
|
||||
import { useToast } from '../Toast';
|
||||
import { opportunitiesApi } from '../../lib/api';
|
||||
@@ -100,7 +101,9 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
|
||||
reload,
|
||||
}) => {
|
||||
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 [stageFilter, setStageFilter] = useState<OpportunityStage | 'Alle' | 'Offen'>('Alle');
|
||||
const [sortConfig, setSortConfig] = useState<OpportunitySortConfig>({
|
||||
@@ -191,7 +194,7 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
|
||||
const changeStage = async (opportunity: Opportunity, stage: OpportunityStage) => {
|
||||
try {
|
||||
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();
|
||||
} catch (err) {
|
||||
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>
|
||||
{OPPORTUNITY_STAGES.map((stage) => (
|
||||
<option key={stage} value={stage}>
|
||||
{stage}
|
||||
{STAGE_LABELS[stage]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Modal } from '../ui/Modal';
|
||||
import { LeadModal } from '../leads/LeadModal';
|
||||
import { DictateTextarea, DictateButton, DictationHint } from '../ui/DictationField';
|
||||
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 { opportunitiesApi } from '../../lib/api';
|
||||
import {
|
||||
@@ -274,11 +274,12 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
|
||||
value={form.stage}
|
||||
onChange={(event) => set('stage', event.target.value as OpportunityStage)}
|
||||
className={inputClass}
|
||||
title={STAGE_HINTS[form.stage]}
|
||||
style={{ borderLeft: `4px solid ${STAGE_COLORS[form.stage]}` }}
|
||||
>
|
||||
{OPPORTUNITY_STAGES.map((stage) => (
|
||||
<option key={stage} value={stage}>
|
||||
{stage}
|
||||
{STAGE_LABELS[stage]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
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 {
|
||||
OPPORTUNITY_STAGES,
|
||||
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">
|
||||
<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">
|
||||
{stage}
|
||||
{STAGE_LABELS[stage]}
|
||||
</span>
|
||||
<span className="ml-auto rounded-md bg-ink-100 px-1.5 py-0.5 text-[10px] font-extrabold text-ink-500">
|
||||
{items.length}
|
||||
@@ -194,7 +202,7 @@ export const OpportunityPipeline: React.FC<OpportunityPipelineProps> = ({
|
||||
>
|
||||
{OPPORTUNITY_STAGES.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
{STAGE_LABELS[option]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -1,24 +1,59 @@
|
||||
import React, { useState } from 'react';
|
||||
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> = {
|
||||
Neu: 'bg-ink-100 text-ink-600 border-ink-200',
|
||||
Qualifizierung: 'bg-sky-50 text-sky-700 border-sky-200',
|
||||
Angebot: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
Verhandlung: 'bg-violet-50 text-violet-700 border-violet-200',
|
||||
Gewonnen: 'bg-brand-50 text-brand-700 border-brand-200',
|
||||
Verloren: 'bg-red-50 text-red-700 border-red-200',
|
||||
Beworben: 'bg-sky-50 text-sky-700 border-sky-200',
|
||||
'Erstgespraech Berater': 'bg-cyan-50 text-cyan-700 border-cyan-200',
|
||||
Kundenvorstellung: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
'Interview Kunde': 'bg-orange-50 text-orange-700 border-orange-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. */
|
||||
export const STAGE_COLORS: Record<OpportunityStage, string> = {
|
||||
Neu: '#9a9a9a',
|
||||
Qualifizierung: '#0284c7',
|
||||
Angebot: '#d97706',
|
||||
Verhandlung: '#7c3aed',
|
||||
Gewonnen: '#6ba04a',
|
||||
Verloren: '#dc2626',
|
||||
Beworben: '#0284c7',
|
||||
'Erstgespraech Berater': '#0891b2',
|
||||
Kundenvorstellung: '#d97706',
|
||||
'Interview Kunde': '#ea580c',
|
||||
Zusage: '#7c3aed',
|
||||
Beauftragt: '#6ba04a',
|
||||
Absage: '#dc2626',
|
||||
};
|
||||
|
||||
export const StageBadge: React.FC<{ stage: OpportunityStage; className?: string }> = ({
|
||||
@@ -26,9 +61,10 @@ export const StageBadge: React.FC<{ stage: OpportunityStage; className?: string
|
||||
className = '',
|
||||
}) => (
|
||||
<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}`}
|
||||
>
|
||||
{stage}
|
||||
{STAGE_LABELS[stage]}
|
||||
</span>
|
||||
);
|
||||
|
||||
|
||||
@@ -209,6 +209,23 @@ export const opportunitiesApi = {
|
||||
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 ----------
|
||||
|
||||
/**
|
||||
|
||||
+27
-8
@@ -204,20 +204,39 @@ export type LeadViewMode = 'grid' | 'list' | 'network';
|
||||
|
||||
// ---------- 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 = [
|
||||
'Neu',
|
||||
'Qualifizierung',
|
||||
'Angebot',
|
||||
'Verhandlung',
|
||||
'Gewonnen',
|
||||
'Verloren',
|
||||
'Beworben',
|
||||
'Erstgespraech Berater',
|
||||
'Kundenvorstellung',
|
||||
'Interview Kunde',
|
||||
'Zusage',
|
||||
'Beauftragt',
|
||||
'Absage',
|
||||
] as const;
|
||||
|
||||
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 {
|
||||
lead_id: number;
|
||||
|
||||
Reference in New Issue
Block a user