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
+27 -2
View File
@@ -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">
+12 -8
View File
@@ -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>
+48 -12
View File
@@ -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>
);
+17
View File
@@ -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
View File
@@ -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;