Kleines Canvas-Konfetti ohne Abhaengigkeit (src/lib/confetti.ts), ausgeloest beim Phasenwechsel in der Pipeline und beim Speichern im Dialog. Entfaellt bei prefers-reduced-motion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
470 lines
16 KiB
TypeScript
470 lines
16 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
import { OpportunityGrid } from './OpportunityGrid';
|
|
import {
|
|
OpportunityList,
|
|
DESC_FIRST,
|
|
SORT_LABELS,
|
|
type OpportunitySortConfig,
|
|
type OpportunitySortKey,
|
|
} from './OpportunityList';
|
|
import { OpportunityPipeline } from './OpportunityPipeline';
|
|
import { OpportunityModal } from './OpportunityModal';
|
|
import { OpportunityCopyDialog } from './OpportunityCopyDialog';
|
|
import { STAGE_LABELS } from './opportunityUi';
|
|
import { ConfirmDialog } from '../ui/Modal';
|
|
import { useToast } from '../Toast';
|
|
import { opportunitiesApi } from '../../lib/api';
|
|
import { fireConfetti } from '../../lib/confetti';
|
|
import {
|
|
OPEN_STAGES,
|
|
OPPORTUNITY_STAGES,
|
|
contactFullName,
|
|
formatEuro,
|
|
opportunityVolume,
|
|
type AppUser,
|
|
type Lead,
|
|
type Opportunity,
|
|
type OpportunityStage,
|
|
type OpportunityViewMode,
|
|
} from '../../types';
|
|
|
|
interface OpportunitiesProps {
|
|
opportunities: Opportunity[];
|
|
leads: Lead[];
|
|
users: AppUser[];
|
|
isLoading: boolean;
|
|
reload: () => void;
|
|
/** Von aussen gewuenschte Opportunity (globale Suche) - wird geoeffnet. */
|
|
focusOpportunityId?: number | null;
|
|
onFocusHandled?: () => void;
|
|
}
|
|
|
|
const VIEW_BUTTONS: { mode: OpportunityViewMode; label: string; icon: string }[] = [
|
|
{
|
|
mode: 'grid',
|
|
label: 'Kachelansicht',
|
|
icon: 'M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z',
|
|
},
|
|
{ mode: 'list', label: 'Listenansicht', icon: 'M4 6h16M4 10h16M4 14h16M4 18h16' },
|
|
{
|
|
mode: 'pipeline',
|
|
label: 'Pipeline-Ansicht',
|
|
icon: 'M4 5h4v14H4zM10 5h4v9h-4zM16 5h4v5h-4z',
|
|
},
|
|
];
|
|
|
|
/** Sortierbare Felder in der Reihenfolge des Auswahlmenues. */
|
|
const SORT_OPTIONS: OpportunitySortKey[] = [
|
|
'updated_at',
|
|
'consultant',
|
|
'last_interaction',
|
|
'volume',
|
|
'daily_rate',
|
|
'duration',
|
|
'probability',
|
|
'stage',
|
|
'title',
|
|
'company',
|
|
'start_date',
|
|
];
|
|
|
|
/** Vergleichswert je Sortierschluessel; null sortiert immer ans Ende. */
|
|
function sortValue(opportunity: Opportunity, key: OpportunitySortKey): string | number | null {
|
|
switch (key) {
|
|
case 'title':
|
|
return opportunity.title.toLowerCase();
|
|
case 'company':
|
|
return opportunity.company?.toLowerCase() ?? null;
|
|
case 'consultant':
|
|
return opportunity.consultant?.toLowerCase() ?? null;
|
|
case 'stage':
|
|
return OPPORTUNITY_STAGES.indexOf(opportunity.stage);
|
|
case 'probability':
|
|
return opportunity.probability;
|
|
case 'volume':
|
|
return opportunityVolume(opportunity);
|
|
case 'daily_rate':
|
|
return opportunity.daily_rate;
|
|
case 'duration':
|
|
return opportunity.duration_days;
|
|
case 'last_interaction':
|
|
return opportunity.last_interaction_at ?? null;
|
|
case 'start_date':
|
|
return opportunity.start_date ?? null;
|
|
case 'updated_at':
|
|
default:
|
|
return opportunity.updated_at ?? null;
|
|
}
|
|
}
|
|
|
|
export const Opportunities: React.FC<OpportunitiesProps> = ({
|
|
opportunities,
|
|
leads,
|
|
users,
|
|
isLoading,
|
|
reload,
|
|
focusOpportunityId,
|
|
onFocusHandled,
|
|
}) => {
|
|
const { addToast } = useToast();
|
|
// 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>({
|
|
key: 'updated_at',
|
|
direction: 'desc',
|
|
});
|
|
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [editing, setEditing] = useState<Opportunity | null>(null);
|
|
const [toDelete, setToDelete] = useState<Opportunity | null>(null);
|
|
// Fuer welche Opportunity gerade Kopien fuer andere Berater angelegt werden.
|
|
const [toCopy, setToCopy] = useState<Opportunity | null>(null);
|
|
|
|
/*
|
|
* Ein Treffer der globalen Suche soll die Karte zeigen, nicht nur die Liste.
|
|
* Der Wunsch wird genau einmal erfuellt und danach zurueckgemeldet - sonst
|
|
* spraenge der Dialog beim naechsten Schliessen wieder auf.
|
|
*/
|
|
useEffect(() => {
|
|
if (!focusOpportunityId) return;
|
|
const wanted = opportunities.find((item) => item.id === focusOpportunityId);
|
|
if (wanted) {
|
|
setEditing(wanted);
|
|
setIsModalOpen(true);
|
|
}
|
|
onFocusHandled?.();
|
|
}, [focusOpportunityId, opportunities, onFocusHandled]);
|
|
|
|
const filtered = useMemo(() => {
|
|
const terms = searchTerm
|
|
.toLowerCase()
|
|
.split(',')
|
|
.map((term) => term.trim())
|
|
.filter(Boolean);
|
|
|
|
const matches = opportunities.filter((opportunity) => {
|
|
if (stageFilter === 'Offen' && !OPEN_STAGES.includes(opportunity.stage)) return false;
|
|
if (stageFilter !== 'Alle' && stageFilter !== 'Offen' && opportunity.stage !== stageFilter) {
|
|
return false;
|
|
}
|
|
if (terms.length === 0) return true;
|
|
|
|
const haystack = [
|
|
opportunity.title,
|
|
opportunity.company,
|
|
opportunity.consultant,
|
|
opportunity.description,
|
|
opportunity.last_interaction_note,
|
|
opportunity.next_step,
|
|
opportunity.notes,
|
|
opportunity.owner_name,
|
|
...opportunity.tags,
|
|
...opportunity.contacts.map((contact) => `${contactFullName(contact)} ${contact.role ?? ''}`),
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
.toLowerCase();
|
|
|
|
return terms.every((term) => haystack.includes(term));
|
|
});
|
|
|
|
const direction = sortConfig.direction === 'asc' ? 1 : -1;
|
|
return [...matches].sort((a, b) => {
|
|
const left = sortValue(a, sortConfig.key);
|
|
const right = sortValue(b, sortConfig.key);
|
|
|
|
// Leere Werte landen unabhaengig von der Richtung hinten.
|
|
if (left === null && right === null) return 0;
|
|
if (left === null) return 1;
|
|
if (right === null) return -1;
|
|
|
|
if (typeof left === 'number' && typeof right === 'number') {
|
|
return (left - right) * direction;
|
|
}
|
|
return String(left).localeCompare(String(right), 'de') * direction;
|
|
});
|
|
}, [opportunities, searchTerm, stageFilter, sortConfig]);
|
|
|
|
const totals = useMemo(
|
|
() => ({
|
|
volume: filtered.reduce((sum, item) => sum + (opportunityVolume(item) ?? 0), 0),
|
|
open: filtered.filter((item) => OPEN_STAGES.includes(item.stage)).length,
|
|
}),
|
|
[filtered]
|
|
);
|
|
|
|
const handleSort = (key: OpportunitySortKey) => {
|
|
setSortConfig((prev) =>
|
|
prev.key === key
|
|
? { key, direction: prev.direction === 'asc' ? 'desc' : 'asc' }
|
|
: { key, direction: DESC_FIRST.includes(key) ? 'desc' : 'asc' }
|
|
);
|
|
};
|
|
|
|
const openCreate = () => {
|
|
setEditing(null);
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
const openEdit = (opportunity: Opportunity) => {
|
|
setEditing(opportunity);
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
const changeStage = async (opportunity: Opportunity, stage: OpportunityStage) => {
|
|
try {
|
|
await opportunitiesApi.update(opportunity.id, { stage });
|
|
addToast(`„${opportunity.title}" ist jetzt in Phase ${STAGE_LABELS[stage]}.`, 'success');
|
|
if (stage === 'Zusage') fireConfetti();
|
|
reload();
|
|
} catch (err) {
|
|
addToast(err instanceof Error ? err.message : 'Phase konnte nicht geändert werden.', 'error');
|
|
}
|
|
};
|
|
|
|
const confirmDelete = async () => {
|
|
if (!toDelete) return;
|
|
try {
|
|
await opportunitiesApi.remove(toDelete.id);
|
|
addToast(`„${toDelete.title}" liegt im Papierkorb.`, 'success');
|
|
reload();
|
|
} catch (err) {
|
|
addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
|
|
} finally {
|
|
setToDelete(null);
|
|
}
|
|
};
|
|
|
|
// Im Modal immer den frischen Stand zeigen, falls zwischenzeitlich geladen wurde.
|
|
const modalOpportunity = editing
|
|
? opportunities.find((item) => item.id === editing.id) ?? editing
|
|
: null;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Steuerleiste */}
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
|
<div className="relative w-full lg:max-w-sm">
|
|
<input
|
|
type="search"
|
|
value={searchTerm}
|
|
onChange={(event) => setSearchTerm(event.target.value)}
|
|
placeholder="Titel, Firma, Berater, Ansprechpartner (Komma = UND)"
|
|
className="w-full rounded-xl border border-ink-200 bg-surface py-2.5 pl-10 pr-4 text-sm text-ink-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25"
|
|
/>
|
|
<svg
|
|
className="absolute left-3.5 top-3 h-4 w-4 text-ink-400"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
|
|
<select
|
|
value={stageFilter}
|
|
onChange={(event) =>
|
|
setStageFilter(event.target.value as OpportunityStage | 'Alle' | 'Offen')
|
|
}
|
|
aria-label="Nach Phase filtern"
|
|
className="rounded-xl border border-ink-200 bg-surface px-4 py-2.5 text-sm font-semibold text-ink-700 outline-none transition focus:border-brand-500"
|
|
>
|
|
<option value="Alle">Alle Phasen</option>
|
|
<option value="Offen">Nur offene</option>
|
|
{OPPORTUNITY_STAGES.map((stage) => (
|
|
<option key={stage} value={stage}>
|
|
{STAGE_LABELS[stage]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Sortierung - in der Liste zusaetzlich ueber die Spaltenkoepfe */}
|
|
<div className="flex items-center gap-1">
|
|
<select
|
|
value={sortConfig.key}
|
|
onChange={(event) => {
|
|
const key = event.target.value as OpportunitySortKey;
|
|
setSortConfig({ key, direction: DESC_FIRST.includes(key) ? 'desc' : 'asc' });
|
|
}}
|
|
aria-label="Sortieren nach"
|
|
className="rounded-xl border border-ink-200 bg-surface px-4 py-2.5 text-sm font-semibold text-ink-700 outline-none transition focus:border-brand-500"
|
|
>
|
|
{SORT_OPTIONS.map((key) => (
|
|
<option key={key} value={key}>
|
|
{SORT_LABELS[key]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
onClick={() =>
|
|
setSortConfig((prev) => ({
|
|
...prev,
|
|
direction: prev.direction === 'asc' ? 'desc' : 'asc',
|
|
}))
|
|
}
|
|
title={sortConfig.direction === 'asc' ? 'Aufsteigend' : 'Absteigend'}
|
|
aria-label={`Sortierrichtung wechseln (aktuell ${
|
|
sortConfig.direction === 'asc' ? 'aufsteigend' : 'absteigend'
|
|
})`}
|
|
className="rounded-xl border border-ink-200 bg-surface p-2.5 text-ink-500 transition hover:border-brand-300 hover:text-brand-700"
|
|
>
|
|
<svg
|
|
className={`h-4 w-4 transition-transform ${
|
|
sortConfig.direction === 'desc' ? 'rotate-180' : ''
|
|
}`}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 15l7-7 7 7" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 lg:ml-auto">
|
|
<div className="flex rounded-xl border border-ink-200 bg-surface p-1">
|
|
{VIEW_BUTTONS.map((button) => (
|
|
<button
|
|
key={button.mode}
|
|
onClick={() => setViewMode(button.mode)}
|
|
title={button.label}
|
|
aria-pressed={viewMode === button.mode}
|
|
className={`rounded-lg p-2 transition ${
|
|
viewMode === button.mode
|
|
? 'bg-brand-500 text-white'
|
|
: 'text-ink-400 hover:bg-brand-50 hover:text-brand-600'
|
|
}`}
|
|
>
|
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={button.icon} />
|
|
</svg>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<button
|
|
onClick={openCreate}
|
|
className="flex items-center gap-2 whitespace-nowrap rounded-xl bg-brand-500 px-4 py-2.5 text-sm font-bold text-white shadow-card transition hover:bg-brand-600"
|
|
>
|
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
Opportunity anlegen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Kennzahlen der aktuellen Auswahl */}
|
|
{filtered.length > 0 && (
|
|
<div className="flex flex-wrap gap-x-8 gap-y-2 rounded-2xl border border-ink-200 bg-surface px-4 py-3">
|
|
{[
|
|
{ label: 'Opportunities', value: String(filtered.length) },
|
|
{ label: 'davon offen', value: String(totals.open) },
|
|
{ label: 'Volumen', value: formatEuro(totals.volume) },
|
|
].map((metric) => (
|
|
<div key={metric.label}>
|
|
<p className="text-[10px] font-extrabold uppercase tracking-wider text-ink-400">
|
|
{metric.label}
|
|
</p>
|
|
<p className="text-sm font-extrabold text-ink-900">{metric.value}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Inhalt */}
|
|
{isLoading && opportunities.length === 0 ? (
|
|
<div className="flex items-center justify-center rounded-2xl border border-ink-200 bg-surface py-24">
|
|
<span className="h-6 w-6 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />
|
|
</div>
|
|
) : filtered.length === 0 && viewMode !== 'pipeline' ? (
|
|
<div className="rounded-2xl border-2 border-dashed border-ink-200 bg-surface py-20 text-center">
|
|
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-ink-50 text-ink-300">
|
|
<svg className="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.8}
|
|
d="M9 17V7m4 10V11m4 6v-3m4 7H5a2 2 0 01-2-2V5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<p className="text-sm font-bold text-ink-500">
|
|
{opportunities.length === 0
|
|
? 'Noch keine Opportunities vorhanden.'
|
|
: 'Keine Opportunity passt zur Suche.'}
|
|
</p>
|
|
{opportunities.length === 0 && (
|
|
<button
|
|
onClick={openCreate}
|
|
className="mt-4 rounded-xl bg-brand-50 px-5 py-2 text-sm font-bold text-brand-700 transition hover:bg-brand-100"
|
|
>
|
|
Erste Opportunity anlegen
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : viewMode === 'grid' ? (
|
|
<OpportunityGrid opportunities={filtered} onEdit={openEdit} onDelete={setToDelete} onCopy={setToCopy} />
|
|
) : viewMode === 'list' ? (
|
|
<OpportunityList
|
|
opportunities={filtered}
|
|
sortConfig={sortConfig}
|
|
onSort={handleSort}
|
|
onEdit={openEdit}
|
|
onDelete={setToDelete}
|
|
onCopy={setToCopy}
|
|
/>
|
|
) : (
|
|
<OpportunityPipeline
|
|
opportunities={filtered}
|
|
onEdit={openEdit}
|
|
onStageChange={changeStage}
|
|
onDelete={setToDelete}
|
|
onCopy={setToCopy}
|
|
/>
|
|
)}
|
|
|
|
{isModalOpen && (
|
|
<OpportunityModal
|
|
opportunity={modalOpportunity}
|
|
leads={leads}
|
|
users={users}
|
|
onClose={() => setIsModalOpen(false)}
|
|
onSaved={reload}
|
|
onCopy={(opportunity) => {
|
|
// Der Bearbeiten-Dialog schliesst, sonst laegen zwei Dialoge uebereinander.
|
|
setIsModalOpen(false);
|
|
setToCopy(opportunity);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{toCopy && (
|
|
<OpportunityCopyDialog
|
|
opportunity={toCopy}
|
|
onClose={() => setToCopy(null)}
|
|
onCopied={reload}
|
|
/>
|
|
)}
|
|
|
|
{toDelete && (
|
|
<ConfirmDialog
|
|
title="Opportunity löschen?"
|
|
message={`„${toDelete.title}" wandert in den Papierkorb und verschwindet aus dieser Liste. Von dort lässt sie sich zurückholen; nach 90 Tagen wird sie endgültig gelöscht.`}
|
|
onConfirm={confirmDelete}
|
|
onCancel={() => setToDelete(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|