feat: convert campaign and lead views to cards
This commit is contained in:
@@ -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 -->
|
||||
@@ -267,112 +267,7 @@ export function CampaignsBoard() {
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden overflow-x-auto rounded-lg border bg-card md:block">
|
||||
<table className="w-full min-w-[820px] border-separate border-spacing-0">
|
||||
<thead>
|
||||
<tr className="text-left text-sm text-muted-foreground">
|
||||
<th className="sticky left-0 bg-card p-3 font-normal">Kampagne</th>
|
||||
<th className="p-3 font-normal">PLZ / Radius</th>
|
||||
<th className="p-3 font-normal">Cadence</th>
|
||||
<th className="p-3 font-normal">Limits</th>
|
||||
<th className="p-3 font-normal">Status</th>
|
||||
<th className="p-3 font-normal">Lauf</th>
|
||||
<th className="p-3 font-normal">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{campaignsSorted.map((campaign) => (
|
||||
<tr
|
||||
className="border-t"
|
||||
key={campaign._id}
|
||||
>
|
||||
<td className="max-w-[220px] p-3 align-top">
|
||||
<div className="space-y-1">
|
||||
<p className="truncate font-medium">{campaign.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatNiche(campaign)}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="max-w-[180px] p-3 align-top">
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<p className="inline-flex items-center gap-1">
|
||||
<MapPin className="size-3" />
|
||||
<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">
|
||||
<div className="grid gap-3">
|
||||
{campaignsSorted.map((campaign) => (
|
||||
<Card key={campaign._id}>
|
||||
<CardHeader>
|
||||
@@ -447,7 +342,6 @@ export function CampaignsBoard() {
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
type LeadBlacklistStatus,
|
||||
} from "@/lib/dashboard-model";
|
||||
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
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>
|
||||
</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>
|
||||
|
||||
<div className="mx-auto grid w-full max-w-7xl gap-3">
|
||||
{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>
|
||||
<p className="rounded-md bg-muted p-4 text-sm">Leads werden geladen…</p>
|
||||
) : 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.
|
||||
Keine Leads vorhanden. Bitte zuerst eine Kampagne starten oder
|
||||
importieren.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
) : (
|
||||
<tbody>
|
||||
{sortedLeads.map((lead) => (
|
||||
sortedLeads.map((lead) => (
|
||||
<LeadReviewRow
|
||||
key={lead._id}
|
||||
lead={lead}
|
||||
onActionMessage={setActionMessage}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
))
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{actionMessage ? (
|
||||
@@ -219,6 +183,7 @@ function LeadReviewRow({
|
||||
lead: LeadRow;
|
||||
onActionMessage: (value: string) => void;
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [draft, setDraft] = useState<LeadReviewDraft>(() => ({
|
||||
priority: lead.priority,
|
||||
contactStatus: lead.contactStatus,
|
||||
@@ -313,57 +278,86 @@ function LeadReviewRow({
|
||||
setDraft((current) => ({ ...current, [field]: value }));
|
||||
};
|
||||
|
||||
const detailsId = `lead-review-details-${lead._id}`;
|
||||
|
||||
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">
|
||||
<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="truncate">{lead.niche ?? "Nische offen"}</span>
|
||||
<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>{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 className="inline-flex min-w-0 max-w-full truncate">
|
||||
{location}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<td className="p-3 align-top">
|
||||
<p
|
||||
className={`inline-flex rounded-md border px-2 py-1 text-xs font-medium ${priorityBadgeClass(
|
||||
className={`inline-flex shrink-0 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]">
|
||||
</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) =>
|
||||
@@ -382,13 +376,11 @@ function LeadReviewRow({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</td>
|
||||
</div>
|
||||
|
||||
<td className="p-3 align-top">
|
||||
<Badge variant="outline">
|
||||
{getLeadContactStatusLabel(draft.contactStatus)}
|
||||
</Badge>
|
||||
<div className="mt-2 max-w-[180px]">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Kontaktstatus</p>
|
||||
<div className="mt-2">
|
||||
<Select
|
||||
value={draft.contactStatus}
|
||||
onValueChange={(nextStatus) =>
|
||||
@@ -407,10 +399,11 @@ function LeadReviewRow({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</td>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<td className="max-w-[220px] p-3 align-top">
|
||||
<div className="grid gap-2">
|
||||
<section className="grid gap-2">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Prioritätsgrund</p>
|
||||
<Input
|
||||
value={draft.priorityReason}
|
||||
@@ -419,8 +412,10 @@ function LeadReviewRow({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 grid gap-2">
|
||||
<p className="text-xs text-muted-foreground">Kontaktstatus-Notiz</p>
|
||||
<div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Kontaktstatus-Notiz
|
||||
</p>
|
||||
<Input
|
||||
value={draft.contactStatusReason}
|
||||
onChange={(event) => {
|
||||
@@ -428,8 +423,8 @@ function LeadReviewRow({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 grid gap-2">
|
||||
<p className="text-xs text-muted-foreground">Notiz</p>
|
||||
<div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">Notiz</p>
|
||||
<Input
|
||||
value={draft.notes}
|
||||
onChange={(event) => {
|
||||
@@ -437,22 +432,6 @@ function LeadReviewRow({
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
@@ -460,10 +439,10 @@ function LeadReviewRow({
|
||||
reasonParts.map((reason) => <p key={reason}>• {reason}</p>)
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</section>
|
||||
|
||||
<td className="min-w-[260px] p-3 align-top">
|
||||
<div className="grid gap-2">
|
||||
<section className="grid gap-2">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Review-E-Mail</p>
|
||||
<Input
|
||||
value={draft.reviewEmail}
|
||||
@@ -473,8 +452,8 @@ function LeadReviewRow({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid gap-2">
|
||||
<p className="text-xs text-muted-foreground">Review-Quelle</p>
|
||||
<div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">Review-Quelle</p>
|
||||
<Input
|
||||
value={draft.reviewEmailSource}
|
||||
onChange={(event) => {
|
||||
@@ -482,9 +461,8 @@ function LeadReviewRow({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid gap-2">
|
||||
<p className="text-xs text-muted-foreground">Ansprechperson</p>
|
||||
<div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">Ansprechperson</p>
|
||||
<Input
|
||||
value={draft.reviewContactPerson}
|
||||
onChange={(event) => {
|
||||
@@ -492,8 +470,7 @@ function LeadReviewRow({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="mt-3 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">
|
||||
<Switch
|
||||
checked={draft.reviewIsBusinessContactAddress}
|
||||
onCheckedChange={(checked) => {
|
||||
@@ -502,9 +479,12 @@ function LeadReviewRow({
|
||||
/>
|
||||
Genannte E-Mail als Business-Kontakt
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div className="mt-3 grid gap-2">
|
||||
<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) =>
|
||||
@@ -523,9 +503,11 @@ function LeadReviewRow({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Sperrstatus</label>
|
||||
<div className="mt-2">
|
||||
<Select
|
||||
value={draft.blacklistStatus}
|
||||
onValueChange={(nextStatus) =>
|
||||
@@ -544,18 +526,26 @@ function LeadReviewRow({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</td>
|
||||
</div>
|
||||
|
||||
<td className="max-w-[170px] p-3 align-top">
|
||||
<div className="grid gap-2">
|
||||
<Button
|
||||
onClick={saveRow}
|
||||
disabled={isSaving || isBlocking}
|
||||
size="sm"
|
||||
<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}
|
||||
@@ -566,11 +556,12 @@ function LeadReviewRow({
|
||||
Sperren
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{rowMessage ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">{rowMessage}</p>
|
||||
<p className="text-xs text-muted-foreground">{rowMessage}</p>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
30
tests/campaigns-board-layout.test.ts
Normal file
30
tests/campaigns-board-layout.test.ts
Normal 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\)/);
|
||||
});
|
||||
112
tests/leads-review-table.test.ts
Normal file
112
tests/leads-review-table.test.ts
Normal 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/);
|
||||
});
|
||||
Reference in New Issue
Block a user