Improve audit pipeline and outreach review

This commit is contained in:
2026-06-08 22:16:32 +02:00
parent ff18fc202e
commit 1695110e0a
34 changed files with 2792 additions and 238 deletions

View File

@@ -23,7 +23,16 @@ import {
} from "@/lib/dashboard-model";
import { Button } from "@/components/ui/button";
import { Card, CardHeader } from "@/components/ui/card";
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
@@ -63,6 +72,7 @@ type LeadReviewPayload = {
reviewContactPerson?: string;
reviewIsBusinessContactAddress?: boolean;
};
type LeadStatusFilter = "all" | "high" | "blocked";
function normalizeTextInput(value: string): string | undefined {
const next = value.trim();
@@ -132,6 +142,7 @@ function duplicateBadgeVariant(
export function LeadsReviewTable() {
const leads = useQuery(api.leads.list, { limit: 120 });
const [actionMessage, setActionMessage] = useState<string | null>(null);
const [activeFilter, setActiveFilter] = useState<LeadStatusFilter>("all");
const sortedLeads = useMemo(() => {
if (!leads) {
@@ -140,6 +151,30 @@ export function LeadsReviewTable() {
return [...leads].sort((a, b) => b.createdAt - a.createdAt);
}, [leads]);
const filteredLeads = useMemo(() => {
if (activeFilter === "high") {
return sortedLeads.filter((lead) => lead.priority === "high");
}
if (activeFilter === "blocked") {
return sortedLeads.filter((lead) => lead.blacklistStatus === "blocked");
}
return sortedLeads;
}, [activeFilter, sortedLeads]);
const leadStatusFilters: Array<{ label: string; value: LeadStatusFilter; count: number }> = [
{ label: "Alle Leads", value: "all", count: sortedLeads.length },
{
label: "Hohe Priorität",
value: "high",
count: sortedLeads.filter((lead) => lead.priority === "high").length,
},
{
label: "Gesperrt",
value: "blocked",
count: sortedLeads.filter((lead) => lead.blacklistStatus === "blocked").length,
},
];
return (
<section className="space-y-4 px-4 py-5 sm:px-6 lg:px-8">
@@ -148,16 +183,52 @@ export function LeadsReviewTable() {
<h1 className="text-2xl font-semibold tracking-normal">Leads prüfen</h1>
</div>
<div className="mx-auto flex w-full max-w-7xl flex-wrap gap-2" aria-label="Lead-Filter">
{leadStatusFilters.map((filter) => (
<button
aria-pressed={activeFilter === filter.value}
className="inline-flex min-h-8 items-center gap-2 rounded-md border px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted aria-pressed:border-foreground aria-pressed:text-foreground"
key={filter.value}
onClick={() => setActiveFilter(filter.value)}
type="button"
>
{filter.label}
<Badge variant="secondary">{filter.count}</Badge>
</button>
))}
</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>
Array.from({ length: 4 }, (_, index) => (
<Card key={index}>
<CardHeader>
<div className="h-5 w-2/3 rounded-md bg-muted" />
<div className="h-4 w-1/2 rounded-md bg-muted" />
<div className="mt-2 h-12 rounded-md bg-muted" />
</CardHeader>
</Card>
))
) : 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>
<Card>
<CardHeader>
<p className="text-sm font-medium">Keine Leads vorhanden</p>
<p className="text-sm text-muted-foreground">
Bitte zuerst eine Kampagne starten oder importieren.
</p>
</CardHeader>
</Card>
) : filteredLeads.length === 0 ? (
<Card>
<CardHeader>
<p className="text-sm font-medium">Keine Treffer</p>
<p className="text-sm text-muted-foreground">
Für diesen Filter sind aktuell keine Leads vorhanden.
</p>
</CardHeader>
</Card>
) : (
sortedLeads.map((lead) => (
filteredLeads.map((lead) => (
<LeadReviewRow
key={lead._id}
lead={lead}
@@ -168,7 +239,7 @@ export function LeadsReviewTable() {
</div>
{actionMessage ? (
<p className="mx-auto max-w-7xl text-sm text-muted-foreground">
<p className="mx-auto max-w-7xl text-sm text-muted-foreground" role="status">
{actionMessage}
</p>
) : null}
@@ -183,7 +254,7 @@ function LeadReviewRow({
lead: LeadRow;
onActionMessage: (value: string) => void;
}) {
const [isExpanded, setIsExpanded] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [draft, setDraft] = useState<LeadReviewDraft>(() => ({
priority: lead.priority,
contactStatus: lead.contactStatus,
@@ -279,14 +350,26 @@ function LeadReviewRow({
};
const detailsId = `lead-review-details-${lead._id}`;
const titleId = `lead-review-title-${lead._id}`;
const priorityId = `lead-priority-${lead._id}`;
const contactStatusId = `lead-contact-status-${lead._id}`;
const priorityReasonId = `lead-priority-reason-${lead._id}`;
const contactReasonId = `lead-contact-reason-${lead._id}`;
const notesId = `lead-notes-${lead._id}`;
const reviewEmailId = `lead-review-email-${lead._id}`;
const reviewSourceId = `lead-review-source-${lead._id}`;
const contactPersonId = `lead-contact-person-${lead._id}`;
const businessContactId = `lead-business-contact-${lead._id}`;
const duplicateStatusId = `lead-duplicate-status-${lead._id}`;
const blacklistStatusId = `lead-blacklist-status-${lead._id}`;
return (
<Card>
<Card aria-labelledby={titleId}>
<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">
<p className="max-w-full truncate font-medium" id={titleId}>
{lead.companyName}
</p>
<p className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground">
@@ -339,24 +422,35 @@ function LeadReviewRow({
<Button
type="button"
variant="outline"
onClick={() => setIsExpanded((previous) => !previous)}
aria-expanded={isExpanded}
aria-controls={detailsId}
onClick={() => setIsDialogOpen(true)}
size="sm"
>
{isExpanded ? "Weniger anzeigen" : "Mehr anzeigen"}
Mehr anzeigen
</Button>
</div>
<div
id={detailsId}
className="grid gap-3 border-t p-4"
hidden={!isExpanded}
<Dialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
>
<DialogContent
className="max-h-[calc(100dvh-2rem)] max-w-5xl overflow-y-auto"
id={detailsId}
>
<DialogHeader>
<div>
<DialogTitle>{lead.companyName} prüfen</DialogTitle>
<DialogDescription>
Priorität, Kontaktstatus, Duplikate und Kontaktinformationen bearbeiten.
</DialogDescription>
</div>
<DialogCloseButton />
</DialogHeader>
<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>
<Label className="text-xs text-muted-foreground" htmlFor={priorityId}>Priorität</Label>
<div className="mt-2">
<Select
value={draft.priority}
@@ -364,7 +458,7 @@ function LeadReviewRow({
updateDraft("priority", nextPriority as LeadPriority)
}
>
<SelectTrigger>
<SelectTrigger id={priorityId}>
<SelectValue placeholder="Priorität" />
</SelectTrigger>
<SelectContent>
@@ -379,7 +473,7 @@ function LeadReviewRow({
</div>
<div>
<p className="text-xs text-muted-foreground">Kontaktstatus</p>
<Label className="text-xs text-muted-foreground" htmlFor={contactStatusId}>Kontaktstatus</Label>
<div className="mt-2">
<Select
value={draft.contactStatus}
@@ -387,7 +481,7 @@ function LeadReviewRow({
updateDraft("contactStatus", nextStatus as LeadContactStatus)
}
>
<SelectTrigger>
<SelectTrigger id={contactStatusId}>
<SelectValue placeholder="Kontaktstatus" />
</SelectTrigger>
<SelectContent>
@@ -404,8 +498,9 @@ function LeadReviewRow({
<section className="grid gap-2">
<div>
<p className="text-xs text-muted-foreground">Prioritätsgrund</p>
<Label className="text-xs text-muted-foreground" htmlFor={priorityReasonId}>Prioritätsgrund</Label>
<Input
id={priorityReasonId}
value={draft.priorityReason}
onChange={(event) => {
updateDraft("priorityReason", event.target.value);
@@ -413,10 +508,11 @@ function LeadReviewRow({
/>
</div>
<div>
<p className="mt-2 text-xs text-muted-foreground">
<Label className="mt-2 text-xs text-muted-foreground" htmlFor={contactReasonId}>
Kontaktstatus-Notiz
</p>
</Label>
<Input
id={contactReasonId}
value={draft.contactStatusReason}
onChange={(event) => {
updateDraft("contactStatusReason", event.target.value);
@@ -424,8 +520,9 @@ function LeadReviewRow({
/>
</div>
<div>
<p className="mt-2 text-xs text-muted-foreground">Notiz</p>
<Label className="mt-2 text-xs text-muted-foreground" htmlFor={notesId}>Notiz</Label>
<Input
id={notesId}
value={draft.notes}
onChange={(event) => {
updateDraft("notes", event.target.value);
@@ -443,8 +540,9 @@ function LeadReviewRow({
<section className="grid gap-2">
<div>
<p className="text-xs text-muted-foreground">Review-E-Mail</p>
<Label className="text-xs text-muted-foreground" htmlFor={reviewEmailId}>Review-E-Mail</Label>
<Input
id={reviewEmailId}
value={draft.reviewEmail}
onChange={(event) => {
updateDraft("reviewEmail", event.target.value);
@@ -453,8 +551,9 @@ function LeadReviewRow({
</div>
<div>
<p className="mt-2 text-xs text-muted-foreground">Review-Quelle</p>
<Label className="mt-2 text-xs text-muted-foreground" htmlFor={reviewSourceId}>Review-Quelle</Label>
<Input
id={reviewSourceId}
value={draft.reviewEmailSource}
onChange={(event) => {
updateDraft("reviewEmailSource", event.target.value);
@@ -462,28 +561,30 @@ function LeadReviewRow({
/>
</div>
<div>
<p className="mt-2 text-xs text-muted-foreground">Ansprechperson</p>
<Label className="mt-2 text-xs text-muted-foreground" htmlFor={contactPersonId}>Ansprechperson</Label>
<Input
id={contactPersonId}
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">
<Label className="mt-2 inline-flex items-center gap-2 text-xs text-muted-foreground" htmlFor={businessContactId}>
<Switch
id={businessContactId}
checked={draft.reviewIsBusinessContactAddress}
onCheckedChange={(checked) => {
updateDraft("reviewIsBusinessContactAddress", checked);
}}
/>
Genannte E-Mail als Business-Kontakt
</label>
</Label>
</section>
<section className="grid gap-2">
<div>
<p className="text-xs text-muted-foreground">Duplikatstatus</p>
<Label className="text-xs text-muted-foreground" htmlFor={duplicateStatusId}>Duplikatstatus</Label>
<div className="mt-2">
<Select
value={draft.duplicateStatus}
@@ -491,7 +592,7 @@ function LeadReviewRow({
updateDraft("duplicateStatus", nextStatus as LeadDuplicateStatus)
}
>
<SelectTrigger>
<SelectTrigger id={duplicateStatusId}>
<SelectValue placeholder="Duplikatstatus" />
</SelectTrigger>
<SelectContent>
@@ -506,7 +607,7 @@ function LeadReviewRow({
</div>
<div>
<label className="text-xs text-muted-foreground">Sperrstatus</label>
<Label className="text-xs text-muted-foreground" htmlFor={blacklistStatusId}>Sperrstatus</Label>
<div className="mt-2">
<Select
value={draft.blacklistStatus}
@@ -514,7 +615,7 @@ function LeadReviewRow({
updateDraft("blacklistStatus", nextStatus as LeadBlacklistStatus)
}
>
<SelectTrigger>
<SelectTrigger id={blacklistStatusId}>
<SelectValue placeholder="Sperrstatus" />
</SelectTrigger>
<SelectContent>
@@ -557,11 +658,16 @@ function LeadReviewRow({
</Button>
</div>
{rowMessage ? (
<p className="text-xs text-muted-foreground">{rowMessage}</p>
rowMessage === "Speichern fehlgeschlagen" ? (
<p className="text-xs text-destructive" role="alert">{rowMessage}</p>
) : (
<p className="text-xs text-muted-foreground" role="status">{rowMessage}</p>
)
) : null}
</section>
</div>
</div>
</DialogContent>
</Dialog>
</Card>
);
}