Files
pitchfast/components/campaigns/campaigns-board.tsx

420 lines
14 KiB
TypeScript

"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery } from "convex/react";
import { FunctionReturnType } from "convex/server";
import { MapPin, Pencil, Play, RefreshCcw, Plus } 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, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import { CampaignFormDialog } from "@/components/campaigns/campaign-form-dialog";
type CampaignsListResult = FunctionReturnType<typeof api.campaigns.list>;
type CampaignRunsListResult = FunctionReturnType<typeof api.runs.list>;
type CampaignRow = NonNullable<CampaignsListResult>[number];
type CampaignRunRow = NonNullable<CampaignRunsListResult>[number];
type RecurrenceLabel = Record<CampaignRow["recurrence"], string>;
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<string, string> = {
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;
};
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<CampaignRow | null>(null);
const [isFormOpen, setIsFormOpen] = useState(false);
const [actionBusyId, setActionBusyId] = useState<Id<"campaigns"> | null>(null);
const [actionLabel, setActionLabel] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [rowError, setRowError] = useState<string | null>(null);
const actionLabelTimerRef = useRef<ReturnType<typeof setTimeout> | 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<CampaignRunRow[]>(() => {
return recentCampaignRuns ?? [];
}, [recentCampaignRuns]);
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 (
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="h-7 w-48 rounded-md bg-muted" />
<div className="h-8 w-24 rounded-md bg-muted" />
</div>
<div className="grid gap-3">
{Array.from({ length: 4 }, (_, index) => (
<Skeleton className="h-28 rounded-lg" key={index} />
))}
</div>
</section>
);
}
return (
<section className="space-y-4">
<CampaignFormDialog
campaign={editingCampaign ? formPayloadFromCampaign(editingCampaign) : null}
open={isFormOpen}
onOpenChange={closeDialog}
onSubmit={submitCampaign}
/>
<div className="flex flex-col gap-3 border-b pb-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-sm text-muted-foreground">Lokale Kampagnenverwaltung</p>
<h1 className="mt-2 text-3xl font-semibold tracking-normal">
Kampagnen
</h1>
</div>
<Button onClick={openCreateDialog} className="justify-start sm:w-auto">
<Plus className="size-4" />
Kampagne anlegen
</Button>
</div>
{formError ? <p className="text-sm text-destructive" role="alert">{formError}</p> : null}
{rowError ? <p className="text-sm text-destructive" role="alert">{rowError}</p> : null}
{actionLabel ? <p className="text-sm" role="status">{actionLabel}</p> : null}
{campaignsSorted.length === 0 ? (
<Card>
<CardHeader>
<CardTitle>Keine Kampagnen</CardTitle>
<CardDescription>
Lege zuerst eine Kampagne mit Kategorie, PLZ und Limits an.
</CardDescription>
</CardHeader>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{campaignsSorted.map((campaign) => {
const campaignTitleId = `campaign-title-${campaign._id}`;
return (
<Card aria-labelledby={campaignTitleId} key={campaign._id}>
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<CardTitle className="truncate" id={campaignTitleId}>
{campaign.name}
</CardTitle>
<CardDescription className="truncate">
{formatNiche(campaign)}
</CardDescription>
</div>
<Badge
variant={campaign.status === "active" ? "default" : "secondary"}
>
{campaign.status === "active" ? "Aktiv" : "Pausiert"}
</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-2 text-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="inline-flex items-center gap-1 text-muted-foreground">
<MapPin className="size-3" />
<span>{campaign.postalCode}</span>
</div>
<span>{campaign.radiusKm} km</span>
</div>
<Separator className="bg-border" />
<div>
<p>Cadence: {recurrenceLabel[campaign.recurrence]}</p>
<p>
Limits: L {campaign.maxNewLeadsPerRun}, A{" "}
{campaign.maxAuditsPerRun}
</p>
</div>
<div>
<p className="text-muted-foreground">
Letzter Lauf: {formatDateTime(campaign.lastRunAt)}
</p>
<p className="text-muted-foreground">
Nächster Lauf: {formatDateTime(campaign.nextRunAt)}
</p>
<p className="text-muted-foreground">
Run-Status:{" "}
{statusLabel[campaign.currentRunStatus] ??
campaign.currentRunStatus}
</p>
</div>
<div className="grid gap-2">
<Button
variant="outline"
onClick={() => openEditDialog(campaign)}
disabled={actionBusyId === campaign._id}
className="w-full justify-start"
>
<Pencil className="size-4" />
Bearbeiten
</Button>
<Button
variant="outline"
onClick={() => toggleCampaign(campaign)}
disabled={actionBusyId === campaign._id}
className="w-full justify-start"
>
<RefreshCcw className="size-4" />
{campaign.status === "active" ? "Pausieren" : "Fortfahren"}
</Button>
<Button
onClick={() => runCampaign(campaign)}
disabled={actionBusyId === campaign._id}
className="w-full justify-start"
>
<Play className="size-4" />
Jetzt ausführen
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
<Card>
<CardHeader>
<CardTitle>Aktuelle Run-Logs</CardTitle>
<CardDescription>
Letzte Kampagnenläufe inklusive Cron-Skips und Fehlerhinweisen.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-2 text-sm">
{recentCampaignRuns === undefined ? (
<Skeleton className="h-16 rounded-lg" />
) : visibleRuns.length === 0 ? (
<p className="text-muted-foreground">Noch keine Kampagnenläufe.</p>
) : (
visibleRuns.map((run) => (
<div className="rounded-md border p-3" key={run._id}>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-medium">
{statusLabel[run.status] ?? run.status}
</p>
<p className="text-xs text-muted-foreground">
{formatDateTime(run.updatedAt)}
</p>
</div>
<p className="mt-1 text-muted-foreground">
{stepLabel[run.currentStep ?? ""] ?? run.currentStep ?? "Schritt offen"}
</p>
{run.currentStep === "campaign_cron_skipped" ? (
<p className="mt-1 text-xs text-muted-foreground">
Cron wurde übersprungen, weil bereits ein Agentenlauf aktiv war.
</p>
) : null}
{run.errorSummary ? (
<p className="mt-1 text-xs text-destructive">
{run.errorSummary}
</p>
) : null}
</div>
))
)}
</CardContent>
</Card>
</section>
);
}