Files
everythingbutwork/apps/web/src/routes/leads.tsx
T
dejanandClaude Opus 4.8 d72aa9fbf3 feat(vertrieb): Lead-CRM mit Dossiers, Kontakten & Austausch-Log
Neues Lead-Modul im Vertriebsbereich: qualitative Dossiers (Stammdaten,
Pain & Bedarf, Soft Factors, Strategie), Kontaktpersonen, Austausch-Log
mit Follow-up-Daten und Lead→Deal-Promotion. Routen /leads (Liste, nach
Status gruppiert) und /lead/:id (Dossier), Teaser im Bereich Vertrieb.

Migration 0008_lead_crm (lead, lead_contact, lead_touchpoint).
Datenmodell-Tabellen liegen in der gemeinsamen schema.ts (Folge-Commit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:31:14 +02:00

254 lines
8.4 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 { 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);
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>
<Button onClick={() => setOpen(true)}>{voice.cta.newLead}</Button>
</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)} />}
</div>
);
}
function LeadCard({
lead,
}: {
lead: {
id: string;
firma: string;
status: LeadStatus;
score: number;
ownerName: 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>}
<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 [firma, setFirma] = useState('');
const [branche, setBranche] = useState('');
const [subSegment, setSubSegment] = useState('');
const [website, setWebsite] = useState('');
const [quelle, setQuelle] = useState('');
const [ownerUserId, setOwnerUserId] = 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,
status,
score,
});
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" onClick={onClose}>
<div
className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)]"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-xl font-semibold">Neuer Lead</h2>
<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>
<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>
</div>
</div>
);
}
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>
);
}