feat: add lead qualification workflow
This commit is contained in:
576
components/leads/leads-review-table.tsx
Normal file
576
components/leads/leads-review-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user