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,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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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