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