feat: add lead qualification workflow

This commit is contained in:
2026-06-04 16:09:47 +02:00
parent 15d8bfeb66
commit 59824b7336
19 changed files with 2833 additions and 78 deletions

View File

@@ -0,0 +1,576 @@
"use client";
import { useMemo, useState } from "react";
import { useMutation, useQuery } from "convex/react";
import { FunctionReturnType } from "convex/server";
import { Building2, Mail, MapPin, Phone, ShieldAlert } from "lucide-react";
import { api } from "@/convex/_generated/api";
import { Id } from "@/convex/_generated/dataModel";
import {
getLeadBlacklistStatusLabel,
getLeadContactStatusLabel,
getLeadDuplicateStatusLabel,
getLeadPriorityLabel,
leadBlacklistStatusOptions,
leadContactStatusOptions,
leadDuplicateStatusOptions,
leadPriorityOptions,
type LeadContactStatus,
type LeadDuplicateStatus,
type LeadPriority,
type LeadBlacklistStatus,
} from "@/lib/dashboard-model";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
type LeadsListResult = FunctionReturnType<typeof api.leads.list>;
type LeadRow = NonNullable<LeadsListResult>[number];
type LeadReviewDraft = {
priority: LeadPriority;
contactStatus: LeadContactStatus;
priorityReason: string;
contactStatusReason: string;
notes: string;
reviewEmail: string;
reviewEmailSource: string;
reviewContactPerson: string;
reviewIsBusinessContactAddress: boolean;
duplicateStatus: LeadDuplicateStatus;
blacklistStatus: LeadBlacklistStatus;
};
type LeadReviewPayload = {
id: Id<"leads">;
priority?: LeadPriority;
priorityReason?: string;
contactStatus?: LeadContactStatus;
contactStatusReason?: string;
notes?: string;
duplicateStatus?: LeadDuplicateStatus;
duplicateReason?: string;
blacklistStatus?: LeadBlacklistStatus;
blacklistReason?: string;
duplicateOfLeadId?: Id<"leads">;
applyBlacklist?: boolean;
reviewEmail?: string;
reviewEmailSource?: string;
reviewContactPerson?: string;
reviewIsBusinessContactAddress?: boolean;
};
function normalizeTextInput(value: string): string | undefined {
const next = value.trim();
return next.length > 0 ? next : undefined;
}
function contactSourceLabel(lead: LeadRow): string {
if (lead.sourceProvider) {
return lead.sourceProvider;
}
if (lead.emailSource) {
return lead.emailSource;
}
return "Unbekannt";
}
function formatLocation(lead: LeadRow): string {
if (lead.postalCode && lead.city) {
return `${lead.postalCode} ${lead.city}`;
}
if (lead.city || lead.address) {
return lead.city ?? lead.address ?? "";
}
return lead.address ?? "Ort offen";
}
function priorityBadgeClass(priority: LeadPriority): string {
switch (priority) {
case "high":
return "text-destructive border-destructive/30 bg-destructive/15";
case "medium":
return "text-muted-foreground border-muted-foreground/30 bg-muted/20";
case "low":
return "text-muted-foreground border-muted/40 bg-muted/35";
case "defer":
return "text-muted-foreground border-secondary/50 bg-secondary/30";
case "blocked":
return "text-destructive border-destructive/40 bg-destructive/15";
default:
return "text-muted-foreground border-muted bg-muted/20";
}
}
function duplicateBadgeVariant(
duplicateStatus: LeadDuplicateStatus,
): "secondary" | "default" | "outline" | "destructive" {
if (duplicateStatus === "duplicate") {
return "destructive";
}
if (duplicateStatus === "possible_duplicate") {
return "outline";
}
if (duplicateStatus === "unique") {
return "secondary";
}
return "outline";
}
export function LeadsReviewTable() {
const leads = useQuery(api.leads.list, { limit: 120 });
const [actionMessage, setActionMessage] = useState<string | null>(null);
const sortedLeads = useMemo(() => {
if (!leads) {
return [];
}
return [...leads].sort((a, b) => b.createdAt - a.createdAt);
}, [leads]);
return (
<section className="space-y-4 px-4 py-5 sm:px-6 lg:px-8">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-2">
<p className="text-sm text-muted-foreground">Leads Review</p>
<h1 className="text-2xl font-semibold tracking-normal">Leads prüfen</h1>
</div>
<div className="mx-auto w-full max-w-7xl">
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<div className="min-w-[1150px]">
<table className="w-full border-separate border-spacing-0 text-sm">
<thead>
<tr className="text-left text-xs text-muted-foreground">
<th className="p-3 font-normal">Firma / Ort</th>
<th className="p-3 font-normal">Kontakt + Quelle</th>
<th className="p-3 font-normal">Priorität</th>
<th className="p-3 font-normal">Kontaktstatus</th>
<th className="p-3 font-normal">Qualität</th>
<th className="p-3 font-normal">Review-Felder</th>
<th className="p-3 font-normal">Aktionen</th>
</tr>
</thead>
{leads === undefined ? (
<tbody>
<tr>
<td className="p-3" colSpan={7}>
<p className="rounded-md bg-muted p-4 text-sm">
Leads werden geladen
</p>
</td>
</tr>
</tbody>
) : sortedLeads.length === 0 ? (
<tbody>
<tr>
<td className="p-3" colSpan={7}>
<p className="rounded-md border p-4 text-sm text-muted-foreground">
Keine Leads vorhanden. Bitte zuerst eine Kampagne starten
oder importieren.
</p>
</td>
</tr>
</tbody>
) : (
<tbody>
{sortedLeads.map((lead) => (
<LeadReviewRow
key={lead._id}
lead={lead}
onActionMessage={setActionMessage}
/>
))}
</tbody>
)}
</table>
</div>
</div>
</Card>
</div>
{actionMessage ? (
<p className="mx-auto max-w-7xl text-sm text-muted-foreground">
{actionMessage}
</p>
) : null}
</section>
);
}
function LeadReviewRow({
lead,
onActionMessage,
}: {
lead: LeadRow;
onActionMessage: (value: string) => void;
}) {
const [draft, setDraft] = useState<LeadReviewDraft>(() => ({
priority: lead.priority,
contactStatus: lead.contactStatus,
priorityReason: lead.priorityReason ?? "",
contactStatusReason: lead.contactStatusReason ?? "",
notes: lead.notes ?? "",
reviewEmail: lead.email ?? "",
reviewEmailSource: lead.emailSource ?? "",
reviewContactPerson: lead.contactPerson ?? "",
reviewIsBusinessContactAddress: false,
duplicateStatus: (lead.duplicateStatus as LeadDuplicateStatus) ?? "unchecked",
blacklistStatus: lead.blacklistStatus,
}));
const [isSaving, setIsSaving] = useState(false);
const [isBlocking, setIsBlocking] = useState(false);
const [rowMessage, setRowMessage] = useState<string | null>(null);
const reviewUpdate = useMutation(api.leads.reviewUpdate);
const location = formatLocation(lead);
const reasonParts = [
lead.priorityReason,
lead.contactStatusReason,
lead.duplicateReason,
lead.blacklistReason,
].filter((item): item is string => Boolean(item));
const update = async (
payload?: Omit<LeadReviewPayload, "id">,
) => {
setIsSaving(true);
setRowMessage(null);
onActionMessage("");
try {
await reviewUpdate({ id: lead._id, ...payload } as LeadReviewPayload);
setRowMessage("Gespeichert");
onActionMessage("Aktualisierung übernommen");
} catch {
setRowMessage("Speichern fehlgeschlagen");
} finally {
setIsSaving(false);
setTimeout(() => setRowMessage(null), 1400);
}
};
const saveRow = async () => {
const reviewEmail = normalizeTextInput(draft.reviewEmail);
const reviewEmailSource = normalizeTextInput(draft.reviewEmailSource);
const reviewContactPerson = draft.reviewContactPerson.trim();
const shouldUpdateEmailReview =
reviewEmail !== normalizeTextInput(lead.email ?? "") ||
reviewEmailSource !== normalizeTextInput(lead.emailSource ?? "") ||
reviewContactPerson !== normalizeTextInput(lead.contactPerson ?? "");
if (shouldUpdateEmailReview && !reviewEmail && !lead.email) {
setRowMessage("Review-E-Mail setzen, um Kontaktinfos zu ändern.");
return;
}
const payload = {
id: lead._id,
priority: draft.priority,
priorityReason: draft.priorityReason,
contactStatus: draft.contactStatus,
contactStatusReason: draft.contactStatusReason,
notes: draft.notes,
duplicateStatus: draft.duplicateStatus,
duplicateReason: lead.duplicateReason,
blacklistStatus: draft.blacklistStatus,
blacklistReason: lead.blacklistReason,
reviewIsBusinessContactAddress: draft.reviewIsBusinessContactAddress,
...(shouldUpdateEmailReview ? {
reviewEmail: reviewEmail ?? lead.email,
reviewEmailSource: reviewEmailSource ?? lead.emailSource,
reviewContactPerson,
} : {}),
};
await update(payload);
};
const blockLead = async () => {
setIsBlocking(true);
await update({ applyBlacklist: true });
setIsBlocking(false);
};
const updateDraft = <T extends keyof LeadReviewDraft>(
field: T,
value: LeadReviewDraft[T],
) => {
setDraft((current) => ({ ...current, [field]: value }));
};
return (
<tr className="border-t">
<td className="max-w-[260px] p-3 align-top">
<p className="font-medium">{lead.companyName}</p>
<p className="mt-1 inline-flex items-center gap-1 truncate text-xs text-muted-foreground">
<Building2 className="size-3 shrink-0" />
<span className="truncate">{lead.niche ?? "Nische offen"}</span>
</p>
<p className="mt-2 inline-flex items-center gap-1 text-xs text-muted-foreground">
<MapPin className="size-3 shrink-0" />
<span>{location}</span>
</p>
{lead.address ? (
<p className="mt-1 max-w-full truncate text-xs text-muted-foreground">
{lead.address}
</p>
) : null}
</td>
<td className="max-w-[260px] p-3 align-top">
<p className="inline-flex w-full items-start gap-1 text-sm">
<Mail className="mt-0.5 size-3 shrink-0" />
<span className="min-w-0 break-all">
{lead.email || "Keine E-Mail"}
</span>
</p>
{lead.phone ? (
<p className="mt-2 inline-flex w-full items-start gap-1 text-xs text-muted-foreground">
<Phone className="size-3 shrink-0" />
<span className="break-all">{lead.phone}</span>
</p>
) : null}
<p className="mt-2 text-xs text-muted-foreground">
Quelle: {contactSourceLabel(lead)}
</p>
{lead.websiteDomain ? (
<p className="mt-1 text-xs text-muted-foreground">
Domain: {lead.websiteDomain}
</p>
) : null}
</td>
<td className="p-3 align-top">
<p
className={`inline-flex rounded-md border px-2 py-1 text-xs font-medium ${priorityBadgeClass(
draft.priority,
)}`}
>
{getLeadPriorityLabel(draft.priority)}
</p>
<div className="mt-2 max-w-[160px]">
<Select
value={draft.priority}
onValueChange={(nextPriority) =>
updateDraft("priority", nextPriority as LeadPriority)
}
>
<SelectTrigger>
<SelectValue placeholder="Priorität" />
</SelectTrigger>
<SelectContent>
{leadPriorityOptions.map((value) => (
<SelectItem value={value} key={value}>
{getLeadPriorityLabel(value)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</td>
<td className="p-3 align-top">
<Badge variant="outline">
{getLeadContactStatusLabel(draft.contactStatus)}
</Badge>
<div className="mt-2 max-w-[180px]">
<Select
value={draft.contactStatus}
onValueChange={(nextStatus) =>
updateDraft("contactStatus", nextStatus as LeadContactStatus)
}
>
<SelectTrigger>
<SelectValue placeholder="Kontaktstatus" />
</SelectTrigger>
<SelectContent>
{leadContactStatusOptions.map((status) => (
<SelectItem value={status} key={status}>
{getLeadContactStatusLabel(status)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</td>
<td className="max-w-[220px] p-3 align-top">
<div className="grid gap-2">
<p className="text-xs text-muted-foreground">Prioritätsgrund</p>
<Input
value={draft.priorityReason}
onChange={(event) => {
updateDraft("priorityReason", event.target.value);
}}
/>
</div>
<div className="mt-2 grid gap-2">
<p className="text-xs text-muted-foreground">Kontaktstatus-Notiz</p>
<Input
value={draft.contactStatusReason}
onChange={(event) => {
updateDraft("contactStatusReason", event.target.value);
}}
/>
</div>
<div className="mt-2 grid gap-2">
<p className="text-xs text-muted-foreground">Notiz</p>
<Input
value={draft.notes}
onChange={(event) => {
updateDraft("notes", event.target.value);
}}
/>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Badge
variant={duplicateBadgeVariant(draft.duplicateStatus)}
title={lead.duplicateReason ?? undefined}
>
{getLeadDuplicateStatusLabel(draft.duplicateStatus)}
</Badge>
<Badge
variant={lead.blacklistStatus === "blocked" ? "destructive" : "secondary"}
>
{getLeadBlacklistStatusLabel(lead.blacklistStatus)}
</Badge>
</div>
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
{reasonParts.length === 0 ? (
<p>Keine Zusatzhinweise</p>
) : (
reasonParts.map((reason) => <p key={reason}> {reason}</p>)
)}
</div>
</td>
<td className="min-w-[260px] p-3 align-top">
<div className="grid gap-2">
<p className="text-xs text-muted-foreground">Review-E-Mail</p>
<Input
value={draft.reviewEmail}
onChange={(event) => {
updateDraft("reviewEmail", event.target.value);
}}
/>
</div>
<div className="mt-2 grid gap-2">
<p className="text-xs text-muted-foreground">Review-Quelle</p>
<Input
value={draft.reviewEmailSource}
onChange={(event) => {
updateDraft("reviewEmailSource", event.target.value);
}}
/>
</div>
<div className="mt-2 grid gap-2">
<p className="text-xs text-muted-foreground">Ansprechperson</p>
<Input
value={draft.reviewContactPerson}
onChange={(event) => {
updateDraft("reviewContactPerson", event.target.value);
}}
/>
</div>
<label className="mt-3 inline-flex items-center gap-2 text-xs text-muted-foreground">
<Switch
checked={draft.reviewIsBusinessContactAddress}
onCheckedChange={(checked) => {
updateDraft("reviewIsBusinessContactAddress", checked);
}}
/>
Genannte E-Mail als Business-Kontakt
</label>
<div className="mt-3 grid gap-2">
<p className="text-xs text-muted-foreground">Duplikatstatus</p>
<Select
value={draft.duplicateStatus}
onValueChange={(nextStatus) =>
updateDraft("duplicateStatus", nextStatus as LeadDuplicateStatus)
}
>
<SelectTrigger>
<SelectValue placeholder="Duplikatstatus" />
</SelectTrigger>
<SelectContent>
{leadDuplicateStatusOptions.map((status) => (
<SelectItem value={status} key={status}>
{getLeadDuplicateStatusLabel(status)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="mt-2">
<label className="text-xs text-muted-foreground">Sperrstatus</label>
<Select
value={draft.blacklistStatus}
onValueChange={(nextStatus) =>
updateDraft("blacklistStatus", nextStatus as LeadBlacklistStatus)
}
>
<SelectTrigger>
<SelectValue placeholder="Sperrstatus" />
</SelectTrigger>
<SelectContent>
{leadBlacklistStatusOptions.map((status) => (
<SelectItem value={status} key={status}>
{getLeadBlacklistStatusLabel(status)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</td>
<td className="max-w-[170px] p-3 align-top">
<div className="grid gap-2">
<Button
onClick={saveRow}
disabled={isSaving || isBlocking}
size="sm"
>
<span>Speichern</span>
</Button>
<Button
variant="destructive"
onClick={blockLead}
disabled={isSaving || isBlocking}
size="sm"
>
<ShieldAlert className="size-4" />
Sperren
</Button>
</div>
{rowMessage ? (
<p className="mt-2 text-xs text-muted-foreground">{rowMessage}</p>
) : null}
</td>
</tr>
);
}