Bisher waren alle Overlay-Dialoge plain divs ohne role/Fokus-Trap/Escape. Jetzt setzen sie auf <Modal> auf (role=dialog, aria-modal, Fokus-Trap + -Rueckgabe, Escape, Body-Scroll-Lock, konsistenter X-Button, Scroll bei langem Inhalt). - Modal: X-Schliessen-Button + max-h-[90vh]/overflow-y-auto ergaenzt - BlockerDialog, ManualDialog (Timeline) - leads: CSV-Import + Neuer Lead - bereich: Neues Projekt - projekt: Gate-Review + Projekt abschliessen - lead: Zu Deal machen Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
391 lines
13 KiB
TypeScript
391 lines
13 KiB
TypeScript
import { useState } from 'react';
|
||
import { Link } from 'react-router';
|
||
import { clsx } from 'clsx';
|
||
import { toast } from 'sonner';
|
||
import { trpc } from '../lib/trpc';
|
||
import { voice } from '../lib/voice';
|
||
import { Card } from '../components/ui/Card';
|
||
import { Button } from '../components/ui/Button';
|
||
import { Modal } from '../components/ui/Modal';
|
||
import { SkeletonCard } from '../components/ui/SkeletonCard';
|
||
import { LEAD_STATUS_META, LEAD_STATUS_ORDER, type LeadStatus } from '../lib/lead-meta';
|
||
|
||
export function LeadsRoute() {
|
||
const q = trpc.lead.list.useQuery();
|
||
const [open, setOpen] = useState(false);
|
||
const [importOpen, setImportOpen] = useState(false);
|
||
|
||
if (q.isPending) return <SkeletonCard lines={6} />;
|
||
const leads = q.data ?? [];
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<header className="flex items-end justify-between gap-4">
|
||
<div>
|
||
<p className="text-sm text-[var(--color-ink-muted)]">
|
||
<Link to="/bereich/vertrieb" className="hover:underline">
|
||
Vertrieb
|
||
</Link>
|
||
{' › Leads'}
|
||
</p>
|
||
<h1 className="text-3xl font-bold">Leads</h1>
|
||
<p className="text-[var(--color-ink-muted)]">
|
||
Qualitative Lead-Dossiers — Stammdaten, Pain, Strategie, Austausch-Log.
|
||
</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button variant="secondary" onClick={() => setImportOpen(true)}>
|
||
CSV importieren
|
||
</Button>
|
||
<Button onClick={() => setOpen(true)}>{voice.cta.newLead}</Button>
|
||
</div>
|
||
</header>
|
||
|
||
{leads.length === 0 ? (
|
||
<Card.Root>
|
||
<Card.Body>
|
||
<p className="text-sm text-[var(--color-ink-muted)]">Noch keine Leads. Leg einen an.</p>
|
||
</Card.Body>
|
||
</Card.Root>
|
||
) : (
|
||
LEAD_STATUS_ORDER.map((status) => {
|
||
const group = leads.filter((l) => l.status === status);
|
||
if (group.length === 0) return null;
|
||
return (
|
||
<section key={status}>
|
||
<h2 className="mb-2 text-lg font-semibold">
|
||
{LEAD_STATUS_META[status].label}{' '}
|
||
<span className="text-sm font-normal text-[var(--color-ink-muted)]">
|
||
({group.length})
|
||
</span>
|
||
</h2>
|
||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||
{group.map((l) => (
|
||
<LeadCard key={l.id} lead={l} />
|
||
))}
|
||
</div>
|
||
</section>
|
||
);
|
||
})
|
||
)}
|
||
|
||
{open && <LeadCreateDialog onClose={() => setOpen(false)} />}
|
||
{importOpen && <LeadImportDialog onClose={() => setImportOpen(false)} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LeadImportDialog({ onClose }: { onClose: () => void }) {
|
||
const projectsQ = trpc.project.list.useQuery();
|
||
const projects = (projectsQ.data ?? []).map((r) => ({ id: r.project.id, name: r.project.name }));
|
||
const [projectId, setProjectId] = useState('');
|
||
const [fileName, setFileName] = useState('');
|
||
const [csv, setCsv] = useState('');
|
||
const [result, setResult] = useState<{
|
||
created: number;
|
||
skippedEmpty: number;
|
||
skippedDuplicate: number;
|
||
contacts: number;
|
||
touchpoints: number;
|
||
} | null>(null);
|
||
const utils = trpc.useUtils();
|
||
const importMut = trpc.lead.importCsv.useMutation({
|
||
onSuccess: async (res) => {
|
||
setResult(res);
|
||
await utils.lead.list.invalidate();
|
||
toast(`${res.created} Leads importiert.`);
|
||
},
|
||
});
|
||
|
||
function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
setFileName(file.name);
|
||
setResult(null);
|
||
const reader = new FileReader();
|
||
reader.onload = () => setCsv(typeof reader.result === 'string' ? reader.result : '');
|
||
reader.readAsText(file);
|
||
}
|
||
|
||
return (
|
||
<Modal open onClose={onClose} title="Leads aus CSV importieren">
|
||
<p className="mt-1 text-sm text-[var(--color-ink-muted)]">
|
||
Spalten: Quelle, Vorname, Nachname, Position, Firma, Straße, PLZ, Ort, Telefon, Mobil, Fax,
|
||
E-Mail, Website, Branche, Notizen. Je Zeile entsteht ein Lead mit primärem Kontakt; Notizen
|
||
werden als erster Austausch-Log-Eintrag gespeichert. Duplikate (gleicher Firmenname) werden
|
||
übersprungen.
|
||
</p>
|
||
|
||
<div className="mt-4 space-y-3">
|
||
<label className="block">
|
||
<span className="text-sm font-medium">Projekt zuordnen (optional)</span>
|
||
<select
|
||
value={projectId}
|
||
onChange={(e) => setProjectId(e.target.value)}
|
||
disabled={!!result}
|
||
className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
|
||
>
|
||
<option value="">— kein Projekt —</option>
|
||
{projects.map((p) => (
|
||
<option key={p.id} value={p.id}>
|
||
{p.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className="block">
|
||
<span className="text-sm font-medium">CSV-Datei</span>
|
||
<input
|
||
type="file"
|
||
accept=".csv,text/csv"
|
||
onChange={onFile}
|
||
className="mt-1 block w-full text-sm file:mr-3 file:rounded-[var(--radius-md)] file:border file:border-[var(--color-border)] file:bg-white file:px-3 file:py-1.5 file:text-sm"
|
||
/>
|
||
</label>
|
||
{fileName && !result && (
|
||
<p className="text-xs text-[var(--color-ink-muted)]">
|
||
Bereit: {fileName} ({Math.max(0, csv.split('\n').filter((l) => l.trim()).length - 1)}{' '}
|
||
Datenzeilen)
|
||
</p>
|
||
)}
|
||
|
||
{result && (
|
||
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] p-3 text-sm">
|
||
<p className="font-medium text-[var(--color-ampel-green-fg)]">
|
||
{result.created} Leads importiert
|
||
</p>
|
||
<ul className="mt-1 space-y-0.5 text-xs text-[var(--color-ink-muted)]">
|
||
<li>{result.contacts} Kontakte angelegt</li>
|
||
<li>{result.touchpoints} Notizen als Log-Eintrag</li>
|
||
{result.skippedDuplicate > 0 && (
|
||
<li>{result.skippedDuplicate} Duplikate übersprungen</li>
|
||
)}
|
||
{result.skippedEmpty > 0 && <li>{result.skippedEmpty} leere Zeilen übersprungen</li>}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex justify-end gap-2">
|
||
<Button type="button" variant="ghost" onClick={onClose}>
|
||
{result ? 'Schließen' : 'Abbrechen'}
|
||
</Button>
|
||
{!result && (
|
||
<Button
|
||
type="button"
|
||
onClick={() => importMut.mutate({ csv, projectId: projectId || undefined })}
|
||
disabled={!csv || importMut.isPending}
|
||
>
|
||
{importMut.isPending ? 'Importiere…' : 'Importieren'}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
function LeadCard({
|
||
lead,
|
||
}: {
|
||
lead: {
|
||
id: string;
|
||
firma: string;
|
||
status: LeadStatus;
|
||
score: number;
|
||
ownerName: string | null;
|
||
projectName: string | null;
|
||
branche: string | null;
|
||
subSegment: string | null;
|
||
naechsterSchrittDatum: string | null;
|
||
};
|
||
}) {
|
||
const meta = LEAD_STATUS_META[lead.status];
|
||
const sub = [lead.branche, lead.subSegment].filter(Boolean).join(' · ');
|
||
return (
|
||
<Link to={`/lead/${lead.id}`} className="contents">
|
||
<Card.Root className="cursor-pointer hover:shadow-[var(--shadow-md)]">
|
||
<Card.Body className="space-y-2">
|
||
<div className="flex items-start justify-between gap-2">
|
||
<p className="font-semibold">{lead.firma}</p>
|
||
<span className={clsx('shrink-0 rounded-full px-2 py-0.5 text-xs', meta.badge)}>
|
||
{meta.label}
|
||
</span>
|
||
</div>
|
||
{sub && <p className="text-xs text-[var(--color-ink-muted)]">{sub}</p>}
|
||
{lead.projectName && (
|
||
<p className="text-xs text-[var(--color-ink-muted)]">
|
||
<span className="rounded-full bg-black/5 px-2 py-0.5">↳ {lead.projectName}</span>
|
||
</p>
|
||
)}
|
||
<div className="flex items-center justify-between text-xs text-[var(--color-ink-muted)]">
|
||
<span>{lead.ownerName ?? 'ohne Owner'}</span>
|
||
<span className="font-mono tabular-nums">Score {lead.score}/10</span>
|
||
</div>
|
||
{lead.naechsterSchrittDatum && (
|
||
<p className="text-xs text-[var(--color-ink-muted)]">
|
||
Nächster Schritt: {lead.naechsterSchrittDatum}
|
||
</p>
|
||
)}
|
||
</Card.Body>
|
||
</Card.Root>
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
function LeadCreateDialog({ onClose }: { onClose: () => void }) {
|
||
const users = trpc.user.list.useQuery();
|
||
const projectsQ = trpc.project.list.useQuery();
|
||
const projects = (projectsQ.data ?? []).map((r) => ({ id: r.project.id, name: r.project.name }));
|
||
const [firma, setFirma] = useState('');
|
||
const [branche, setBranche] = useState('');
|
||
const [subSegment, setSubSegment] = useState('');
|
||
const [website, setWebsite] = useState('');
|
||
const [quelle, setQuelle] = useState('');
|
||
const [ownerUserId, setOwnerUserId] = useState('');
|
||
const [projectId, setProjectId] = useState('');
|
||
const [status, setStatus] = useState<LeadStatus>('cold');
|
||
const [score, setScore] = useState(0);
|
||
|
||
const utils = trpc.useUtils();
|
||
const create = trpc.lead.create.useMutation({
|
||
onSuccess: async () => {
|
||
toast(voice.toast.leadCreated);
|
||
await utils.lead.list.invalidate();
|
||
onClose();
|
||
},
|
||
});
|
||
|
||
function submit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!firma.trim()) return;
|
||
create.mutate({
|
||
firma: firma.trim(),
|
||
branche: branche.trim() || undefined,
|
||
subSegment: subSegment.trim() || undefined,
|
||
website: website.trim() || undefined,
|
||
quelle: quelle.trim() || undefined,
|
||
ownerUserId: ownerUserId || undefined,
|
||
projectId: projectId || undefined,
|
||
status,
|
||
score,
|
||
});
|
||
}
|
||
|
||
return (
|
||
<Modal open onClose={onClose} title="Neuer Lead">
|
||
<p className="mt-1 text-sm text-[var(--color-ink-muted)]">
|
||
Nur die Firma ist Pflicht — alles Weitere füllst du im Dossier.
|
||
</p>
|
||
<form onSubmit={submit} className="mt-4 space-y-3">
|
||
<Field label="Firma">
|
||
<input
|
||
required
|
||
value={firma}
|
||
onChange={(e) => setFirma(e.target.value)}
|
||
className={inputCls}
|
||
/>
|
||
</Field>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field label="Branche">
|
||
<input
|
||
value={branche}
|
||
onChange={(e) => setBranche(e.target.value)}
|
||
className={inputCls}
|
||
/>
|
||
</Field>
|
||
<Field label="Nische / Sub-Segment">
|
||
<input
|
||
value={subSegment}
|
||
onChange={(e) => setSubSegment(e.target.value)}
|
||
className={inputCls}
|
||
/>
|
||
</Field>
|
||
</div>
|
||
<Field label="Website">
|
||
<input
|
||
value={website}
|
||
onChange={(e) => setWebsite(e.target.value)}
|
||
className={inputCls}
|
||
/>
|
||
</Field>
|
||
<Field label="Quelle (wie gefunden)">
|
||
<input value={quelle} onChange={(e) => setQuelle(e.target.value)} className={inputCls} />
|
||
</Field>
|
||
<Field label="Projekt (optional)">
|
||
<select
|
||
value={projectId}
|
||
onChange={(e) => setProjectId(e.target.value)}
|
||
className={inputCls}
|
||
>
|
||
<option value="">— kein Projekt —</option>
|
||
{projects.map((p) => (
|
||
<option key={p.id} value={p.id}>
|
||
{p.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</Field>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field label="Owner">
|
||
<select
|
||
value={ownerUserId}
|
||
onChange={(e) => setOwnerUserId(e.target.value)}
|
||
className={inputCls}
|
||
>
|
||
<option value="">— niemand —</option>
|
||
{(users.data ?? []).map((u) => (
|
||
<option key={u.id} value={u.id}>
|
||
{u.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</Field>
|
||
<Field label="Status">
|
||
<select
|
||
value={status}
|
||
onChange={(e) => setStatus(e.target.value as LeadStatus)}
|
||
className={inputCls}
|
||
>
|
||
{LEAD_STATUS_ORDER.map((s) => (
|
||
<option key={s} value={s}>
|
||
{LEAD_STATUS_META[s].label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</Field>
|
||
</div>
|
||
<Field label={`Score: ${score}/10`}>
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={10}
|
||
value={score}
|
||
onChange={(e) => setScore(Number(e.target.value))}
|
||
className="w-full"
|
||
/>
|
||
</Field>
|
||
<div className="flex justify-end gap-2 pt-2">
|
||
<Button type="button" variant="ghost" onClick={onClose}>
|
||
Abbrechen
|
||
</Button>
|
||
<Button type="submit" disabled={create.isPending}>
|
||
Anlegen
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
const inputCls =
|
||
'w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm';
|
||
|
||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||
return (
|
||
<label className="block">
|
||
<span className="text-sm font-medium">{label}</span>
|
||
<div className="mt-1">{children}</div>
|
||
</label>
|
||
);
|
||
}
|