Files
everythingbutwork/apps/web/src/routes/leads.tsx
T
Raffael SheikhandClaude Opus 4.8 f600c9f187 UI: alle 8 Dialoge auf barrierearme Modal-Basis umgestellt
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>
2026-06-07 11:49:05 +02:00

391 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}