feat: build dashboard lead funnel
This commit is contained in:
@@ -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"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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);
|
||||||
|
setSignOutError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
await authClient.signOut();
|
await authClient.signOut();
|
||||||
router.replace("/login");
|
router.replace("/login");
|
||||||
router.refresh();
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
92
components/dashboard-theme.tsx
Normal file
92
components/dashboard-theme.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
204
components/lead-funnel-board.tsx
Normal file
204
components/lead-funnel-board.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user