From ca42c8d5a6ae140d7467d5529377e97b88253fd1 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Thu, 4 Jun 2026 17:11:39 +0200 Subject: [PATCH 1/2] feat: convert campaign and lead views to cards --- ...rt-campaigns-and-leads-to-compact-cards.md | 51 ++ components/campaigns/campaigns-board.tsx | 248 ++----- components/leads/leads-review-table.tsx | 607 +++++++++--------- tests/campaigns-board-layout.test.ts | 30 + tests/leads-review-table.test.ts | 112 ++++ 5 files changed, 563 insertions(+), 485 deletions(-) create mode 100644 backlog/tasks/task-20 - Convert-campaigns-and-leads-to-compact-cards.md create mode 100644 tests/campaigns-board-layout.test.ts create mode 100644 tests/leads-review-table.test.ts diff --git a/backlog/tasks/task-20 - Convert-campaigns-and-leads-to-compact-cards.md b/backlog/tasks/task-20 - Convert-campaigns-and-leads-to-compact-cards.md new file mode 100644 index 0000000..00473ba --- /dev/null +++ b/backlog/tasks/task-20 - Convert-campaigns-and-leads-to-compact-cards.md @@ -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 + + +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. + + +## Acceptance Criteria + +- [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. + + + + +## Implementation Plan + + +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. + + +## Implementation Notes + + +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. + + +## Final Summary + + +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. + diff --git a/components/campaigns/campaigns-board.tsx b/components/campaigns/campaigns-board.tsx index 2816495..fbad89b 100644 --- a/components/campaigns/campaigns-board.tsx +++ b/components/campaigns/campaigns-board.tsx @@ -267,187 +267,81 @@ export function CampaignsBoard() { ) : ( - <> -
- - - - - - - - - - - - - - - {campaignsSorted.map((campaign) => ( - + {campaignsSorted.map((campaign) => ( + + +
+
+ {campaign.name} + + {formatNiche(campaign)} + +
+ -
+ {campaign.status === "active" ? "Aktiv" : "Pausiert"} + + + - - - - - - - - - - - - - ))} - -
KampagnePLZ / RadiusCadenceLimitsStatusLaufAktionen
-
-

{campaign.name}

-

- {formatNiche(campaign)} -

-
-
-
-

- - {campaign.postalCode} -

-

{campaign.radiusKm} km Umkreis

-
-
- - {recurrenceLabel[campaign.recurrence]} - - -

- Leads: {campaign.maxNewLeadsPerRun} · Audits:{" "} - {campaign.maxAuditsPerRun} -

-
- - {campaign.status === "active" ? "Aktiv" : "Pausiert"} - - -
-

Letzter Lauf: {formatDateTime(campaign.lastRunAt)}

-

Nächster Lauf: {formatDateTime(campaign.nextRunAt)}

-

Run-Status: {statusLabel[campaign.currentRunStatus] ?? campaign.currentRunStatus}

-
-
-
- - - -
-
-
- -
- {campaignsSorted.map((campaign) => ( - - -
-
- {campaign.name} - - {formatNiche(campaign)} - -
- - {campaign.status === "active" ? "Aktiv" : "Pausiert"} - + +
+
+ + {campaign.postalCode}
- + {campaign.radiusKm} km +
+ +
+

Cadence: {recurrenceLabel[campaign.recurrence]}

+

+ Limits: L {campaign.maxNewLeadsPerRun}, A{" "} + {campaign.maxAuditsPerRun} +

+
+
+

Letzter Lauf: {formatDateTime(campaign.lastRunAt)}

+

Nächster Lauf: {formatDateTime(campaign.nextRunAt)}

+

+ Run-Status: {statusLabel[campaign.currentRunStatus] ?? campaign.currentRunStatus} +

+
- -
-
- - {campaign.postalCode} -
- {campaign.radiusKm} km -
- -
-

Cadence: {recurrenceLabel[campaign.recurrence]}

-

- Limits: L {campaign.maxNewLeadsPerRun}, A{" "} - {campaign.maxAuditsPerRun} -

-
-
-

Letzter Lauf: {formatDateTime(campaign.lastRunAt)}

-

Nächster Lauf: {formatDateTime(campaign.nextRunAt)}

-

- Run-Status: {statusLabel[campaign.currentRunStatus] ?? campaign.currentRunStatus} -

-
- -
- - - -
-
- - ))} -
- +
+ + + +
+ +
+ ))} +
)} ); diff --git a/components/leads/leads-review-table.tsx b/components/leads/leads-review-table.tsx index 913c6cd..c1d8c9f 100644 --- a/components/leads/leads-review-table.tsx +++ b/components/leads/leads-review-table.tsx @@ -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() {

Leads prüfen

-
- -
-
- - - - - - - - - - - - - - {leads === undefined ? ( - - - - - - ) : sortedLeads.length === 0 ? ( - - - - - - ) : ( - - {sortedLeads.map((lead) => ( - - ))} - - )} -
Firma / OrtKontakt + QuellePrioritätKontaktstatusQualitätReview-FelderAktionen
-

- Leads werden geladen… -

-
-

- Keine Leads vorhanden. Bitte zuerst eine Kampagne starten - oder importieren. -

-
-
-
-
+
+ {leads === undefined ? ( +

Leads werden geladen…

+ ) : sortedLeads.length === 0 ? ( +

+ Keine Leads vorhanden. Bitte zuerst eine Kampagne starten oder + importieren. +

+ ) : ( + sortedLeads.map((lead) => ( + + )) + )}
{actionMessage ? ( @@ -219,6 +183,7 @@ function LeadReviewRow({ lead: LeadRow; onActionMessage: (value: string) => void; }) { + const [isExpanded, setIsExpanded] = useState(false); const [draft, setDraft] = useState(() => ({ priority: lead.priority, contactStatus: lead.contactStatus, @@ -313,264 +278,290 @@ function LeadReviewRow({ setDraft((current) => ({ ...current, [field]: value })); }; + const detailsId = `lead-review-details-${lead._id}`; + return ( - - -

{lead.companyName}

-

- - {lead.niche ?? "Nische offen"} -

-

- - {location} -

- {lead.address ? ( -

- {lead.address} -

- ) : null} - + + +
+
+
+

+ {lead.companyName} +

+

+ + + {lead.niche ?? "Nische offen"} + +

+

+ + + {location} + +

+
- -

- - - {lead.email || "Keine E-Mail"} - -

- {lead.phone ? ( -

- - {lead.phone} -

- ) : null} -

- Quelle: {contactSourceLabel(lead)} -

- {lead.websiteDomain ? ( -

- Domain: {lead.websiteDomain} -

- ) : null} - +

+ {getLeadPriorityLabel(draft.priority)} +

+
- -

+

+ + + {lead.email || "Keine E-Mail"} + +

+ {lead.phone ? ( +

+ + {lead.phone} +

+ ) : null} +

+ Quelle: {contactSourceLabel(lead)} +

+ {lead.websiteDomain ? ( +

Domain: {lead.websiteDomain}

+ ) : null} +
+
+ + +
+ +
+ + + ); } diff --git a/tests/campaigns-board-layout.test.ts b/tests/campaigns-board-layout.test.ts new file mode 100644 index 0000000..c31a7f8 --- /dev/null +++ b/tests/campaigns-board-layout.test.ts @@ -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, / { + const source = await readFile(leadsReviewPath, "utf8"); + + assert.doesNotMatch(source, /