feat: convert campaign and lead views to cards

This commit is contained in:
2026-06-04 17:11:39 +02:00
parent 59824b7336
commit ca42c8d5a6
5 changed files with 563 additions and 485 deletions

View File

@@ -0,0 +1,51 @@
---
id: TASK-20
title: Convert campaigns and leads to compact cards
status: Done
assignee: []
created_date: '2026-06-04 15:01'
updated_date: '2026-06-04 15:10'
labels: []
dependencies: []
priority: high
ordinal: 22000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Update the dashboard campaign and lead review UI so campaigns render as individual cards and leads render as compact expandable cards while preserving existing Convex behavior.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Campaigns page renders each campaign as its own responsive card instead of a desktop table.
- [x] #2 Leads page renders compact cards showing company/name, contact data, and priority while hiding review fields behind Mehr anzeigen.
- [x] #3 Expanded lead cards preserve all existing review fields and save/block actions.
- [x] #4 UI remains responsive without horizontal table overflow on desktop and mobile.
- [x] #5 Lint and test verification are run and results are documented.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add/adjust tests or static checks that fail for table-based Campaigns/Leads layouts before production edits.
2. Convert CampaignsBoard from desktop table plus mobile cards to one responsive card list.
3. Convert LeadsReviewTable from table rows to compact expandable cards.
4. Run lint, tests, and browser/responsive verification.
5. Record verification notes in Backlog; wait for user confirmation before Done.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented via subagent-driven TDD. Campaigns and Leads converted from table layouts to compact cards. Added static layout regression tests for campaign cards and lead expandable cards. Verification: pnpm lint exits 0 with 2 pre-existing generated Better Auth warnings; pnpm test passes 107/107; pnpm build passes after rerun with network access for Google Fonts. Browser automation could launch only outside sandbox, but authenticated dashboard routes redirected to /login in the fresh Playwright context, so final visual validation should be done in the existing logged-in browser session.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Campaigns now render as responsive cards on all breakpoints. Leads now render as compact expandable cards showing company/contact/priority by default and revealing review fields/actions through Mehr anzeigen. Added regression tests for both card layouts. Verified with pnpm lint, pnpm test, and pnpm build; browser automation reached login due fresh unauthenticated context, while user confirmed the authenticated UI manually.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -267,187 +267,81 @@ export function CampaignsBoard() {
</CardHeader> </CardHeader>
</Card> </Card>
) : ( ) : (
<> <div className="grid gap-3">
<div className="hidden overflow-x-auto rounded-lg border bg-card md:block"> {campaignsSorted.map((campaign) => (
<table className="w-full min-w-[820px] border-separate border-spacing-0"> <Card key={campaign._id}>
<thead> <CardHeader>
<tr className="text-left text-sm text-muted-foreground"> <div className="flex flex-wrap items-start justify-between gap-2">
<th className="sticky left-0 bg-card p-3 font-normal">Kampagne</th> <div className="min-w-0">
<th className="p-3 font-normal">PLZ / Radius</th> <CardTitle className="truncate">{campaign.name}</CardTitle>
<th className="p-3 font-normal">Cadence</th> <CardDescription className="truncate">
<th className="p-3 font-normal">Limits</th> {formatNiche(campaign)}
<th className="p-3 font-normal">Status</th> </CardDescription>
<th className="p-3 font-normal">Lauf</th> </div>
<th className="p-3 font-normal">Aktionen</th> <Badge
</tr> variant={campaign.status === "active" ? "default" : "secondary"}
</thead>
<tbody>
{campaignsSorted.map((campaign) => (
<tr
className="border-t"
key={campaign._id}
> >
<td className="max-w-[220px] p-3 align-top"> {campaign.status === "active" ? "Aktiv" : "Pausiert"}
<div className="space-y-1"> </Badge>
<p className="truncate font-medium">{campaign.name}</p> </div>
<p className="text-sm text-muted-foreground"> </CardHeader>
{formatNiche(campaign)}
</p>
</div>
</td>
<td className="max-w-[180px] p-3 align-top"> <CardContent className="grid gap-2 text-sm">
<div className="space-y-1 text-sm text-muted-foreground"> <div className="flex flex-wrap items-center justify-between gap-3">
<p className="inline-flex items-center gap-1"> <div className="inline-flex items-center gap-1 text-muted-foreground">
<MapPin className="size-3" /> <MapPin className="size-3" />
<span>{campaign.postalCode}</span> <span>{campaign.postalCode}</span>
</p>
<p>{campaign.radiusKm} km Umkreis</p>
</div>
</td>
<td className="p-3 align-top">
<span className="rounded-md bg-muted px-2 py-1 text-sm">
{recurrenceLabel[campaign.recurrence]}
</span>
</td>
<td className="p-3 align-top">
<p className="text-sm">
Leads: {campaign.maxNewLeadsPerRun} · Audits:{" "}
{campaign.maxAuditsPerRun}
</p>
</td>
<td className="p-3 align-top">
<Badge
variant={campaign.status === "active" ? "default" : "secondary"}
>
{campaign.status === "active" ? "Aktiv" : "Pausiert"}
</Badge>
</td>
<td className="p-3 align-top">
<div className="space-y-1 text-sm text-muted-foreground">
<p>Letzter Lauf: {formatDateTime(campaign.lastRunAt)}</p>
<p>Nächster Lauf: {formatDateTime(campaign.nextRunAt)}</p>
<p>Run-Status: {statusLabel[campaign.currentRunStatus] ?? campaign.currentRunStatus}</p>
</div>
</td>
<td className="p-3 align-top">
<div className="flex flex-wrap gap-2">
<Button
className="w-full sm:w-auto"
variant="outline"
onClick={() => openEditDialog(campaign)}
disabled={actionBusyId === campaign._id}
>
<Pencil className="size-4" />
Bearbeiten
</Button>
<Button
className="w-full sm:w-auto"
variant="outline"
onClick={() => toggleCampaign(campaign)}
disabled={actionBusyId === campaign._id}
>
<RefreshCcw className="size-4" />
{campaign.status === "active" ? "Pausieren" : "Fortfahren"}
</Button>
<Button
className="w-full sm:w-auto"
onClick={() => runCampaign(campaign)}
disabled={actionBusyId === campaign._id}
>
<Play className="size-4" />
Jetzt ausführen
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="grid gap-3 md:hidden">
{campaignsSorted.map((campaign) => (
<Card key={campaign._id}>
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<CardTitle className="truncate">{campaign.name}</CardTitle>
<CardDescription className="truncate">
{formatNiche(campaign)}
</CardDescription>
</div>
<Badge
variant={campaign.status === "active" ? "default" : "secondary"}
>
{campaign.status === "active" ? "Aktiv" : "Pausiert"}
</Badge>
</div> </div>
</CardHeader> <span>{campaign.radiusKm} km</span>
</div>
<Separator className="bg-border" />
<div>
<p>Cadence: {recurrenceLabel[campaign.recurrence]}</p>
<p>
Limits: L {campaign.maxNewLeadsPerRun}, A{" "}
{campaign.maxAuditsPerRun}
</p>
</div>
<div>
<p className="text-muted-foreground">Letzter Lauf: {formatDateTime(campaign.lastRunAt)}</p>
<p className="text-muted-foreground">Nächster Lauf: {formatDateTime(campaign.nextRunAt)}</p>
<p className="text-muted-foreground">
Run-Status: {statusLabel[campaign.currentRunStatus] ?? campaign.currentRunStatus}
</p>
</div>
<CardContent className="grid gap-2 text-sm"> <div className="grid gap-2">
<div className="flex flex-wrap items-center justify-between gap-3"> <Button
<div className="inline-flex items-center gap-1 text-muted-foreground"> variant="outline"
<MapPin className="size-3" /> onClick={() => openEditDialog(campaign)}
<span>{campaign.postalCode}</span> disabled={actionBusyId === campaign._id}
</div> className="w-full justify-start"
<span>{campaign.radiusKm} km</span> >
</div> <Pencil className="size-4" />
<Separator className="bg-border" /> Bearbeiten
<div> </Button>
<p>Cadence: {recurrenceLabel[campaign.recurrence]}</p> <Button
<p> variant="outline"
Limits: L {campaign.maxNewLeadsPerRun}, A{" "} onClick={() => toggleCampaign(campaign)}
{campaign.maxAuditsPerRun} disabled={actionBusyId === campaign._id}
</p> className="w-full justify-start"
</div> >
<div> <RefreshCcw className="size-4" />
<p className="text-muted-foreground">Letzter Lauf: {formatDateTime(campaign.lastRunAt)}</p> {campaign.status === "active" ? "Pausieren" : "Fortfahren"}
<p className="text-muted-foreground">Nächster Lauf: {formatDateTime(campaign.nextRunAt)}</p> </Button>
<p className="text-muted-foreground"> <Button
Run-Status: {statusLabel[campaign.currentRunStatus] ?? campaign.currentRunStatus} onClick={() => runCampaign(campaign)}
</p> disabled={actionBusyId === campaign._id}
</div> className="w-full justify-start"
>
<div className="grid gap-2"> <Play className="size-4" />
<Button Jetzt ausführen
variant="outline" </Button>
onClick={() => openEditDialog(campaign)} </div>
disabled={actionBusyId === campaign._id} </CardContent>
className="w-full justify-start" </Card>
> ))}
<Pencil className="size-4" /> </div>
Bearbeiten
</Button>
<Button
variant="outline"
onClick={() => toggleCampaign(campaign)}
disabled={actionBusyId === campaign._id}
className="w-full justify-start"
>
<RefreshCcw className="size-4" />
{campaign.status === "active" ? "Pausieren" : "Fortfahren"}
</Button>
<Button
onClick={() => runCampaign(campaign)}
disabled={actionBusyId === campaign._id}
className="w-full justify-start"
>
<Play className="size-4" />
Jetzt ausführen
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</>
)} )}
</section> </section>
); );

View File

@@ -22,7 +22,7 @@ import {
type LeadBlacklistStatus, type LeadBlacklistStatus,
} from "@/lib/dashboard-model"; } from "@/lib/dashboard-model";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card, CardHeader } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -148,59 +148,23 @@ export function LeadsReviewTable() {
<h1 className="text-2xl font-semibold tracking-normal">Leads prüfen</h1> <h1 className="text-2xl font-semibold tracking-normal">Leads prüfen</h1>
</div> </div>
<div className="mx-auto w-full max-w-7xl"> <div className="mx-auto grid w-full max-w-7xl gap-3">
<Card className="overflow-hidden"> {leads === undefined ? (
<div className="overflow-x-auto"> <p className="rounded-md bg-muted p-4 text-sm">Leads werden geladen</p>
<div className="min-w-[1150px]"> ) : sortedLeads.length === 0 ? (
<table className="w-full border-separate border-spacing-0 text-sm"> <p className="rounded-md border p-4 text-sm text-muted-foreground">
<thead> Keine Leads vorhanden. Bitte zuerst eine Kampagne starten oder
<tr className="text-left text-xs text-muted-foreground"> importieren.
<th className="p-3 font-normal">Firma / Ort</th> </p>
<th className="p-3 font-normal">Kontakt + Quelle</th> ) : (
<th className="p-3 font-normal">Priorität</th> sortedLeads.map((lead) => (
<th className="p-3 font-normal">Kontaktstatus</th> <LeadReviewRow
<th className="p-3 font-normal">Qualität</th> key={lead._id}
<th className="p-3 font-normal">Review-Felder</th> lead={lead}
<th className="p-3 font-normal">Aktionen</th> onActionMessage={setActionMessage}
</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> </div>
{actionMessage ? ( {actionMessage ? (
@@ -219,6 +183,7 @@ function LeadReviewRow({
lead: LeadRow; lead: LeadRow;
onActionMessage: (value: string) => void; onActionMessage: (value: string) => void;
}) { }) {
const [isExpanded, setIsExpanded] = useState(false);
const [draft, setDraft] = useState<LeadReviewDraft>(() => ({ const [draft, setDraft] = useState<LeadReviewDraft>(() => ({
priority: lead.priority, priority: lead.priority,
contactStatus: lead.contactStatus, contactStatus: lead.contactStatus,
@@ -313,264 +278,290 @@ function LeadReviewRow({
setDraft((current) => ({ ...current, [field]: value })); setDraft((current) => ({ ...current, [field]: value }));
}; };
const detailsId = `lead-review-details-${lead._id}`;
return ( return (
<tr className="border-t"> <Card>
<td className="max-w-[260px] p-3 align-top"> <CardHeader className="pb-3">
<p className="font-medium">{lead.companyName}</p> <div className="grid min-w-0 gap-2">
<p className="mt-1 inline-flex items-center gap-1 truncate text-xs text-muted-foreground"> <div className="flex min-w-0 flex-wrap items-start justify-between gap-2">
<Building2 className="size-3 shrink-0" /> <div className="min-w-0 flex-1">
<span className="truncate">{lead.niche ?? "Nische offen"}</span> <p className="max-w-full truncate font-medium">
</p> {lead.companyName}
<p className="mt-2 inline-flex items-center gap-1 text-xs text-muted-foreground"> </p>
<MapPin className="size-3 shrink-0" /> <p className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground">
<span>{location}</span> <Building2 className="size-3 shrink-0" />
</p> <span className="inline-flex min-w-0 max-w-full break-words">
{lead.address ? ( {lead.niche ?? "Nische offen"}
<p className="mt-1 max-w-full truncate text-xs text-muted-foreground"> </span>
{lead.address} </p>
</p> <p className="mt-2 inline-flex items-center gap-1 text-xs text-muted-foreground">
) : null} <MapPin className="size-3 shrink-0" />
</td> <span className="inline-flex min-w-0 max-w-full truncate">
{location}
</span>
</p>
</div>
<td className="max-w-[260px] p-3 align-top"> <p
<p className="inline-flex w-full items-start gap-1 text-sm"> className={`inline-flex shrink-0 rounded-md border px-2 py-1 text-xs font-medium ${priorityBadgeClass(
<Mail className="mt-0.5 size-3 shrink-0" /> draft.priority,
<span className="min-w-0 break-all"> )}`}
{lead.email || "Keine E-Mail"} >
</span> {getLeadPriorityLabel(draft.priority)}
</p> </p>
{lead.phone ? ( </div>
<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"> <div className="grid min-w-0 gap-1 text-xs text-muted-foreground">
<p <p className="inline-flex min-w-0 items-center gap-1">
className={`inline-flex rounded-md border px-2 py-1 text-xs font-medium ${priorityBadgeClass( <Mail className="size-3 shrink-0" />
draft.priority, <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"
> >
{getLeadPriorityLabel(draft.priority)} {isExpanded ? "Weniger anzeigen" : "Mehr anzeigen"}
</p> </Button>
<div className="mt-2 max-w-[160px]"> </div>
<Select
value={draft.priority} <div
onValueChange={(nextPriority) => id={detailsId}
updateDraft("priority", nextPriority as LeadPriority) className="grid gap-3 border-t p-4"
} hidden={!isExpanded}
> >
<SelectTrigger> <div className="grid gap-3 xl:grid-cols-2">
<SelectValue placeholder="Priorität" /> <section className="grid gap-2">
</SelectTrigger> <div>
<SelectContent> <p className="text-xs text-muted-foreground">Priorität</p>
{leadPriorityOptions.map((value) => ( <div className="mt-2">
<SelectItem value={value} key={value}> <Select
{getLeadPriorityLabel(value)} value={draft.priority}
</SelectItem> onValueChange={(nextPriority) =>
))} updateDraft("priority", nextPriority as LeadPriority)
</SelectContent> }
</Select> >
<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>
</td> </div>
</Card>
<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>
); );
} }

View File

@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
const campaignsBoardPath = join(
process.cwd(),
"components",
"campaigns",
"campaigns-board.tsx",
);
test("campaign board renders campaigns as responsive cards", async () => {
const source = await readFile(campaignsBoardPath, "utf8");
assert.doesNotMatch(source, /<table\b/i);
assert.doesNotMatch(source, /<thead\b/i);
assert.doesNotMatch(source, /<tbody\b/i);
assert.doesNotMatch(source, /<tr\b/i);
assert.doesNotMatch(source, /<td\b/i);
assert.doesNotMatch(source, /<th\b/i);
assert.doesNotMatch(source, /md:hidden/i);
assert.doesNotMatch(source, /md:block/i);
assert.match(source, /className="grid gap-3"/);
assert.match(source, /openEditDialog\(campaign\)/);
assert.match(source, /toggleCampaign\(campaign\)/);
assert.match(source, /runCampaign\(campaign\)/);
});

View File

@@ -0,0 +1,112 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
const leadsReviewPath = join(
process.cwd(),
"components",
"leads",
"leads-review-table.tsx",
);
test("LeadsReviewTable uses compact card summaries with expandable review details", async () => {
const source = await readFile(leadsReviewPath, "utf8");
assert.doesNotMatch(source, /<table\b/i);
assert.doesNotMatch(source, /<thead\b/i);
assert.doesNotMatch(source, /<tbody\b/i);
assert.doesNotMatch(source, /<tr\b/i);
assert.doesNotMatch(source, /<td\b/i);
assert.doesNotMatch(source, /<th\b/i);
assert.doesNotMatch(source, /min-w-\[/i);
assert.match(source, /Mehr anzeigen/);
assert.match(source, /Weniger anzeigen/);
assert.match(source, /aria-expanded=\{[^}]+\}/);
assert.match(source, /aria-controls=\{[^}]+\}/);
assert.match(source, /id=\{[^}]+\}/);
assert.match(
source,
/aria-expanded=\{[^}]+\}[\s\S]{0,160}aria-controls=\{[^}]+\}[\s\S]{0,160}(Mehr anzeigen|Weniger anzeigen)/i,
);
assert.match(
source,
/hidden=\{!?isExpanded\}/,
);
const companyNameMatch = source.match(
/<p className="([^"]+)">\s*\{lead\.companyName\}\s*<\/p>/,
);
assert.ok(
companyNameMatch !== null &&
/(?:^|\s)(truncate|max-w-full|min-w-0|break-words)(?:\s|$)/.test(
companyNameMatch[1],
),
"Company name should use overflow-safe text classes in compact card.",
);
const nicheMatch = source.match(
/lead\.niche\s+\?\?\s+"Nische offen"\}\s*<\/span>/,
);
assert.ok(
nicheMatch !== null,
"Niche rendering should still be asserted in test fixture.",
);
const nicheContainerMatch = source.match(
/<span className="([^"]+)">\s*\{lead\.niche\s+\?\?\s+"Nische offen"\}\s*<\/span>/,
);
assert.ok(
nicheContainerMatch !== null &&
/(?:^|\s)(truncate|max-w-full|break-all|break-words)(?:\s|$)/.test(
nicheContainerMatch[1],
),
"Niche should use overflow-safe text classes in compact card.",
);
const locationMatch = source.match(/\{location\}/);
assert.ok(
locationMatch !== null,
"Location rendering should still be present in compact card.",
);
const locationContainerMatch = source.match(
/<span className="([^"]+)">\s*\{location\}\s*<\/span>/,
);
assert.ok(
locationContainerMatch !== null &&
/(?:^|\s)(truncate|max-w-full|break-words)(?:\s|$)/.test(
locationContainerMatch[1],
),
"Location should use overflow-safe text classes in compact card.",
);
const emailSpanMatch = source.match(
/<span className="([^"]+)">\s*\{lead\.email \|\| "Keine E-Mail"\}\s*<\/span>/,
);
assert.ok(
emailSpanMatch !== null &&
/(?:^|\s)(break-all|max-w-full|min-w-0)(?:\s|$)/.test(
emailSpanMatch[1],
),
"Lead email should use overflow-safe text classes in compact card.",
);
const phoneSpanMatch = source.match(
/<span className="([^"]+)">\s*\{lead\.phone\}\s*<\/span>/,
);
assert.ok(
phoneSpanMatch !== null &&
/(?:^|\s)(break-all|max-w-full|min-w-0)(?:\s|$)/.test(phoneSpanMatch[1]),
"Lead phone should use overflow-safe text classes in compact card.",
);
assert.match(source, /Kontaktstatus/);
assert.match(source, /Review-E-Mail/);
assert.match(source, /Review-Quelle/);
assert.match(source, /Ansprechperson/);
assert.match(source, /Genannte E-Mail als Business-Kontakt/);
assert.match(source, /Duplikatstatus/);
assert.match(source, /Sperrstatus/);
assert.match(source, /Sperren/);
assert.match(source, /Speichern/);
});