feat: build dashboard lead funnel

This commit is contained in:
2026-06-04 12:35:34 +02:00
parent e660ec24aa
commit 07841aea0f
14 changed files with 766 additions and 64 deletions

View File

@@ -3,8 +3,8 @@ import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-pag
export default function BlacklistPage() { export default function BlacklistPage() {
return ( return (
<DashboardPlaceholderPage <DashboardPlaceholderPage
description="Sperrlisten fuer Domains, E-Mails, Telefonnummern, Firmennamen und Place IDs folgen nach den Datenmodellen." description="Sperrlisten für Domains, E-Mails, Telefonnummern, Firmennamen und Place IDs folgen nach den Datenmodellen."
title="Blacklist" title="Sperrliste"
/> />
); );
} }

View File

@@ -4,7 +4,7 @@ export default function CampaignsPage() {
return ( return (
<DashboardPlaceholderPage <DashboardPlaceholderPage
description="Kampagnen-Konfiguration, PLZ, Radius, Limits und Laufplanung folgen in TASK-5." description="Kampagnen-Konfiguration, PLZ, Radius, Limits und Laufplanung folgen in TASK-5."
title="Campaigns" title="Kampagnen"
/> />
); );
} }

View File

@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
import { isAuthenticated } from "@/lib/auth-server"; import { isAuthenticated } from "@/lib/auth-server";
import { DashboardSidebar } from "@/components/dashboard-sidebar"; import { DashboardSidebar } from "@/components/dashboard-sidebar";
import { DashboardThemeProvider } from "@/components/dashboard-theme";
import { getDashboardRedirectPath } from "@/lib/route-guards"; import { getDashboardRedirectPath } from "@/lib/route-guards";
export default async function DashboardLayout({ export default async function DashboardLayout({
@@ -17,9 +18,9 @@ export default async function DashboardLayout({
} }
return ( return (
<div className="min-h-dvh bg-background md:flex"> <DashboardThemeProvider>
<DashboardSidebar /> <DashboardSidebar />
<div className="min-w-0 flex-1">{children}</div> <div className="min-w-0 flex-1">{children}</div>
</div> </DashboardThemeProvider>
); );
} }

View File

@@ -3,8 +3,8 @@ import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-pag
export default function OutreachPage() { export default function OutreachPage() {
return ( return (
<DashboardPlaceholderPage <DashboardPlaceholderPage
description="E-Mail-Entwuerfe, Telefon-Skripte und manuelle Versandfreigaben folgen in TASK-13 und TASK-14." description="E-Mail-Entwürfe, Telefon-Skripte und manuelle Versandfreigaben folgen in TASK-13 und TASK-14."
title="Outreach" title="Review"
/> />
); );
} }

View File

@@ -1,9 +1,9 @@
import { import {
dashboardKpis, dashboardKpis,
pipelineHealth, pipelineHealth,
pipelineStages,
reviewQueue, reviewQueue,
} from "@/lib/dashboard-model"; } from "@/lib/dashboard-model";
import { LeadFunnelBoard } from "@/components/lead-funnel-board";
export default function DashboardPage() { export default function DashboardPage() {
return ( return (
@@ -15,16 +15,14 @@ export default function DashboardPage() {
Interner Arbeitsbereich Interner Arbeitsbereich
</p> </p>
<h1 className="mt-2 text-3xl font-semibold tracking-normal"> <h1 className="mt-2 text-3xl font-semibold tracking-normal">
Pipeline-Uebersicht Pipeline-Übersicht
</h1> </h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground"> <p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
Recherche, Audit-Freigabe und Outreach bleiben eng gekoppelt: 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.
</p> </p>
</div> </div>
<p className="text-sm font-medium text-muted-foreground"> <p className="text-sm font-medium text-muted-foreground">MVP intern</p>
Mock-Session aktiv
</p>
</header> </header>
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"> <section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
@@ -44,36 +42,13 @@ export default function DashboardPage() {
))} ))}
</section> </section>
<section className="grid gap-3 xl:grid-cols-4"> <LeadFunnelBoard />
{pipelineStages.map((stage) => {
const Icon = stage.icon;
return (
<article
className="rounded-lg border bg-card p-4 text-card-foreground"
key={stage.title}
>
<div className="flex items-center justify-between gap-4">
<Icon className="size-5 text-muted-foreground" />
<span className="text-2xl font-semibold">{stage.count}</span>
</div>
<h2 className="mt-4 text-sm font-medium">{stage.title}</h2>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{stage.description}
</p>
<p className="mt-4 rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
{stage.meta}
</p>
</article>
);
})}
</section>
<section className="grid gap-3 lg:grid-cols-[1.45fr_0.55fr]"> <section className="grid gap-3 lg:grid-cols-[1.45fr_0.55fr]">
<div className="rounded-lg border bg-card text-card-foreground"> <div className="rounded-lg border bg-card text-card-foreground">
<div className="border-b p-4"> <div className="border-b p-4">
<h2 className="text-base font-semibold tracking-normal"> <h2 className="text-base font-semibold tracking-normal">
Naechste Review-Schritte Nächste Review-Schritte
</h2> </h2>
<p className="mt-1 text-sm leading-6 text-muted-foreground"> <p className="mt-1 text-sm leading-6 text-muted-foreground">
Alles bleibt an manuelle Freigabe gekoppelt. Alles bleibt an manuelle Freigabe gekoppelt.

View File

@@ -4,7 +4,7 @@ export default function SettingsPage() {
return ( return (
<DashboardPlaceholderPage <DashboardPlaceholderPage
description="Provider-Status, Secrets-Hinweise und Workspace-Einstellungen folgen mit den Integrationen." description="Provider-Status, Secrets-Hinweise und Workspace-Einstellungen folgen mit den Integrationen."
title="Settings" title="Einstellungen"
/> />
); );
} }

View File

@@ -1,9 +1,10 @@
--- ---
id: TASK-4 id: TASK-4
title: Build the dashboard shell and lead funnel title: Build the dashboard shell and lead funnel
status: To Do status: Done
assignee: [] assignee: []
created_date: '2026-06-03 19:12' created_date: '2026-06-03 19:12'
updated_date: '2026-06-04 10:35'
labels: labels:
- mvp - mvp
- ui - ui
@@ -13,7 +14,7 @@ dependencies:
references: references:
- PRD.md - PRD.md
priority: high priority: high
ordinal: 4000 ordinal: 20000
--- ---
## Description ## Description
@@ -24,11 +25,11 @@ Create the internal German-language dashboard shell for the MVP. It should provi
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 Dashboard shell has German navigation for campaigns, leads, audits, analytics, blacklist, and settings - [x] #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 - [x] #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 - [x] #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 - [x] #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] #5 Dashboard remains keyboard accessible and responsive on practical desktop/tablet widths
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## 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. 4. Build the Kanban/Funnel view using Convex lead data.
5. Add empty states, loading states, and basic accessibility checks. 5. Add empty states, loading states, and basic accessibility checks.
<!-- SECTION:PLAN:END --> <!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
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.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -6,6 +6,7 @@ import { LogOut } from "lucide-react";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DashboardThemeToggle } from "@/components/dashboard-theme";
import { dashboardNavigation } from "@/lib/dashboard-navigation"; import { dashboardNavigation } from "@/lib/dashboard-navigation";
import { useState } from "react"; import { useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -15,6 +16,7 @@ export function DashboardSidebar() {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const [isSigningOut, setIsSigningOut] = useState(false); const [isSigningOut, setIsSigningOut] = useState(false);
const [signOutError, setSignOutError] = useState<string | null>(null);
const { data: session, isPending } = authClient.useSession(); const { data: session, isPending } = authClient.useSession();
return ( return (
@@ -46,7 +48,7 @@ export function DashboardSidebar() {
<Link <Link
aria-current={isActive ? "page" : undefined} aria-current={isActive ? "page" : undefined}
className={cn( className={cn(
"flex h-9 shrink-0 items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors", "flex h-9 shrink-0 items-center gap-2 rounded-lg px-3 text-sm font-medium outline-none transition-colors focus-visible:ring-3 focus-visible:ring-ring/50",
isActive isActive
? "bg-sidebar-primary text-sidebar-primary-foreground" ? "bg-sidebar-primary text-sidebar-primary-foreground"
: "text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", : "text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
@@ -70,20 +72,36 @@ export function DashboardSidebar() {
{session?.user?.email ?? "admin@local"} {session?.user?.email ?? "admin@local"}
</p> </p>
</div> </div>
<div className="mb-2">
<DashboardThemeToggle />
</div>
<Button <Button
className="w-full justify-start" className="w-full justify-start"
variant="outline" variant="outline"
onClick={async () => { onClick={async () => {
setIsSigningOut(true); setIsSigningOut(true);
await authClient.signOut(); setSignOutError(null);
router.replace("/login");
router.refresh(); try {
await authClient.signOut();
router.replace("/login");
router.refresh();
} catch {
setSignOutError("Abmeldung fehlgeschlagen.");
} finally {
setIsSigningOut(false);
}
}} }}
disabled={isSigningOut} disabled={isSigningOut}
> >
<LogOut /> <LogOut />
{isSigningOut ? "Abmeldung..." : "Abmelden"} {isSigningOut ? "Abmeldung läuft..." : "Abmelden"}
</Button> </Button>
{signOutError ? (
<p className="mt-2 text-xs text-destructive" role="status">
{signOutError}
</p>
) : null}
</div> </div>
</aside> </aside>
); );

View File

@@ -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<DashboardThemeContextValue | null>(null);
export function DashboardThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<DashboardTheme>(() => {
if (typeof window === "undefined") {
return "light";
}
const storedTheme = window.localStorage.getItem(storageKey);
if (storedTheme === "dark" || storedTheme === "light") {
return storedTheme;
}
return "light";
});
const value = useMemo<DashboardThemeContextValue>(
() => ({
theme,
toggleTheme: () => {
setTheme((currentTheme) => {
const nextTheme = currentTheme === "dark" ? "light" : "dark";
window.localStorage.setItem(storageKey, nextTheme);
return nextTheme;
});
},
}),
[theme],
);
return (
<DashboardThemeContext.Provider value={value}>
<div
suppressHydrationWarning
className={cn(
"min-h-dvh bg-background text-foreground md:flex",
theme === "dark" && "dark",
)}
>
{children}
</div>
</DashboardThemeContext.Provider>
);
}
export function DashboardThemeToggle() {
const context = useContext(DashboardThemeContext);
if (!context) {
return null;
}
const isDark = context.theme === "dark";
const Icon = isDark ? Sun : Moon;
return (
<Button
className="w-full justify-start"
variant="ghost"
onClick={context.toggleTheme}
aria-pressed={isDark}
>
<Icon />
{isDark ? "Hellmodus" : "Dunkelmodus"}
</Button>
);
}

View File

@@ -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<typeof api.leads.listFunnel>;
const stageActionHref: Record<LeadFunnelStageId, string> = {
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 <LeadFunnelSkeleton />;
}
const groups = groupLeadFunnelCards(leads);
const totalCards = groups.reduce((total, group) => total + group.cards.length, 0);
if (totalCards === 0) {
return (
<section
className="rounded-lg border bg-card p-6 text-card-foreground"
aria-labelledby="lead-funnel-heading"
>
<p className="text-sm font-medium text-muted-foreground">
Lead-Funnel
</p>
<h2
className="mt-2 text-xl font-semibold tracking-normal"
id="lead-funnel-heading"
>
Noch keine Leads im Arbeitsfluss
</h2>
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground">
Sobald Kampagnen Leads erzeugen oder importieren, erscheinen sie hier
nach Kontaktlage, Audit-Stand und Review-Bedarf sortiert.
</p>
</section>
);
}
return (
<section className="grid gap-3" aria-labelledby="lead-funnel-heading">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2
className="text-xl font-semibold tracking-normal"
id="lead-funnel-heading"
>
Lead-Funnel
</h2>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
{totalCards} Leads nach Kontaktlage, Audit-Stand und nächster
manueller Aktion.
</p>
</div>
<p className="text-sm font-medium text-muted-foreground">
Kein automatischer Versand
</p>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
{groups.map((group) => (
<section
className="flex min-h-[24rem] flex-col rounded-lg border bg-card text-card-foreground"
key={group.stage.id}
aria-labelledby={`${group.stage.id}-heading`}
>
<div className="border-b p-3">
<div className="flex items-center justify-between gap-3">
<h3
className="text-sm font-semibold"
id={`${group.stage.id}-heading`}
>
{group.stage.title}
</h3>
<span className="rounded-md bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
{group.cards.length}
</span>
</div>
<p className="mt-2 text-xs leading-5 text-muted-foreground">
{group.stage.description}
</p>
</div>
<div className="grid gap-2 p-2">
{group.cards.length > 0 ? (
group.cards.map((card) => (
<LeadFunnelCardView card={card} key={card.id} />
))
) : (
<p className="rounded-md border border-dashed p-3 text-xs leading-5 text-muted-foreground">
Keine Leads in dieser Spalte.
</p>
)}
</div>
</section>
))}
</div>
</section>
);
}
function LeadFunnelCardView({ card }: { card: LeadFunnelCard }) {
return (
<article
className="rounded-lg border bg-background p-3"
aria-labelledby={`${card.id}-company`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h4
className="truncate text-sm font-semibold"
id={`${card.id}-company`}
>
{card.company}
</h4>
<p className="mt-1 inline-flex max-w-full items-center gap-1 truncate text-xs text-muted-foreground">
<Building2 className="size-3 shrink-0" />
<span className="truncate">{card.niche}</span>
</p>
</div>
<span
className={cn(
"shrink-0 rounded-md px-2 py-1 text-xs font-medium",
card.priorityLabel === "Hoch"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground",
)}
>
{card.priorityLabel}
</span>
</div>
<p className="mt-3 inline-flex max-w-full items-center gap-1 truncate text-xs text-muted-foreground">
<MapPin className="size-3 shrink-0" />
<span className="truncate">{card.location}</span>
</p>
<div className="mt-3 flex flex-wrap gap-1.5">
<span className="rounded-md bg-secondary px-2 py-1 text-xs text-secondary-foreground">
{card.contactStatusLabel}
</span>
<span className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
{card.contactDetail}
</span>
</div>
<Link
className="mt-3 inline-flex min-h-8 items-center gap-1 rounded-md text-sm font-medium text-primary outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50"
href={stageActionHref[card.stageId]}
>
{card.nextAction}
<ArrowRight className="size-4" />
</Link>
</article>
);
}
function LeadFunnelSkeleton() {
return (
<section className="grid gap-3" aria-label="Lead-Funnel wird geladen">
<div>
<div className="h-6 w-40 rounded-md bg-muted" />
<div className="mt-2 h-4 w-80 max-w-full rounded-md bg-muted" />
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
{Array.from({ length: 6 }, (_, index) => (
<div
className="min-h-[24rem] rounded-lg border bg-card p-3"
key={index}
>
<div className="h-5 w-28 rounded-md bg-muted" />
<div className="mt-4 grid gap-2">
<div className="h-28 rounded-lg bg-muted" />
<div className="h-24 rounded-lg bg-muted" />
</div>
</div>
))}
</div>
</section>
);
}

View File

@@ -105,3 +105,48 @@ export const list = query({
return await ctx.db.query("leads").order("desc").take(limit); 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,
};
}),
);
},
});

View File

@@ -29,6 +29,257 @@ export type ReviewQueueItem = {
detail: string; 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<LeadPriority, string> = {
high: "Hoch",
medium: "Mittel",
low: "Niedrig",
defer: "Zurückstellen",
};
const contactStatusLabels: Record<LeadContactStatus, string> = {
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[] = [ export const pipelineStages: PipelineStage[] = [
{ {
title: "Kampagnen", title: "Kampagnen",
@@ -46,9 +297,9 @@ export const pipelineStages: PipelineStage[] = [
}, },
{ {
title: "Audit-Freigabe", title: "Audit-Freigabe",
description: "Interne Audits warten auf manuelle Pruefung.", description: "Interne Audits warten auf manuelle Prüfung.",
count: 6, count: 6,
meta: "2 Seiten bereit zur Veroeffentlichung", meta: "2 Seiten bereit zur Veröffentlichung",
icon: ShieldCheck, icon: ShieldCheck,
}, },
{ {
@@ -67,7 +318,7 @@ export const dashboardKpis: DashboardKpi[] = [
detail: "aus 3 aktiven Kampagnen", detail: "aus 3 aktiven Kampagnen",
}, },
{ {
label: "Audit-Entwuerfe", label: "Audit-Entwürfe",
value: "6", value: "6",
detail: "manuelle Freigabe offen", detail: "manuelle Freigabe offen",
}, },

View File

@@ -17,12 +17,12 @@ export type DashboardNavigationItem = {
}; };
export const dashboardNavigation: DashboardNavigationItem[] = [ export const dashboardNavigation: DashboardNavigationItem[] = [
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard }, { label: "Übersicht", href: "/dashboard", icon: LayoutDashboard },
{ label: "Campaigns", href: "/dashboard/campaigns", icon: MapPinned }, { label: "Kampagnen", href: "/dashboard/campaigns", icon: MapPinned },
{ label: "Leads", href: "/dashboard/leads", icon: UsersRound }, { label: "Leads", href: "/dashboard/leads", icon: UsersRound },
{ label: "Audits", href: "/dashboard/audits", icon: FileSearch }, { 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: "Analytics", href: "/dashboard/analytics", icon: BarChart3 },
{ label: "Blacklist", href: "/dashboard/blacklist", icon: OctagonMinus }, { label: "Sperrliste", href: "/dashboard/blacklist", icon: OctagonMinus },
{ label: "Settings", href: "/dashboard/settings", icon: Settings }, { label: "Einstellungen", href: "/dashboard/settings", icon: Settings },
]; ];

View File

@@ -4,7 +4,10 @@ import test from "node:test";
import { import {
dashboardKpis, dashboardKpis,
dashboardNavigation, dashboardNavigation,
groupLeadFunnelCards,
leadFunnelStages,
pipelineStages, pipelineStages,
toLeadFunnelCard,
reviewQueue, reviewQueue,
} from "../lib/dashboard-model"; } from "../lib/dashboard-model";
@@ -16,14 +19,14 @@ test("dashboardNavigation contains the expected sidebar routes in order", () =>
assert.deepEqual( assert.deepEqual(
dashboardNavigation.map((item: NavigationItem) => [item.label, item.href]), dashboardNavigation.map((item: NavigationItem) => [item.label, item.href]),
[ [
["Dashboard", "/dashboard"], ["Übersicht", "/dashboard"],
["Campaigns", "/dashboard/campaigns"], ["Kampagnen", "/dashboard/campaigns"],
["Leads", "/dashboard/leads"], ["Leads", "/dashboard/leads"],
["Audits", "/dashboard/audits"], ["Audits", "/dashboard/audits"],
["Outreach", "/dashboard/outreach"], ["Review", "/dashboard/outreach"],
["Analytics", "/dashboard/analytics"], ["Analytics", "/dashboard/analytics"],
["Blacklist", "/dashboard/blacklist"], ["Sperrliste", "/dashboard/blacklist"],
["Settings", "/dashboard/settings"], ["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", () => { test("dashboardKpis and reviewQueue expose the above-the-fold dashboard summary", () => {
assert.equal(dashboardKpis.length, 4); assert.equal(dashboardKpis.length, 4);
assert.equal(reviewQueue.length, 3); assert.equal(reviewQueue.length, 3);