Improve audit pipeline and outreach review
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user