Files
perinco-CRM/src/components/opportunities/Opportunities.tsx
T
schakal00783andClaude Opus 5 49c03f5e1b feat: globale Suche und Anbindung an das ICON CRM
Beides lag bisher nur als nicht eingecheckte Arbeit im Arbeitsverzeichnis,
waehrend auf Gitea nur die Doku dazu stand (docs/12-suche.md). Damit ist
der Code jetzt versioniert.

Globale Suche:
- server/src/routes/search.js: Volltext ueber Kontakte, Opportunities und
  Akquise, dazu die erweiterte Suche mit dem Modell des Scanners
- src/components/GlobalSearch.tsx im Kopf der Anwendung; ein Treffer
  springt in die Ansicht und oeffnet den Datensatz (navigation.tsx)

ICON CRM:
- server/src/routes/integrations.js liefert die Kontakte mit gesetztem
  Haekchen hinaus, server/src/routes/iconCrm.js holt die Kontakte von
  drueben herein - beide nur lesend, mit festem Token statt JWT
- server/src/contactBridge.js und src/lib/contactMatch.ts erkennen
  Dubletten, src/components/leads/IconMergeDialog.tsx laesst sie
  zusammenfuehren
- server/src/ai.js buendelt die OpenAI-Aufrufe

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:07:59 +02:00

468 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 {
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');
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>
);
};