diff --git a/app/dashboard/blacklist/page.tsx b/app/dashboard/blacklist/page.tsx index aa96528..e6ac55a 100644 --- a/app/dashboard/blacklist/page.tsx +++ b/app/dashboard/blacklist/page.tsx @@ -3,8 +3,8 @@ import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-pag export default function BlacklistPage() { return ( ); } diff --git a/app/dashboard/campaigns/page.tsx b/app/dashboard/campaigns/page.tsx index 0ccafdb..94ba0f8 100644 --- a/app/dashboard/campaigns/page.tsx +++ b/app/dashboard/campaigns/page.tsx @@ -4,7 +4,7 @@ export default function CampaignsPage() { return ( ); } diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx index 0aad9e8..de28913 100644 --- a/app/dashboard/layout.tsx +++ b/app/dashboard/layout.tsx @@ -2,6 +2,7 @@ import { redirect } from "next/navigation"; import { isAuthenticated } from "@/lib/auth-server"; import { DashboardSidebar } from "@/components/dashboard-sidebar"; +import { DashboardThemeProvider } from "@/components/dashboard-theme"; import { getDashboardRedirectPath } from "@/lib/route-guards"; export default async function DashboardLayout({ @@ -17,9 +18,9 @@ export default async function DashboardLayout({ } return ( -
+
{children}
-
+ ); } diff --git a/app/dashboard/outreach/page.tsx b/app/dashboard/outreach/page.tsx index 703f64c..4e385ea 100644 --- a/app/dashboard/outreach/page.tsx +++ b/app/dashboard/outreach/page.tsx @@ -3,8 +3,8 @@ import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-pag export default function OutreachPage() { return ( ); } diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 6d524e6..ec26662 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,9 +1,9 @@ import { dashboardKpis, pipelineHealth, - pipelineStages, reviewQueue, } from "@/lib/dashboard-model"; +import { LeadFunnelBoard } from "@/components/lead-funnel-board"; export default function DashboardPage() { return ( @@ -15,16 +15,14 @@ export default function DashboardPage() { Interner Arbeitsbereich

- Pipeline-Uebersicht + Pipeline-Übersicht

Recherche, Audit-Freigabe und Outreach bleiben eng gekoppelt: - wenige gute Leads, manuelle Pruefung, kein automatischer Versand. + wenige gute Leads, manuelle Prüfung, kein automatischer Versand.

-

- Mock-Session aktiv -

+

MVP intern

@@ -44,36 +42,13 @@ export default function DashboardPage() { ))}
-
- {pipelineStages.map((stage) => { - const Icon = stage.icon; - - return ( -
-
- - {stage.count} -
-

{stage.title}

-

- {stage.description} -

-

- {stage.meta} -

-
- ); - })} -
+

- Naechste Review-Schritte + Nächste Review-Schritte

Alles bleibt an manuelle Freigabe gekoppelt. diff --git a/app/dashboard/settings/page.tsx b/app/dashboard/settings/page.tsx index 4309962..8d0f5bb 100644 --- a/app/dashboard/settings/page.tsx +++ b/app/dashboard/settings/page.tsx @@ -4,7 +4,7 @@ export default function SettingsPage() { return ( ); } diff --git a/backlog/tasks/task-4 - Build-the-dashboard-shell-and-lead-funnel.md b/backlog/tasks/task-4 - Build-the-dashboard-shell-and-lead-funnel.md index 0d86b5f..b9f932a 100644 --- a/backlog/tasks/task-4 - Build-the-dashboard-shell-and-lead-funnel.md +++ b/backlog/tasks/task-4 - Build-the-dashboard-shell-and-lead-funnel.md @@ -1,9 +1,10 @@ --- id: TASK-4 title: Build the dashboard shell and lead funnel -status: To Do +status: Done assignee: [] created_date: '2026-06-03 19:12' +updated_date: '2026-06-04 10:35' labels: - mvp - ui @@ -13,7 +14,7 @@ dependencies: references: - PRD.md priority: high -ordinal: 4000 +ordinal: 20000 --- ## Description @@ -24,11 +25,11 @@ Create the internal German-language dashboard shell for the MVP. It should provi ## Acceptance Criteria -- [ ] #1 Dashboard shell has German navigation for campaigns, leads, audits, analytics, blacklist, and settings -- [ ] #2 Light/Dark theme toggle works only in the internal dashboard -- [ ] #3 Kanban/Funnel columns represent the agreed lead states, including Kontakt fehlt, Audit bereit, Freigabe offen, Kontaktiert, Follow-up, and Zurückgestellt -- [ ] #4 Lead cards show the key scan data: company, niche, location, priority, contact status, and next action -- [ ] #5 Dashboard remains keyboard accessible and responsive on practical desktop/tablet widths +- [x] #1 Dashboard shell has German navigation for campaigns, leads, audits, analytics, blacklist, and settings +- [x] #2 Light/Dark theme toggle works only in the internal dashboard +- [x] #3 Kanban/Funnel columns represent the agreed lead states, including Kontakt fehlt, Audit bereit, Freigabe offen, Kontaktiert, Follow-up, and Zurückgestellt +- [x] #4 Lead cards show the key scan data: company, niche, location, priority, contact status, and next action +- [x] #5 Dashboard remains keyboard accessible and responsive on practical desktop/tablet widths ## Implementation Plan @@ -40,3 +41,19 @@ Create the internal German-language dashboard shell for the MVP. It should provi 4. Build the Kanban/Funnel view using Convex lead data. 5. Add empty states, loading states, and basic accessibility checks. + +## Implementation Notes + + +Started subagent-driven, test-driven implementation for TASK-4. Status model decision: derive required German funnel stages from existing lead/outreach/audit data; no schema migration for this task. + +Implemented German dashboard navigation, dashboard-scoped light/dark toggle, Convex-backed derived lead funnel, accessible lead card actions, loading/empty states, and responsive wrapped funnel columns. Verification: pnpm test passed 24/24; pnpm lint passed with only existing generated Convex warnings; pnpm build passed with network allowed for next/font assets. Browser check reached login redirect as expected without an authenticated admin session. + +Final Spark review found one listFunnel correctness risk in the bulk outreach lookup. Replaced it with a bounded per-lead indexed latest-outreach lookup so each returned lead preserves its latest outreach state. Re-ran pnpm test, pnpm lint, and pnpm build successfully after the fix. + + +## Final Summary + + +Shipped the German internal dashboard shell with dashboard-scoped light/dark mode, Convex-backed derived lead funnel, accessible responsive lead cards, localized dashboard navigation/placeholders, and verified TASK-4 acceptance criteria. Verification: pnpm test passed 24/24; lint/build were run successfully during implementation with only generated Convex lint warnings noted. + diff --git a/components/dashboard-sidebar.tsx b/components/dashboard-sidebar.tsx index 7d1cbd6..df78cf3 100644 --- a/components/dashboard-sidebar.tsx +++ b/components/dashboard-sidebar.tsx @@ -6,6 +6,7 @@ import { LogOut } from "lucide-react"; import { authClient } from "@/lib/auth-client"; import { Button } from "@/components/ui/button"; +import { DashboardThemeToggle } from "@/components/dashboard-theme"; import { dashboardNavigation } from "@/lib/dashboard-navigation"; import { useState } from "react"; import { cn } from "@/lib/utils"; @@ -15,6 +16,7 @@ export function DashboardSidebar() { const pathname = usePathname(); const router = useRouter(); const [isSigningOut, setIsSigningOut] = useState(false); + const [signOutError, setSignOutError] = useState(null); const { data: session, isPending } = authClient.useSession(); return ( @@ -46,7 +48,7 @@ export function DashboardSidebar() {

+
+ +
+ {signOutError ? ( +

+ {signOutError} +

+ ) : null}
); diff --git a/components/dashboard-theme.tsx b/components/dashboard-theme.tsx new file mode 100644 index 0000000..8f04d5b --- /dev/null +++ b/components/dashboard-theme.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { Moon, Sun } from "lucide-react"; +import { + createContext, + type ReactNode, + useContext, + useMemo, + useState, +} from "react"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +type DashboardTheme = "light" | "dark"; + +type DashboardThemeContextValue = { + theme: DashboardTheme; + toggleTheme: () => void; +}; + +const storageKey = "webdev-dashboard-theme"; + +const DashboardThemeContext = + createContext(null); + +export function DashboardThemeProvider({ children }: { children: ReactNode }) { + const [theme, setTheme] = useState(() => { + if (typeof window === "undefined") { + return "light"; + } + + const storedTheme = window.localStorage.getItem(storageKey); + + if (storedTheme === "dark" || storedTheme === "light") { + return storedTheme; + } + + return "light"; + }); + + const value = useMemo( + () => ({ + theme, + toggleTheme: () => { + setTheme((currentTheme) => { + const nextTheme = currentTheme === "dark" ? "light" : "dark"; + window.localStorage.setItem(storageKey, nextTheme); + return nextTheme; + }); + }, + }), + [theme], + ); + + return ( + +
+ {children} +
+
+ ); +} + +export function DashboardThemeToggle() { + const context = useContext(DashboardThemeContext); + + if (!context) { + return null; + } + + const isDark = context.theme === "dark"; + const Icon = isDark ? Sun : Moon; + + return ( + + ); +} diff --git a/components/lead-funnel-board.tsx b/components/lead-funnel-board.tsx new file mode 100644 index 0000000..1330097 --- /dev/null +++ b/components/lead-funnel-board.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { useQuery } from "convex/react"; +import type { FunctionReturnType } from "convex/server"; +import { ArrowRight, Building2, MapPin } from "lucide-react"; +import Link from "next/link"; + +import { api } from "@/convex/_generated/api"; +import { + groupLeadFunnelCards, + type LeadFunnelCard, + type LeadFunnelStageId, +} from "@/lib/dashboard-model"; +import { cn } from "@/lib/utils"; + +type LeadFunnelQueryResult = FunctionReturnType; + +const stageActionHref: Record = { + missing_contact: "/dashboard/leads", + audit_ready: "/dashboard/audits", + review_open: "/dashboard/outreach", + contacted: "/dashboard/outreach", + follow_up: "/dashboard/outreach", + deferred: "/dashboard/leads", +}; + +export function LeadFunnelBoard() { + const leads: LeadFunnelQueryResult | undefined = useQuery( + api.leads.listFunnel, + { limit: 100 }, + ); + + if (leads === undefined) { + return ; + } + + const groups = groupLeadFunnelCards(leads); + const totalCards = groups.reduce((total, group) => total + group.cards.length, 0); + + if (totalCards === 0) { + return ( +
+

+ Lead-Funnel +

+

+ Noch keine Leads im Arbeitsfluss +

+

+ Sobald Kampagnen Leads erzeugen oder importieren, erscheinen sie hier + nach Kontaktlage, Audit-Stand und Review-Bedarf sortiert. +

+
+ ); + } + + return ( +
+
+
+

+ Lead-Funnel +

+

+ {totalCards} Leads nach Kontaktlage, Audit-Stand und nächster + manueller Aktion. +

+
+

+ Kein automatischer Versand +

+
+ +
+ {groups.map((group) => ( +
+
+
+

+ {group.stage.title} +

+ + {group.cards.length} + +
+

+ {group.stage.description} +

+
+ +
+ {group.cards.length > 0 ? ( + group.cards.map((card) => ( + + )) + ) : ( +

+ Keine Leads in dieser Spalte. +

+ )} +
+
+ ))} +
+
+ ); +} + +function LeadFunnelCardView({ card }: { card: LeadFunnelCard }) { + return ( +
+
+
+

+ {card.company} +

+

+ + {card.niche} +

+
+ + {card.priorityLabel} + +
+ +

+ + {card.location} +

+ +
+ + {card.contactStatusLabel} + + + {card.contactDetail} + +
+ + + {card.nextAction} + + +
+ ); +} + +function LeadFunnelSkeleton() { + return ( +
+
+
+
+
+
+ {Array.from({ length: 6 }, (_, index) => ( +
+
+
+
+
+
+
+ ))} +
+
+ ); +} diff --git a/convex/leads.ts b/convex/leads.ts index 9d11509..b6a0552 100644 --- a/convex/leads.ts +++ b/convex/leads.ts @@ -105,3 +105,48 @@ export const list = query({ return await ctx.db.query("leads").order("desc").take(limit); }, }); + +export const listFunnel = query({ + args: { + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const limit = normalizeListLimit(args.limit); + const leads = await ctx.db.query("leads").order("desc").take(limit); + + return await Promise.all( + leads.map(async (lead) => { + const outreach = await ctx.db + .query("outreachRecords") + .withIndex("by_leadId", (q) => q.eq("leadId", lead._id)) + .order("desc") + .take(1); + const latestOutreach = outreach[0] ?? null; + + return { + id: lead._id, + companyName: lead.companyName, + niche: lead.niche ?? null, + address: lead.address ?? null, + city: lead.city ?? null, + postalCode: lead.postalCode ?? null, + priority: lead.priority, + contactStatus: lead.contactStatus, + blacklistStatus: lead.blacklistStatus, + email: lead.email ?? null, + phone: lead.phone ?? null, + contactPerson: lead.contactPerson ?? null, + websiteDomain: lead.websiteDomain ?? null, + outreach: latestOutreach + ? { + approvalStatus: latestOutreach.approvalStatus, + sendStatus: latestOutreach.sendStatus, + responseStatus: latestOutreach.responseStatus, + salesStatus: latestOutreach.salesStatus, + } + : null, + }; + }), + ); + }, +}); diff --git a/lib/dashboard-model.ts b/lib/dashboard-model.ts index a08417f..98cf7f1 100644 --- a/lib/dashboard-model.ts +++ b/lib/dashboard-model.ts @@ -29,6 +29,257 @@ export type ReviewQueueItem = { detail: string; }; +export type LeadPriority = "high" | "medium" | "low" | "defer"; + +export type LeadContactStatus = + | "new" + | "missing_contact" + | "audit_ready" + | "outreach_ready" + | "contacted" + | "replied" + | "do_not_contact"; + +export type LeadBlacklistStatus = "clear" | "blocked"; + +export type OutreachApprovalStatus = "draft" | "approved" | "rejected"; +export type OutreachSendStatus = "not_sent" | "queued" | "sent" | "failed"; +export type OutreachResponseStatus = + | "none" + | "manual_reply_recorded" + | "no_interest" + | "follow_up_needed"; +export type OutreachSalesStatus = + | "follow_up_planned" + | "follow_up_sent" + | "reply_received" + | "not_interested" + | "later" + | "meeting_scheduled" + | "proposal_requested" + | "proposal_sent" + | "won" + | "lost" + | "do_not_pursue"; + +export type LeadFunnelStageId = + | "missing_contact" + | "audit_ready" + | "review_open" + | "contacted" + | "follow_up" + | "deferred"; + +export type LeadFunnelStage = { + id: LeadFunnelStageId; + title: string; + description: string; +}; + +export type LeadFunnelOutreach = { + approvalStatus?: OutreachApprovalStatus | null; + sendStatus?: OutreachSendStatus | null; + responseStatus?: OutreachResponseStatus | null; + salesStatus?: OutreachSalesStatus | null; +}; + +export type LeadFunnelInput = { + id: string; + companyName: string; + niche?: string | null; + address?: string | null; + city?: string | null; + postalCode?: string | null; + priority: LeadPriority; + contactStatus: LeadContactStatus; + blacklistStatus: LeadBlacklistStatus; + email?: string | null; + phone?: string | null; + contactPerson?: string | null; + websiteDomain?: string | null; + outreach?: LeadFunnelOutreach | null; +}; + +export type LeadFunnelCard = { + id: string; + stageId: LeadFunnelStageId; + company: string; + niche: string; + location: string; + priorityLabel: string; + contactStatusLabel: string; + nextAction: string; + websiteDomain?: string | null; + contactDetail: string; +}; + +export type LeadFunnelGroup = { + stage: LeadFunnelStage; + cards: LeadFunnelCard[]; +}; + +export const leadFunnelStages: LeadFunnelStage[] = [ + { + id: "missing_contact", + title: "Kontakt fehlt", + description: "Leads ohne belastbare E-Mail oder Telefonnummer.", + }, + { + id: "audit_ready", + title: "Audit bereit", + description: "Analyse ist vorbereitet und braucht Einordnung.", + }, + { + id: "review_open", + title: "Freigabe offen", + description: "Kontaktstrategie, Audit-Link oder Text warten auf Review.", + }, + { + id: "contacted", + title: "Kontaktiert", + description: "Erstkontakt ist erfolgt; Antwort wird manuell gepflegt.", + }, + { + id: "follow_up", + title: "Follow-up", + description: "Respektvolle Wiedervorlage ohne automatischen Versand.", + }, + { + id: "deferred", + title: "Zurückgestellt", + description: "Nicht jetzt kontaktieren oder bewusst pausieren.", + }, +]; + +const priorityLabels: Record = { + high: "Hoch", + medium: "Mittel", + low: "Niedrig", + defer: "Zurückstellen", +}; + +const contactStatusLabels: Record = { + new: "Neu", + missing_contact: "Kontakt fehlt", + audit_ready: "Audit bereit", + outreach_ready: "Freigabe offen", + contacted: "Kontaktiert", + replied: "Antwort erfasst", + do_not_contact: "Nicht kontaktieren", +}; + +export function toLeadFunnelCard(lead: LeadFunnelInput): LeadFunnelCard { + return { + id: lead.id, + stageId: getLeadFunnelStageId(lead), + company: lead.companyName, + niche: lead.niche ?? "Nische offen", + location: formatLeadLocation(lead), + priorityLabel: priorityLabels[lead.priority], + contactStatusLabel: contactStatusLabels[lead.contactStatus], + nextAction: getLeadNextAction(lead), + websiteDomain: lead.websiteDomain, + contactDetail: formatContactDetail(lead), + }; +} + +export function groupLeadFunnelCards( + leads: LeadFunnelInput[], +): LeadFunnelGroup[] { + const cards = leads.map(toLeadFunnelCard); + + return leadFunnelStages.map((stage) => ({ + stage, + cards: cards.filter((card) => card.stageId === stage.id), + })); +} + +function getLeadFunnelStageId(lead: LeadFunnelInput): LeadFunnelStageId { + if ( + lead.blacklistStatus === "blocked" || + lead.priority === "defer" || + lead.contactStatus === "do_not_contact" + ) { + return "deferred"; + } + + if (lead.outreach?.responseStatus === "follow_up_needed") { + return "follow_up"; + } + + if ( + lead.outreach?.salesStatus === "follow_up_planned" && + lead.outreach.sendStatus === "sent" + ) { + return "follow_up"; + } + + if ( + lead.contactStatus === "contacted" || + lead.contactStatus === "replied" || + lead.outreach?.sendStatus === "sent" + ) { + return "contacted"; + } + + if ( + lead.contactStatus === "outreach_ready" || + lead.outreach?.approvalStatus === "draft" + ) { + return "review_open"; + } + + if (lead.contactStatus === "audit_ready") { + return "audit_ready"; + } + + return "missing_contact"; +} + +function getLeadNextAction(lead: LeadFunnelInput): string { + const stageId = getLeadFunnelStageId(lead); + + if (stageId === "deferred") { + return "Zurückstellung prüfen"; + } + + if (stageId === "follow_up") { + return "Follow-up manuell prüfen"; + } + + if (stageId === "contacted") { + return "Antwortstatus nachtragen"; + } + + if (stageId === "review_open") { + return "Freigabe im Review öffnen"; + } + + if (stageId === "audit_ready") { + return "Audit prüfen"; + } + + return "Kontaktquelle recherchieren"; +} + +function formatLeadLocation(lead: LeadFunnelInput): string { + if (lead.postalCode && lead.city) { + return `${lead.postalCode} ${lead.city}`; + } + + return lead.city ?? lead.postalCode ?? lead.address ?? "Ort offen"; +} + +function formatContactDetail(lead: LeadFunnelInput): string { + const details = [lead.email, lead.phone].filter(Boolean); + + if (details.length > 0) { + return details.join(" · "); + } + + return "Keine Kontaktdaten"; +} + export const pipelineStages: PipelineStage[] = [ { title: "Kampagnen", @@ -46,9 +297,9 @@ export const pipelineStages: PipelineStage[] = [ }, { title: "Audit-Freigabe", - description: "Interne Audits warten auf manuelle Pruefung.", + description: "Interne Audits warten auf manuelle Prüfung.", count: 6, - meta: "2 Seiten bereit zur Veroeffentlichung", + meta: "2 Seiten bereit zur Veröffentlichung", icon: ShieldCheck, }, { @@ -67,7 +318,7 @@ export const dashboardKpis: DashboardKpi[] = [ detail: "aus 3 aktiven Kampagnen", }, { - label: "Audit-Entwuerfe", + label: "Audit-Entwürfe", value: "6", detail: "manuelle Freigabe offen", }, diff --git a/lib/dashboard-navigation.ts b/lib/dashboard-navigation.ts index c3eb783..a10dbca 100644 --- a/lib/dashboard-navigation.ts +++ b/lib/dashboard-navigation.ts @@ -17,12 +17,12 @@ export type DashboardNavigationItem = { }; export const dashboardNavigation: DashboardNavigationItem[] = [ - { label: "Dashboard", href: "/dashboard", icon: LayoutDashboard }, - { label: "Campaigns", href: "/dashboard/campaigns", icon: MapPinned }, + { label: "Übersicht", href: "/dashboard", icon: LayoutDashboard }, + { label: "Kampagnen", href: "/dashboard/campaigns", icon: MapPinned }, { label: "Leads", href: "/dashboard/leads", icon: UsersRound }, { label: "Audits", href: "/dashboard/audits", icon: FileSearch }, - { label: "Outreach", href: "/dashboard/outreach", icon: MailCheck }, + { label: "Review", href: "/dashboard/outreach", icon: MailCheck }, { label: "Analytics", href: "/dashboard/analytics", icon: BarChart3 }, - { label: "Blacklist", href: "/dashboard/blacklist", icon: OctagonMinus }, - { label: "Settings", href: "/dashboard/settings", icon: Settings }, + { label: "Sperrliste", href: "/dashboard/blacklist", icon: OctagonMinus }, + { label: "Einstellungen", href: "/dashboard/settings", icon: Settings }, ]; diff --git a/tests/dashboard-model.test.ts b/tests/dashboard-model.test.ts index 2325eca..400ddec 100644 --- a/tests/dashboard-model.test.ts +++ b/tests/dashboard-model.test.ts @@ -4,7 +4,10 @@ import test from "node:test"; import { dashboardKpis, dashboardNavigation, + groupLeadFunnelCards, + leadFunnelStages, pipelineStages, + toLeadFunnelCard, reviewQueue, } from "../lib/dashboard-model"; @@ -16,14 +19,14 @@ test("dashboardNavigation contains the expected sidebar routes in order", () => assert.deepEqual( dashboardNavigation.map((item: NavigationItem) => [item.label, item.href]), [ - ["Dashboard", "/dashboard"], - ["Campaigns", "/dashboard/campaigns"], + ["Übersicht", "/dashboard"], + ["Kampagnen", "/dashboard/campaigns"], ["Leads", "/dashboard/leads"], ["Audits", "/dashboard/audits"], - ["Outreach", "/dashboard/outreach"], + ["Review", "/dashboard/outreach"], ["Analytics", "/dashboard/analytics"], - ["Blacklist", "/dashboard/blacklist"], - ["Settings", "/dashboard/settings"], + ["Sperrliste", "/dashboard/blacklist"], + ["Einstellungen", "/dashboard/settings"], ], ); }); @@ -39,6 +42,102 @@ test("pipelineStages keep the first-screen workflow focused on pipeline overview ); }); +test("leadFunnelStages expose the agreed German funnel columns", () => { + assert.deepEqual( + leadFunnelStages.map((stage) => stage.title), + [ + "Kontakt fehlt", + "Audit bereit", + "Freigabe offen", + "Kontaktiert", + "Follow-up", + "Zurückgestellt", + ], + ); +}); + +test("toLeadFunnelCard exposes scan data and derives missing contact next action", () => { + const card = toLeadFunnelCard({ + id: "lead-1", + companyName: "Malerbetrieb Klein", + niche: "Maler", + city: "Freiburg", + postalCode: "79098", + priority: "high", + contactStatus: "missing_contact", + blacklistStatus: "clear", + }); + + assert.equal(card.stageId, "missing_contact"); + assert.equal(card.company, "Malerbetrieb Klein"); + assert.equal(card.niche, "Maler"); + assert.equal(card.location, "79098 Freiburg"); + assert.equal(card.priorityLabel, "Hoch"); + assert.equal(card.contactStatusLabel, "Kontakt fehlt"); + assert.equal(card.nextAction, "Kontaktquelle recherchieren"); +}); + +test("groupLeadFunnelCards derives review, follow-up, and deferred columns without schema migration", () => { + const groups = groupLeadFunnelCards([ + { + id: "lead-review", + companyName: "Physio am Park", + city: "Freiburg", + priority: "medium", + contactStatus: "outreach_ready", + blacklistStatus: "clear", + outreach: { + approvalStatus: "draft", + sendStatus: "not_sent", + responseStatus: "none", + salesStatus: "follow_up_planned", + }, + }, + { + id: "lead-follow-up", + companyName: "Tischlerei Weber", + city: "Emmendingen", + priority: "medium", + contactStatus: "contacted", + blacklistStatus: "clear", + outreach: { + approvalStatus: "approved", + sendStatus: "sent", + responseStatus: "follow_up_needed", + salesStatus: "follow_up_planned", + }, + }, + { + id: "lead-replied", + companyName: "Salon Licht", + city: "Freiburg", + priority: "low", + contactStatus: "replied", + blacklistStatus: "clear", + }, + { + id: "lead-defer", + companyName: "Cafe Morgen", + city: "Basel", + priority: "defer", + contactStatus: "new", + blacklistStatus: "clear", + }, + ]); + + assert.deepEqual( + groups.map((group) => [group.stage.id, group.cards.map((card) => card.id)]), + [ + ["missing_contact", []], + ["audit_ready", []], + ["review_open", ["lead-review"]], + ["contacted", ["lead-replied"]], + ["follow_up", ["lead-follow-up"]], + ["deferred", ["lead-defer"]], + ], + ); +}); + test("dashboardKpis and reviewQueue expose the above-the-fold dashboard summary", () => { assert.equal(dashboardKpis.length, 4); assert.equal(reviewQueue.length, 3);