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 = ({ 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('pipeline'); const [searchTerm, setSearchTerm] = useState(''); const [stageFilter, setStageFilter] = useState('Alle'); const [sortConfig, setSortConfig] = useState({ key: 'updated_at', direction: 'desc', }); const [isModalOpen, setIsModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [toDelete, setToDelete] = useState(null); // Fuer welche Opportunity gerade Kopien fuer andere Berater angelegt werden. const [toCopy, setToCopy] = useState(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 (
{/* Steuerleiste */}
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" />
{/* Sortierung - in der Liste zusaetzlich ueber die Spaltenkoepfe */}
{VIEW_BUTTONS.map((button) => ( ))}
{/* Kennzahlen der aktuellen Auswahl */} {filtered.length > 0 && (
{[ { label: 'Opportunities', value: String(filtered.length) }, { label: 'davon offen', value: String(totals.open) }, { label: 'Volumen', value: formatEuro(totals.volume) }, ].map((metric) => (

{metric.label}

{metric.value}

))}
)} {/* Inhalt */} {isLoading && opportunities.length === 0 ? (
) : filtered.length === 0 && viewMode !== 'pipeline' ? (

{opportunities.length === 0 ? 'Noch keine Opportunities vorhanden.' : 'Keine Opportunity passt zur Suche.'}

{opportunities.length === 0 && ( )}
) : viewMode === 'grid' ? ( ) : viewMode === 'list' ? ( ) : ( )} {isModalOpen && ( setIsModalOpen(false)} onSaved={reload} onCopy={(opportunity) => { // Der Bearbeiten-Dialog schliesst, sonst laegen zwei Dialoge uebereinander. setIsModalOpen(false); setToCopy(opportunity); }} /> )} {toCopy && ( setToCopy(null)} onCopied={reload} /> )} {toDelete && ( setToDelete(null)} /> )}
); };