"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery } from "convex/react"; import { FunctionReturnType } from "convex/server"; import { CalendarClock, Clock3, MapPin, Pencil, Play, Plus, ShieldCheck, } from "lucide-react"; import { api } from "@/convex/_generated/api"; import { Id } from "@/convex/_generated/dataModel"; import { campaignFormDefaults } from "@/lib/campaign-form"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Card, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; import { CampaignFormDialog } from "@/components/campaigns/campaign-form-dialog"; import { cn } from "@/lib/utils"; type CampaignsListResult = FunctionReturnType; type CampaignRunsListResult = FunctionReturnType; type CampaignRow = NonNullable[number]; type CampaignRunRow = NonNullable[number]; type RecurrenceLabel = Record; type CurrentRunStatusLabel = { [key: string]: string; }; const recurrenceLabel: RecurrenceLabel = { manual: "manuell", daily: "täglich", weekly: "wöchentlich", monthly: "monatlich", }; const statusLabel: CurrentRunStatusLabel = { running: "Läuft", pending: "Ausstehend", succeeded: "Erledigt", failed: "Fehlgeschlagen", canceled: "Abgebrochen", idle: "Leerlauf", paused: "Pausiert", }; const stepLabel: Record = { campaign_cron_queued: "Cron geplant", campaign_cron_skipped: "Cron übersprungen", campaign_cron_stale_pending: "Timeout bereinigt", lead_discovery: "Lead-Recherche", }; const dateFormatter = new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short", }); function formatDateTime(value?: number | null): string { if (!value) { return "Nicht gesetzt"; } return dateFormatter.format(new Date(value)); } const formPayloadFromCampaign = (campaign?: CampaignRow | null) => { if (!campaign) { return campaignFormDefaults; } return { status: campaign.status, categoryMode: campaign.categoryMode, recurrence: campaign.recurrence, radiusKm: campaign.radiusKm, maxNewLeadsPerRun: campaign.maxNewLeadsPerRun, maxAuditsPerRun: campaign.maxAuditsPerRun, name: campaign.name, category: campaign.category, customSearchTerm: campaign.customSearchTerm ?? "", postalCode: campaign.postalCode, }; }; const formatNiche = (campaign: CampaignRow): string => { if (campaign.category !== "Anderes") { return campaign.category; } return campaign.customSearchTerm?.trim() ? `${campaign.category}: ${campaign.customSearchTerm}` : campaign.category; }; const formatRunStatus = (value: string): string => { return statusLabel[value] ?? value; }; const getRunStatusClassName = (value: string): string => { if (value === "running" || value === "pending") { return "evidence-surface border-transparent"; } if (value === "succeeded") { return "safe-surface border-transparent"; } if (value === "failed" || value === "canceled") { return "border-transparent bg-[var(--danger-soft)] text-destructive"; } return "border-border/75 bg-muted/55 text-muted-foreground"; }; const getRunStepClassName = (value?: string | null): string => { if (value === "campaign_cron_skipped" || value === "campaign_cron_stale_pending") { return "review-surface border-transparent"; } if (value === "lead_discovery" || value === "campaign_cron_queued") { return "evidence-surface border-transparent"; } return "border-border/75 bg-background/60"; }; export function CampaignsBoard() { const campaigns = useQuery(api.campaigns.list, { limit: 100 }); const recentCampaignRuns = useQuery(api.runs.list, { limit: 8, type: "campaign", }); const createCampaign = useMutation(api.campaigns.create); const updateCampaign = useMutation(api.campaigns.update); const setStatus = useMutation(api.campaigns.setStatus); const requestRun = useMutation(api.campaigns.requestRun); const [editingCampaign, setEditingCampaign] = useState(null); const [isFormOpen, setIsFormOpen] = useState(false); const [actionBusyId, setActionBusyId] = useState | null>(null); const [actionLabel, setActionLabel] = useState(null); const [formError, setFormError] = useState(null); const [rowError, setRowError] = useState(null); const actionLabelTimerRef = useRef | null>(null); const clearActionLabelTimer = () => { if (actionLabelTimerRef.current) { clearTimeout(actionLabelTimerRef.current); actionLabelTimerRef.current = null; } }; const setActionLabelWithTimeout = ( label: string, clearAfterMs = 1200, ) => { clearActionLabelTimer(); setActionLabel(label); if (clearAfterMs > 0) { actionLabelTimerRef.current = setTimeout(() => setActionLabel(null), clearAfterMs); } }; useEffect(() => { return () => { clearActionLabelTimer(); }; }, []); const campaignsSorted = useMemo(() => { if (!campaigns) { return []; } return [...campaigns].sort((a, b) => b.createdAt - a.createdAt); }, [campaigns]); const visibleRuns = useMemo(() => { return recentCampaignRuns ?? []; }, [recentCampaignRuns]); const activeCampaignCount = campaignsSorted.filter( (campaign) => campaign.status === "active", ).length; const pausedCampaignCount = campaignsSorted.length - activeCampaignCount; const nextScheduledCampaign = [...campaignsSorted] .filter((campaign) => campaign.nextRunAt) .sort((a, b) => (a.nextRunAt ?? Infinity) - (b.nextRunAt ?? Infinity)) .at(0); const closeDialog = () => { setEditingCampaign(null); setIsFormOpen(false); setFormError(null); }; const openCreateDialog = () => { setEditingCampaign(null); setRowError(null); setIsFormOpen(true); }; const openEditDialog = (campaign: CampaignRow) => { setEditingCampaign(campaign); setRowError(null); setIsFormOpen(true); }; const submitCampaign = async (payload: { status: CampaignRow["status"]; categoryMode: CampaignRow["categoryMode"]; category: string; customSearchTerm?: string; postalCode: string; radiusKm: number; maxNewLeadsPerRun: number; maxAuditsPerRun: number; recurrence: CampaignRow["recurrence"]; countryCode: "DE"; country: "Deutschland"; name: string; }) => { setActionLabel("Speichere..."); setFormError(null); try { if (!editingCampaign) { await createCampaign(payload); } else { await updateCampaign({ id: editingCampaign._id, ...payload, }); } setActionLabelWithTimeout("Gespeichert"); setIsFormOpen(false); setEditingCampaign(null); } catch { setFormError("Speichern fehlgeschlagen."); setActionLabelWithTimeout("Fehler", 2000); } }; const runCampaign = async (campaign: CampaignRow) => { setActionBusyId(campaign._id); setRowError(null); try { await requestRun({ id: campaign._id }); setActionLabelWithTimeout(`${campaign.name}: Lauf gestartet`); } catch { setRowError("Kampagne konnte nicht gestartet werden."); setActionLabelWithTimeout("Kampagne konnte nicht gestartet werden.", 2400); } finally { setActionBusyId(null); } }; const toggleCampaign = async (campaign: CampaignRow) => { const nextStatus = campaign.status === "active" ? "paused" : "active"; setActionBusyId(campaign._id); setRowError(null); try { await setStatus({ id: campaign._id, status: nextStatus }); setActionLabelWithTimeout( `${campaign.name}: ${nextStatus === "active" ? "Aktiviert" : "Pausiert"}`, ); } catch { setRowError("Status konnte nicht geändert werden."); setActionLabelWithTimeout("Status konnte nicht geändert werden.", 2400); } finally { setActionBusyId(null); } }; if (campaigns === undefined) { return (
{Array.from({ length: 4 }, (_, index) => ( ))}
); } return (

Controlled Sourcing

Kontrollierte Suchläufe

Kampagnen bleiben bewusst limitiert: Suchgebiet, Cadence, Run-Status und manuelle Freigaben stehen vor jeder Outreach-Bewegung.

{formError ?

{formError}

: null} {rowError ?

{rowError}

: null} {actionLabel ?

{actionLabel}

: null}
{campaignsSorted.length === 0 ? ( Keine Kampagnen Lege zuerst eine Kampagne mit Kategorie, PLZ und Limits an. ) : ( <>
Kampagne
Suchgebiet
Status
Cadence
Limits
Läufe
Run
Aktionen
{campaignsSorted.map((campaign) => { const campaignTitleId = `campaign-title-${campaign._id}`; const isBusy = actionBusyId === campaign._id; const isActive = campaign.status === "active"; return (

{campaign.name}

{formatNiche(campaign)}

{campaign.postalCode} Radius {campaign.radiusKm} km
{recurrenceLabel[campaign.recurrence]}
Lead-Limit: {campaign.maxNewLeadsPerRun} Audit-Limit: {campaign.maxAuditsPerRun}
Letzter Lauf: {formatDateTime(campaign.lastRunAt)} Nächster Lauf: {formatDateTime(campaign.nextRunAt)}
{formatRunStatus(campaign.currentRunStatus)}
); })}
{campaignsSorted.map((campaign) => { const campaignTitleId = `campaign-title-mobile-${campaign._id}`; const isBusy = actionBusyId === campaign._id; const isActive = campaign.status === "active"; return (

{campaign.name}

{formatNiche(campaign)} · {campaign.postalCode}

Radius {campaign.radiusKm} km · {recurrenceLabel[campaign.recurrence]}

Lead-Limit: {campaign.maxNewLeadsPerRun} · Audit-Limit:{" "} {campaign.maxAuditsPerRun}

Letzter Lauf: {formatDateTime(campaign.lastRunAt)}

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

Run: {formatRunStatus(campaign.currentRunStatus)}
); })}
)}
); }