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>
254 lines
8.4 KiB
TypeScript
254 lines
8.4 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 { 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>
|
||
);
|
||
}
|