656 lines
24 KiB
TypeScript
656 lines
24 KiB
TypeScript
"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<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;
|
|
};
|
|
|
|
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<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 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 (
|
|
<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="agency-panel flex flex-col gap-4 p-4 sm:flex-row sm:items-end sm:justify-between">
|
|
<div>
|
|
<p className="agency-kicker">Controlled Sourcing</p>
|
|
<h1 className="mt-2 font-heading text-3xl font-semibold tracking-normal">
|
|
Kontrollierte Suchläufe
|
|
</h1>
|
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
|
Kampagnen bleiben bewusst limitiert: Suchgebiet, Cadence, Run-Status
|
|
und manuelle Freigaben stehen vor jeder Outreach-Bewegung.
|
|
</p>
|
|
</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}
|
|
|
|
<div className="grid gap-4 2xl:grid-cols-[minmax(0,1fr)_20rem]">
|
|
<div className="min-w-0">
|
|
{campaignsSorted.length === 0 ? (
|
|
<Card className="agency-panel">
|
|
<CardHeader>
|
|
<CardTitle>Keine Kampagnen</CardTitle>
|
|
<CardDescription>
|
|
Lege zuerst eine Kampagne mit Kategorie, PLZ und Limits an.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
</Card>
|
|
) : (
|
|
<>
|
|
<div className="campaign-ledger hidden 2xl:block">
|
|
<div role="table" aria-label="Kampagnen-Ledger">
|
|
<div role="rowgroup">
|
|
<div
|
|
className="campaign-ledger-row campaign-ledger-header"
|
|
role="row"
|
|
>
|
|
<div role="columnheader">Kampagne</div>
|
|
<div role="columnheader">Suchgebiet</div>
|
|
<div role="columnheader">Status</div>
|
|
<div role="columnheader">Cadence</div>
|
|
<div role="columnheader">Limits</div>
|
|
<div role="columnheader">Läufe</div>
|
|
<div role="columnheader">Run</div>
|
|
<div role="columnheader">Aktionen</div>
|
|
</div>
|
|
</div>
|
|
<div role="rowgroup">
|
|
{campaignsSorted.map((campaign) => {
|
|
const campaignTitleId = `campaign-title-${campaign._id}`;
|
|
const isBusy = actionBusyId === campaign._id;
|
|
const isActive = campaign.status === "active";
|
|
|
|
return (
|
|
<div
|
|
aria-labelledby={campaignTitleId}
|
|
className="campaign-ledger-row"
|
|
key={campaign._id}
|
|
role="row"
|
|
>
|
|
<div className="min-w-0" role="cell">
|
|
<h2
|
|
className="truncate font-heading text-sm font-semibold"
|
|
id={campaignTitleId}
|
|
>
|
|
{campaign.name}
|
|
</h2>
|
|
<p className="mt-1 truncate text-xs text-muted-foreground">
|
|
{formatNiche(campaign)}
|
|
</p>
|
|
</div>
|
|
<div className="grid gap-1 text-sm" role="cell">
|
|
<span className="inline-flex items-center gap-1 font-medium">
|
|
<MapPin className="size-3.5 text-primary" />
|
|
{campaign.postalCode}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
Radius {campaign.radiusKm} km
|
|
</span>
|
|
</div>
|
|
<div className="grid gap-1" role="cell">
|
|
<label className="inline-flex items-center gap-2 text-sm font-medium">
|
|
<Switch
|
|
checked={isActive}
|
|
onCheckedChange={() => toggleCampaign(campaign)}
|
|
disabled={isBusy}
|
|
aria-label={`${campaign.name} Status ändern`}
|
|
/>
|
|
{isActive ? "Aktiv" : "Pausiert"}
|
|
</label>
|
|
</div>
|
|
<div className="grid gap-1 text-sm" role="cell">
|
|
<span className="inline-flex items-center gap-1 font-medium">
|
|
<Clock3 className="size-3.5 text-primary" />
|
|
{recurrenceLabel[campaign.recurrence]}
|
|
</span>
|
|
</div>
|
|
<div className="grid gap-1 text-sm" role="cell">
|
|
<span>Lead-Limit: {campaign.maxNewLeadsPerRun}</span>
|
|
<span>Audit-Limit: {campaign.maxAuditsPerRun}</span>
|
|
</div>
|
|
<div className="grid gap-1 text-xs text-muted-foreground" role="cell">
|
|
<span>Letzter Lauf: {formatDateTime(campaign.lastRunAt)}</span>
|
|
<span>Nächster Lauf: {formatDateTime(campaign.nextRunAt)}</span>
|
|
</div>
|
|
<div className="grid gap-1" role="cell">
|
|
<Badge
|
|
className={getRunStatusClassName(
|
|
campaign.currentRunStatus,
|
|
)}
|
|
variant="outline"
|
|
>
|
|
{formatRunStatus(campaign.currentRunStatus)}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex flex-wrap items-center justify-end gap-1" role="cell">
|
|
<Button
|
|
variant="outline"
|
|
size="icon-sm"
|
|
onClick={() => openEditDialog(campaign)}
|
|
disabled={isBusy}
|
|
aria-label={`${campaign.name} bearbeiten`}
|
|
title={`${campaign.name} bearbeiten`}
|
|
>
|
|
<Pencil className="size-3.5" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => runCampaign(campaign)}
|
|
disabled={isBusy}
|
|
size="icon-sm"
|
|
aria-label={`${campaign.name} Suchlauf starten`}
|
|
title={`${campaign.name} Suchlauf starten`}
|
|
>
|
|
<Play className="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="campaign-mobile-records grid gap-3 2xl:hidden">
|
|
{campaignsSorted.map((campaign) => {
|
|
const campaignTitleId = `campaign-title-mobile-${campaign._id}`;
|
|
const isBusy = actionBusyId === campaign._id;
|
|
const isActive = campaign.status === "active";
|
|
|
|
return (
|
|
<article
|
|
aria-labelledby={campaignTitleId}
|
|
className="campaign-mobile-record"
|
|
key={campaign._id}
|
|
>
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<h2
|
|
className="truncate font-heading text-base font-semibold"
|
|
id={campaignTitleId}
|
|
>
|
|
{campaign.name}
|
|
</h2>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
{formatNiche(campaign)} · {campaign.postalCode}
|
|
</p>
|
|
</div>
|
|
<label className="inline-flex items-center gap-2 text-sm font-medium">
|
|
<Switch
|
|
checked={isActive}
|
|
onCheckedChange={() => toggleCampaign(campaign)}
|
|
disabled={isBusy}
|
|
aria-label={`${campaign.name} Status ändern`}
|
|
/>
|
|
{isActive ? "Aktiv" : "Pausiert"}
|
|
</label>
|
|
</div>
|
|
|
|
<div className="mt-3 grid gap-2 text-sm sm:grid-cols-2">
|
|
<p className="rounded-md bg-muted/50 px-3 py-2">
|
|
Radius {campaign.radiusKm} km · {recurrenceLabel[campaign.recurrence]}
|
|
</p>
|
|
<p className="rounded-md bg-muted/50 px-3 py-2">
|
|
Lead-Limit: {campaign.maxNewLeadsPerRun} · Audit-Limit:{" "}
|
|
{campaign.maxAuditsPerRun}
|
|
</p>
|
|
<p className="rounded-md bg-muted/50 px-3 py-2">
|
|
Letzter Lauf: {formatDateTime(campaign.lastRunAt)}
|
|
</p>
|
|
<p className="rounded-md bg-muted/50 px-3 py-2">
|
|
Nächster Lauf: {formatDateTime(campaign.nextRunAt)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
|
|
<Badge
|
|
className={getRunStatusClassName(
|
|
campaign.currentRunStatus,
|
|
)}
|
|
variant="outline"
|
|
>
|
|
Run: {formatRunStatus(campaign.currentRunStatus)}
|
|
</Badge>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => openEditDialog(campaign)}
|
|
disabled={isBusy}
|
|
>
|
|
<Pencil className="size-3.5" />
|
|
Bearbeiten
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => runCampaign(campaign)}
|
|
disabled={isBusy}
|
|
size="sm"
|
|
>
|
|
<Play className="size-3.5" />
|
|
Suchlauf starten
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<aside className="campaign-status-rail agency-panel grid gap-3 p-4">
|
|
<div>
|
|
<p className="agency-kicker">Sourcing Status</p>
|
|
<h2 className="mt-2 font-heading text-lg font-semibold">
|
|
Kampagnenkontrolle
|
|
</h2>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="safe-surface rounded-md p-3">
|
|
<p className="text-2xl font-semibold">{activeCampaignCount}</p>
|
|
<p className="text-xs font-medium">aktiv</p>
|
|
</div>
|
|
<div className="review-surface rounded-md p-3">
|
|
<p className="text-2xl font-semibold">{pausedCampaignCount}</p>
|
|
<p className="text-xs font-medium">pausiert</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-md border border-border/75 bg-background/60 p-3 text-sm">
|
|
<p className="inline-flex items-center gap-2 font-semibold">
|
|
<CalendarClock className="size-4 text-primary" />
|
|
Nächster geplanter Lauf
|
|
</p>
|
|
<p className="mt-2 text-muted-foreground">
|
|
{nextScheduledCampaign
|
|
? `${nextScheduledCampaign.name}: ${formatDateTime(nextScheduledCampaign.nextRunAt)}`
|
|
: "Kein Lauf geplant"}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="safe-surface rounded-md p-3 text-sm">
|
|
<p className="inline-flex items-center gap-2 font-semibold">
|
|
<ShieldCheck className="size-4" />
|
|
Versand nur nach Freigabe
|
|
</p>
|
|
<p className="mt-1 text-xs">
|
|
Kampagnen erzeugen Recherche und Audits. Outreach bleibt manuell
|
|
geprüft.
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<h2 className="font-heading text-base font-semibold">
|
|
Aktuelle Run-Logs
|
|
</h2>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
Cron-Skips, Fehler und letzte Kampagnenläufe.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-3 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={cn(
|
|
"rounded-md border p-3",
|
|
getRunStatusClassName(run.status),
|
|
getRunStepClassName(run.currentStep),
|
|
)}
|
|
key={run._id}
|
|
>
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<p className="font-medium">
|
|
{formatRunStatus(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>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|