568 lines
19 KiB
TypeScript
568 lines
19 KiB
TypeScript
"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, CardHeader } 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 grid w-full max-w-7xl gap-3">
|
|
{leads === undefined ? (
|
|
<p className="rounded-md bg-muted p-4 text-sm">Leads werden geladen…</p>
|
|
) : sortedLeads.length === 0 ? (
|
|
<p className="rounded-md border p-4 text-sm text-muted-foreground">
|
|
Keine Leads vorhanden. Bitte zuerst eine Kampagne starten oder
|
|
importieren.
|
|
</p>
|
|
) : (
|
|
sortedLeads.map((lead) => (
|
|
<LeadReviewRow
|
|
key={lead._id}
|
|
lead={lead}
|
|
onActionMessage={setActionMessage}
|
|
/>
|
|
))
|
|
)}
|
|
</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 [isExpanded, setIsExpanded] = useState(false);
|
|
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 }));
|
|
};
|
|
|
|
const detailsId = `lead-review-details-${lead._id}`;
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<div className="grid min-w-0 gap-2">
|
|
<div className="flex min-w-0 flex-wrap items-start justify-between gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="max-w-full truncate font-medium">
|
|
{lead.companyName}
|
|
</p>
|
|
<p className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground">
|
|
<Building2 className="size-3 shrink-0" />
|
|
<span className="inline-flex min-w-0 max-w-full break-words">
|
|
{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 className="inline-flex min-w-0 max-w-full truncate">
|
|
{location}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
|
|
<p
|
|
className={`inline-flex shrink-0 rounded-md border px-2 py-1 text-xs font-medium ${priorityBadgeClass(
|
|
draft.priority,
|
|
)}`}
|
|
>
|
|
{getLeadPriorityLabel(draft.priority)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid min-w-0 gap-1 text-xs text-muted-foreground">
|
|
<p className="inline-flex min-w-0 items-center gap-1">
|
|
<Mail className="size-3 shrink-0" />
|
|
<span className="max-w-full min-w-0 break-all">
|
|
{lead.email || "Keine E-Mail"}
|
|
</span>
|
|
</p>
|
|
{lead.phone ? (
|
|
<p className="inline-flex min-w-0 items-center gap-1">
|
|
<Phone className="size-3 shrink-0" />
|
|
<span className="max-w-full min-w-0 break-all">{lead.phone}</span>
|
|
</p>
|
|
) : null}
|
|
<p className="truncate max-w-full">
|
|
Quelle: {contactSourceLabel(lead)}
|
|
</p>
|
|
{lead.websiteDomain ? (
|
|
<p className="truncate max-w-full">Domain: {lead.websiteDomain}</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<div className="border-t p-4 pt-3">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => setIsExpanded((previous) => !previous)}
|
|
aria-expanded={isExpanded}
|
|
aria-controls={detailsId}
|
|
size="sm"
|
|
>
|
|
{isExpanded ? "Weniger anzeigen" : "Mehr anzeigen"}
|
|
</Button>
|
|
</div>
|
|
|
|
<div
|
|
id={detailsId}
|
|
className="grid gap-3 border-t p-4"
|
|
hidden={!isExpanded}
|
|
>
|
|
<div className="grid gap-3 xl:grid-cols-2">
|
|
<section className="grid gap-2">
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Priorität</p>
|
|
<div className="mt-2">
|
|
<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>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Kontaktstatus</p>
|
|
<div className="mt-2">
|
|
<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>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="grid gap-2">
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Prioritätsgrund</p>
|
|
<Input
|
|
value={draft.priorityReason}
|
|
onChange={(event) => {
|
|
updateDraft("priorityReason", event.target.value);
|
|
}}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<p className="mt-2 text-xs text-muted-foreground">
|
|
Kontaktstatus-Notiz
|
|
</p>
|
|
<Input
|
|
value={draft.contactStatusReason}
|
|
onChange={(event) => {
|
|
updateDraft("contactStatusReason", event.target.value);
|
|
}}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<p className="mt-2 text-xs text-muted-foreground">Notiz</p>
|
|
<Input
|
|
value={draft.notes}
|
|
onChange={(event) => {
|
|
updateDraft("notes", event.target.value);
|
|
}}
|
|
/>
|
|
</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>
|
|
</section>
|
|
|
|
<section className="grid gap-2">
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Review-E-Mail</p>
|
|
<Input
|
|
value={draft.reviewEmail}
|
|
onChange={(event) => {
|
|
updateDraft("reviewEmail", event.target.value);
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="mt-2 text-xs text-muted-foreground">Review-Quelle</p>
|
|
<Input
|
|
value={draft.reviewEmailSource}
|
|
onChange={(event) => {
|
|
updateDraft("reviewEmailSource", event.target.value);
|
|
}}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<p className="mt-2 text-xs text-muted-foreground">Ansprechperson</p>
|
|
<Input
|
|
value={draft.reviewContactPerson}
|
|
onChange={(event) => {
|
|
updateDraft("reviewContactPerson", event.target.value);
|
|
}}
|
|
/>
|
|
</div>
|
|
<label className="mt-2 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>
|
|
</section>
|
|
|
|
<section className="grid gap-2">
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Duplikatstatus</p>
|
|
<div className="mt-2">
|
|
<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>
|
|
|
|
<div>
|
|
<label className="text-xs text-muted-foreground">Sperrstatus</label>
|
|
<div className="mt-2">
|
|
<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>
|
|
</div>
|
|
|
|
<div className="mt-2 grid gap-2 sm:grid-cols-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 grid gap-2 sm:grid-cols-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="text-xs text-muted-foreground">{rowMessage}</p>
|
|
) : null}
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|