Initial commit of Descrybe v2 without local scratch artifacts.

Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+521
View File
@@ -0,0 +1,521 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { api } from "$lib/api";
import { requireSupportDesk } from "$lib/admin-gate";
import { i18n } from "$lib/i18n";
import { formatCredits } from "$lib/utils";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import {
Badge,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
type BadgeVariant
} from "$lib/components/ui";
import {
Activity,
ArrowRight,
BarChart3,
BookOpen,
ClipboardList,
LifeBuoy,
Users
} from "@lucide/svelte";
type ProviderBucket = { tokens: number; jobs: number; products: number };
type ProviderBreakdown = {
internal: ProviderBucket;
popular: ProviderBucket;
custom: ProviderBucket;
};
/** Matches GET /api/admin/analytics?summary=1 `summary` payload (live tables only). */
type Summary = {
users: number;
companies: number;
users_period?: number;
companies_period?: number;
credits_allocated: number;
credits_used: number;
credits_remaining: number;
tokens_total: number;
tokens_period: number;
jobs_total: number;
jobs_by_status: Record<string, number>;
jobs_stuck?: number;
jobs_failed_period?: number;
jobs_completed_period?: number;
products_processed: number;
products_raw?: number;
feeds_input?: number;
feeds_export?: number;
api_keys_active?: number;
api_keys_total?: number;
feed_sync_by_status?: Record<string, number>;
tickets_by_status?: Record<string, number>;
};
type SignalTone = "ok" | "info" | "warn" | "critical";
type Signal = {
id: string;
tone: SignalTone;
title: string;
detail: string;
href: string;
cta: string;
};
/** Curated ops shortcuts — not a flat dump of every admin tool. */
const shortcuts = $derived.by(() => [
{
href: "/admin/diagnostics",
title: i18n.t("admin.overview.shortcut.diagnostics.title"),
description: i18n.t("admin.overview.shortcut.diagnostics.desc"),
icon: Activity
},
{
href: "/admin/analytics",
title: i18n.t("admin.overview.shortcut.analytics.title"),
description: i18n.t("admin.overview.shortcut.analytics.desc"),
icon: BarChart3
},
{
href: "/admin/support/knowledge",
title: i18n.t("admin.overview.shortcut.knowledge.title"),
description: i18n.t("admin.overview.shortcut.knowledge.desc"),
icon: BookOpen
},
{
href: "/admin/users",
title: i18n.t("admin.overview.shortcut.users.title"),
description: i18n.t("admin.overview.shortcut.users.desc"),
icon: Users
},
{
href: "/admin/stuck-products",
title: i18n.t("admin.overview.shortcut.stuck.title"),
description: i18n.t("admin.overview.shortcut.stuck.desc"),
icon: ClipboardList
}
]);
let loading = $state(true);
let accessDenied = $state(false);
let error = $state("");
let days = $state(30);
let summary = $state<Summary | null>(null);
let providers = $state<ProviderBreakdown | null>(null);
const ticketsOpen = $derived.by(() => {
const by = summary?.tickets_by_status ?? {};
return Number(by.open ?? 0) + Number(by.pending ?? 0);
});
const failRate = $derived.by(() => {
if (!summary) return null;
const failed = Number(summary.jobs_failed_period ?? 0);
const completed = Number(summary.jobs_completed_period ?? 0);
const denom = failed + completed;
if (denom <= 0) return null;
return Math.round((failed / denom) * 1000) / 10;
});
/** {i18n.t("admin.overview.signals")} from summary only — no second analytics/diagnostics fetch. */
const signals = $derived.by((): Signal[] => {
if (!summary) return [];
const out: Signal[] = [];
const stuck = Number(summary.jobs_stuck ?? 0);
const failed = Number(summary.jobs_failed_period ?? 0);
const running = Number(summary.jobs_by_status?.running ?? 0);
const completed = Number(summary.jobs_completed_period ?? 0);
const openTickets = ticketsOpen;
if (stuck > 0) {
out.push({
id: "stuck",
tone: "critical",
title: i18n.t(
stuck === 1 ? "admin.overview.signal.stuckTitle" : "admin.overview.signal.stuckTitlePlural",
{ count: formatCredits(stuck) }
),
detail: i18n.t("admin.overview.signal.stuckDetail"),
href: "/admin/stuck-products",
cta: i18n.t("admin.overview.signal.stuckCta")
});
}
if (failed > 0) {
out.push({
id: "failed",
tone: "warn",
title: i18n.t("admin.overview.signal.failedTitle", {
count: formatCredits(failed),
days
}),
detail:
failRate === null
? i18n.t("admin.overview.signal.failedDetail", {
completed: formatCredits(completed)
})
: i18n.t("admin.overview.signal.failedDetailRate", {
rate: failRate,
completed: formatCredits(completed)
}),
href: "/admin/diagnostics",
cta: i18n.t("admin.overview.signal.failedCta")
});
}
if (running > 0) {
out.push({
id: "running",
tone: "info",
title: i18n.t(
running === 1
? "admin.overview.signal.runningTitle"
: "admin.overview.signal.runningTitlePlural",
{ count: formatCredits(running) }
),
detail: i18n.t("admin.overview.signal.runningDetail"),
href: "/admin/diagnostics",
cta: i18n.t("admin.overview.signal.runningCta")
});
}
if (openTickets > 0) {
out.push({
id: "tickets",
tone: "info",
title: i18n.t(
openTickets === 1
? "admin.overview.signal.ticketsTitle"
: "admin.overview.signal.ticketsTitlePlural",
{ count: formatCredits(openTickets) }
),
detail: i18n.t("admin.overview.signal.ticketsDetail"),
href: "/admin/support",
cta: i18n.t("admin.overview.signal.ticketsCta")
});
}
if (out.length === 0) {
out.push({
id: "clear",
tone: "ok",
title: i18n.t("admin.overview.signal.clearTitle"),
detail: i18n.t("admin.overview.signal.clearDetail", { days }),
href: "/admin/diagnostics",
cta: i18n.t("admin.overview.signal.clearCta")
});
}
return out;
});
function signalBadge(tone: SignalTone): { label: string; variant: BadgeVariant } {
switch (tone) {
case "critical":
return { label: i18n.t("admin.overview.badge.action"), variant: "destructive" };
case "warn":
return { label: i18n.t("admin.overview.badge.watch"), variant: "warning" };
case "info":
return { label: i18n.t("admin.overview.badge.live"), variant: "secondary" };
default:
return { label: i18n.t("admin.overview.badge.clear"), variant: "success" };
}
}
onMount(async () => {
const gate = await requireSupportDesk();
if (!gate.ok) {
if (gate.reason === "auth") {
await goto("/login");
return;
}
if (gate.reason === "forbidden") {
accessDenied = true;
} else {
error = gate.message;
}
loading = false;
return;
}
// support_staff: Overview is in nav but must land on the desk, not analytics.
if (gate.staff.is_support_only) {
await goto("/admin/support");
return;
}
try {
const res = await api<{
days?: number;
summary: Summary;
providers?: ProviderBreakdown;
}>("/api/admin/analytics?days=30&summary=1");
summary = res.summary ?? null;
providers = res.providers ?? null;
if (typeof res.days === "number" && res.days > 0) days = res.days;
} catch {
/* overview cards optional if analytics fails */
} finally {
loading = false;
}
});
</script>
<PageShell
eyebrow={i18n.t("admin.overview.eyebrow")}
title={i18n.t("admin.overview.title")}
description={i18n.t("admin.overview.description")}
>
{#if loading}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else if error}
<Alert message={error} />
{:else}
{#if summary}
<section class="space-y-3" aria-labelledby="admin-kpi-heading">
<div class="flex flex-wrap items-end justify-between gap-2">
<h2 id="admin-kpi-heading" class="text-sm font-semibold text-foreground">
{i18n.t("admin.overview.keyMetrics")}
</h2>
<p class="text-xs text-muted-foreground">{i18n.t("admin.overview.summaryWindow", { days })}</p>
</div>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Card>
<CardHeader class="pb-1">
<CardTitle class="text-sm font-medium">{i18n.t("admin.overview.tokens")}</CardTitle>
<CardDescription>{i18n.t("admin.overview.tokensHint")}</CardDescription>
</CardHeader>
<CardContent>
<div class="text-2xl font-semibold tracking-tight text-card-foreground">
{formatCredits(summary.tokens_total)}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.overview.tokensPeriod", {
count: formatCredits(summary.tokens_period),
days
})}
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-1">
<CardTitle class="text-sm font-medium">{i18n.t("admin.overview.credits")}</CardTitle>
<CardDescription>{i18n.t("admin.overview.creditsHint")}</CardDescription>
</CardHeader>
<CardContent>
<div class="text-2xl font-semibold tracking-tight text-card-foreground">
{formatCredits(summary.credits_used)}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.overview.creditsFoot", {
remaining: formatCredits(summary.credits_remaining),
allocated: formatCredits(summary.credits_allocated)
})}
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-1">
<CardTitle class="text-sm font-medium">{i18n.t("admin.overview.jobs")}</CardTitle>
<CardDescription>{i18n.t("admin.overview.jobsHint")}</CardDescription>
</CardHeader>
<CardContent>
<div class="text-2xl font-semibold tracking-tight text-card-foreground">
{formatCredits(summary.jobs_total)}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.overview.jobsFoot", {
failed: formatCredits(summary.jobs_by_status?.failed ?? 0),
running: formatCredits(summary.jobs_by_status?.running ?? 0),
stuck: formatCredits(summary.jobs_stuck ?? 0)
})}
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-1">
<CardTitle class="text-sm font-medium">{i18n.t("admin.overview.platform")}</CardTitle>
<CardDescription>{i18n.t("admin.overview.platformHint")}</CardDescription>
</CardHeader>
<CardContent>
<div class="text-2xl font-semibold tracking-tight text-card-foreground">
{formatCredits(summary.companies)}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.overview.platformFoot", {
users: formatCredits(summary.users),
processed: formatCredits(summary.products_processed),
feeds: formatCredits(summary.feeds_input ?? 0)
})}
</p>
</CardContent>
</Card>
</div>
</section>
{:else}
<Alert message={i18n.t("admin.overview.summaryLoadFailed")} />
{/if}
<section class="space-y-3" aria-labelledby="admin-signals-heading">
<div class="flex flex-wrap items-end justify-between gap-2">
<h2 id="admin-signals-heading" class="text-sm font-semibold text-foreground">
{i18n.t("admin.overview.signals")}
</h2>
{#if summary}
<a
href="/admin/diagnostics"
class="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{i18n.t("admin.overview.fullDiagnostics")}
<ArrowRight class="h-3 w-3" aria-hidden="true" />
</a>
{/if}
</div>
{#if signals.length > 0}
<ul class="divide-y divide-border overflow-hidden rounded-lg border border-border bg-card">
{#each signals as signal (signal.id)}
{@const badge = signalBadge(signal.tone)}
<li>
<a
href={signal.href}
class="flex flex-col gap-2 px-4 py-3 transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:flex-row sm:items-center sm:justify-between"
aria-label="{signal.title}. {signal.cta}"
>
<div class="flex min-w-0 items-start gap-3">
<Badge variant={badge.variant}>{badge.label}</Badge>
<div class="min-w-0">
<p class="text-sm font-medium text-card-foreground">{signal.title}</p>
<p class="mt-0.5 text-xs text-muted-foreground">{signal.detail}</p>
</div>
</div>
<span
class="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-primary sm:pl-4"
>
{signal.cta}
<ArrowRight class="h-3 w-3" aria-hidden="true" />
</span>
</a>
</li>
{/each}
</ul>
{/if}
</section>
<section class="space-y-3" aria-labelledby="admin-shortcuts-heading">
<h2 id="admin-shortcuts-heading" class="text-sm font-semibold text-foreground">
{i18n.t("admin.overview.shortcuts")}
</h2>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
{#each shortcuts as item}
{@const Icon = item.icon}
<a
href={item.href}
aria-label={item.title}
class="group flex min-h-[5.5rem] flex-col gap-2 rounded-lg border border-border bg-card p-3 text-card-foreground shadow-sm transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span
class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-border bg-muted text-foreground"
aria-hidden="true"
>
<Icon class="h-4 w-4" />
</span>
<span class="text-sm font-semibold tracking-tight">{item.title}</span>
<span class="text-xs leading-snug text-muted-foreground">{item.description}</span>
</a>
{/each}
</div>
<p class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
{i18n.t("admin.overview.also")}
<a
href="/admin/support"
class="inline-flex items-center gap-1 font-medium text-foreground underline-offset-2 hover:underline"
>
<LifeBuoy class="h-3 w-3" aria-hidden="true" />
{i18n.t("admin.overview.linkSupport")}
</a>
<a href="/admin/billing" class="font-medium text-foreground underline-offset-2 hover:underline"
>{i18n.t("admin.overview.linkBilling")}</a
>
<a
href="/admin/settings"
class="font-medium text-foreground underline-offset-2 hover:underline">{i18n.t("admin.overview.linkSettings")}</a
>
</p>
</section>
{#if providers}
<section class="space-y-3" aria-labelledby="admin-providers-heading">
<div class="flex flex-wrap items-end justify-between gap-2">
<h2 id="admin-providers-heading" class="text-sm font-semibold text-foreground">
{i18n.t("admin.overview.providers")}
</h2>
<a
href="/admin/analytics"
class="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{i18n.t("admin.overview.charts")}
<ArrowRight class="h-3 w-3" aria-hidden="true" />
</a>
</div>
<div class="grid gap-3 sm:grid-cols-3">
<Card>
<CardHeader class="pb-1">
<CardTitle class="text-sm font-medium">{i18n.t("admin.overview.internalAi")}</CardTitle>
<CardDescription>{i18n.t("admin.overview.internalAiHint")}</CardDescription>
</CardHeader>
<CardContent>
<div class="text-xl font-semibold text-card-foreground">
{formatCredits(providers.internal.tokens)}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.overview.providerFoot", {
jobs: formatCredits(providers.internal.jobs),
products: formatCredits(providers.internal.products)
})}
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-1">
<CardTitle class="text-sm font-medium">{i18n.t("admin.overview.popularKeys")}</CardTitle>
<CardDescription>{i18n.t("admin.overview.popularKeysHint")}</CardDescription>
</CardHeader>
<CardContent>
<div class="text-xl font-semibold text-card-foreground">
{formatCredits(providers.popular.tokens)}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.overview.providerFoot", {
jobs: formatCredits(providers.popular.jobs),
products: formatCredits(providers.popular.products)
})}
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-1">
<CardTitle class="text-sm font-medium">{i18n.t("admin.overview.customUrl")}</CardTitle>
<CardDescription>{i18n.t("admin.overview.customUrlHint")}</CardDescription>
</CardHeader>
<CardContent>
<div class="text-xl font-semibold text-card-foreground">
{formatCredits(providers.custom.tokens)}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.overview.providerFoot", {
jobs: formatCredits(providers.custom.jobs),
products: formatCredits(providers.custom.products)
})}
</p>
</CardContent>
</Card>
</div>
</section>
{/if}
{/if}
</PageShell>
@@ -0,0 +1,889 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { api, failureMessage } from "$lib/api";
import { requirePlatformAdmin } from "$lib/admin-gate";
import { formatCredits, formatDate } from "$lib/utils";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import AdminStatusChart from "$lib/components/AdminStatusChart.svelte";
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableShell
} from "$lib/components/ui";
import {
Activity,
AlertTriangle,
Building2,
Coins,
Cpu,
KeyRound,
Layers,
Ticket,
UserPlus
} from "@lucide/svelte";
type DayPoint = {
date: string;
tokens?: number;
products?: number;
created?: number;
completed?: number;
failed?: number;
};
type SignupDayPoint = {
date: string;
users: number;
companies: number;
};
type ProviderBucket = {
tokens: number;
jobs: number;
products: number;
};
type ProviderBreakdown = {
internal: ProviderBucket;
popular: ProviderBucket;
custom: ProviderBucket;
};
type ProviderDayPoint = {
date: string;
internal: number;
popular: number;
custom: number;
unknown?: number;
total: number;
};
type ProviderDetail = {
provider: string;
class: string;
tokens: number;
products: number;
};
type Summary = {
users: number;
companies: number;
users_period?: number;
companies_period?: number;
credits_allocated: number;
credits_used: number;
credits_remaining: number;
tokens_total: number;
tokens_period: number;
jobs_total: number;
jobs_by_status: Record<string, number>;
jobs_stuck?: number;
jobs_failed_period?: number;
jobs_completed_period?: number;
products_processed: number;
products_raw: number;
feeds_input?: number;
feeds_export?: number;
api_keys_active?: number;
api_keys_total?: number;
feed_sync_by_status?: Record<string, number>;
tickets_by_status?: Record<string, number>;
};
type CompanyUsage = {
id: string;
name: string;
total_credits: number;
used_credits: number;
credits_remaining: number;
tokens: number;
jobs: number;
providers?: ProviderBreakdown;
};
type CycleRow = {
company_id: string;
company_name: string;
start_date: string;
end_date: string;
credits_used: number;
products_processed: number;
};
type AnalyticsResponse = {
days: number;
summary: Summary;
series: {
tokens_by_day: DayPoint[];
jobs_by_day: DayPoint[];
tokens_by_provider_day?: ProviderDayPoint[];
signups_by_day?: SignupDayPoint[];
};
companies: CompanyUsage[];
billing_cycles: CycleRow[];
providers?: ProviderBreakdown;
tokens_by_provider_detail?: ProviderDetail[];
notes?: string[];
};
let loading = $state(true);
let accessDenied = $state(false);
let error = $state("");
let days = $state(30);
let data = $state<AnalyticsResponse | null>(null);
const tokenPoints = $derived(
(data?.series.tokens_by_day ?? []).map((p) => ({
label: p.date.slice(5),
value: Number(p.tokens ?? 0),
secondary: Number(p.products ?? 0)
}))
);
const jobPoints = $derived(
(data?.series.jobs_by_day ?? []).map((p) => ({
label: p.date.slice(5),
value: Number(p.created ?? 0),
secondary: Number(p.failed ?? 0)
}))
);
const signupPoints = $derived(
(data?.series.signups_by_day ?? []).map((p) => ({
label: p.date.slice(5),
value: Number(p.users ?? 0),
secondary: Number(p.companies ?? 0)
}))
);
const statusEntries = $derived(
Object.entries(data?.summary.jobs_by_status ?? {}).sort((a, b) => b[1] - a[1])
);
const jobStatusChart = $derived(
statusEntries.map(([label, value]) => ({ label, value: Number(value) }))
);
const feedSyncChart = $derived(
Object.entries(data?.summary.feed_sync_by_status ?? {})
.map(([label, value]) => ({ label, value: Number(value) }))
.sort((a, b) => b.value - a.value)
);
const ticketChart = $derived(
Object.entries(data?.summary.tickets_by_status ?? {})
.map(([label, value]) => ({ label, value: Number(value) }))
.sort((a, b) => b.value - a.value)
);
const failRate = $derived.by(() => {
const failed = Number(data?.summary.jobs_failed_period ?? 0);
const completed = Number(data?.summary.jobs_completed_period ?? 0);
const denom = failed + completed;
if (denom <= 0) return null;
return Math.round((failed / denom) * 1000) / 10;
});
const ticketsOpen = $derived(
Number(data?.summary.tickets_by_status?.open ?? 0) +
Number(data?.summary.tickets_by_status?.pending ?? 0)
);
const providers = $derived(
data?.providers ?? {
internal: { tokens: 0, jobs: 0, products: 0 },
popular: { tokens: 0, jobs: 0, products: 0 },
custom: { tokens: 0, jobs: 0, products: 0 }
}
);
const providerSeries = $derived(data?.series.tokens_by_provider_day ?? []);
const providerMax = $derived(
Math.max(1, ...providerSeries.map((p) => Math.max(p.internal, p.popular, p.custom, p.unknown ?? 0)))
);
const providerHasData = $derived(providerSeries.some((p) => p.total > 0));
const providerDetail = $derived(data?.tokens_by_provider_detail ?? []);
onMount(async () => {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
if (gate.reason === "auth") {
await goto("/login");
return;
}
if (gate.reason === "forbidden") {
accessDenied = true;
} else {
error = gate.message;
}
loading = false;
return;
}
await load(days);
});
async function load(nextDays: number) {
loading = true;
error = "";
days = nextDays;
try {
data = await api<AnalyticsResponse>(`/api/admin/analytics?days=${nextDays}`);
} catch (err) {
error = failureMessage(err, i18n.t("flash.admin.analyticsLoadFailed"));
data = null;
} finally {
loading = false;
}
}
function barH(v: number, max: number, height: number): number {
return Math.max(v > 0 ? 2 : 0, Math.round((v / max) * (height - 28)));
}
</script>
<PageShell
eyebrow={i18n.t("admin.analytics.eyebrow")}
title={i18n.t("admin.analytics.title")}
description={i18n.t("admin.analytics.description")}
>
{#snippet actions()}
{#if data}
<div
class="flex flex-wrap items-center gap-2"
role="group"
aria-labelledby="analytics-period-label"
aria-busy={loading}
>
<span class="text-sm text-muted-foreground" id="analytics-period-label">{i18n.t("admin.analytics.period")}</span>
{#each [7, 30, 90] as option}
<Button
size="sm"
variant={days === option ? "default" : "outline"}
onclick={() => load(option)}
disabled={loading}
aria-pressed={days === option}
aria-label={i18n.t("admin.analytics.lastNDaysAria", { days: option })}
>
{option}d
</Button>
{/each}
{#if loading}
<span class="text-xs text-muted-foreground">{i18n.t("admin.analytics.refreshing")}</span>
{/if}
</div>
{/if}
{/snippet}
{#if loading && !data}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else if error && !data}
<Alert message={error} />
{:else if data}
<div class="space-y-8">
{#if error}
<Alert message={error} />
{/if}
<section aria-labelledby="analytics-overview-heading" class="space-y-3">
<div>
<h2 id="analytics-overview-heading" class="text-sm font-semibold text-foreground">
{i18n.t("admin.analytics.overview")}
</h2>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.analytics.overviewHint", { days: data.days })}
</p>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">{i18n.t("admin.analytics.llmTokens")}</CardTitle>
<Cpu class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums">{formatCredits(data.summary.tokens_total)}</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.analytics.tokensInPeriod", {
tokens: formatCredits(data.summary.tokens_period)
})}
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">{i18n.t("admin.analytics.credits")}</CardTitle>
<Coins class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums">{formatCredits(data.summary.credits_used)}</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.analytics.creditsRemainingOf", {
remaining: formatCredits(data.summary.credits_remaining),
allocated: formatCredits(data.summary.credits_allocated)
})}
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">{i18n.t("admin.analytics.jobs")}</CardTitle>
<Activity class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums">{formatCredits(data.summary.jobs_total)}</div>
<p class="mt-1 text-xs text-muted-foreground">
{#if statusEntries.length === 0}
{i18n.t("admin.analytics.noJobsYet")}
{:else}
{statusEntries.map(([s, n]) => `${n} ${s}`).join(" · ")}
{/if}
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">{i18n.t("admin.analytics.volume")}</CardTitle>
<Layers class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums">
{formatCredits(data.summary.products_processed)}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.analytics.feedsVolume", {
input: formatCredits(data.summary.feeds_input ?? 0),
export: formatCredits(data.summary.feeds_export ?? 0)
})}
</p>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.analytics.usersCompaniesRaw", {
users: formatCredits(data.summary.users),
companies: formatCredits(data.summary.companies),
raw: formatCredits(data.summary.products_raw)
})}
</p>
</CardContent>
</Card>
</div>
</section>
<section aria-labelledby="analytics-ops-heading" class="space-y-3">
<div>
<h2 id="analytics-ops-heading" class="text-sm font-semibold text-foreground">
{i18n.t("admin.analytics.operations")}
</h2>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.analytics.operationsHint")}
</p>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">{i18n.t("admin.analytics.failureRate")}</CardTitle>
<AlertTriangle class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums">
{failRate === null ? i18n.t("status.emDash") : `${failRate}%`}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.analytics.failedCompleted", {
failed: formatCredits(data.summary.jobs_failed_period ?? 0),
completed: formatCredits(data.summary.jobs_completed_period ?? 0)
})}
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">{i18n.t("admin.analytics.stuckJobs")}</CardTitle>
<AlertTriangle class="h-4 w-4 text-chart-amber" aria-hidden="true" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums">
{formatCredits(data.summary.jobs_stuck ?? 0)}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.analytics.runningOver2h")}
<a class="underline-offset-2 hover:underline" href="/admin/diagnostics">{i18n.t("admin.analytics.linkDiagnostics")}</a>
·
<a class="underline-offset-2 hover:underline" href="/admin/stuck-products"
>{i18n.t("admin.analytics.linkStuck")}</a
>
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">{i18n.t("admin.analytics.apiKeys")}</CardTitle>
<KeyRound class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums">
{formatCredits(data.summary.api_keys_active ?? 0)}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.analytics.apiKeysTotal", {
total: formatCredits(data.summary.api_keys_total ?? 0)
})}
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">{i18n.t("admin.analytics.signups")}</CardTitle>
<UserPlus class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums">
{formatCredits(data.summary.users_period ?? 0)}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.analytics.signupsDetail", {
companies: formatCredits(data.summary.companies_period ?? 0),
tickets: formatCredits(ticketsOpen)
})}
</p>
</CardContent>
</Card>
</div>
</section>
<section aria-labelledby="analytics-providers-heading" class="space-y-3">
<div>
<h2 id="analytics-providers-heading" class="text-sm font-semibold text-foreground">
{i18n.t("admin.analytics.providerMix")}
</h2>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.analytics.providerMixHint")}
</p>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Card class="border-l-4 border-l-chart-sky">
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium">{i18n.t("admin.analytics.internalAi")}</CardTitle>
<CardDescription>{i18n.t("admin.analytics.internalAiHint")}</CardDescription>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums">
{formatCredits(providers.internal.tokens)}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.analytics.productsJobs", {
products: formatCredits(providers.internal.products),
jobs: formatCredits(providers.internal.jobs)
})}
</p>
</CardContent>
</Card>
<Card class="border-l-4 border-l-chart-emerald">
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium">{i18n.t("admin.analytics.popularKeys")}</CardTitle>
<CardDescription>{i18n.t("admin.analytics.popularKeysHint")}</CardDescription>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums">
{formatCredits(providers.popular.tokens)}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.analytics.productsJobs", {
products: formatCredits(providers.popular.products),
jobs: formatCredits(providers.popular.jobs)
})}
</p>
</CardContent>
</Card>
<Card class="border-l-4 border-l-chart-amber">
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium">{i18n.t("admin.analytics.customUrl")}</CardTitle>
<CardDescription>{i18n.t("admin.analytics.customUrlHint")}</CardDescription>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums">
{formatCredits(providers.custom.tokens)}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.analytics.productsJobs", {
products: formatCredits(providers.custom.products),
jobs: formatCredits(providers.custom.jobs)
})}
</p>
</CardContent>
</Card>
</div>
</section>
<section aria-labelledby="analytics-trends-heading" class="space-y-3">
<div>
<h2 id="analytics-trends-heading" class="text-sm font-semibold text-foreground">
{i18n.t("admin.analytics.trends")}
</h2>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.analytics.trendsHint")}
</p>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>{i18n.t("admin.analytics.tokensByDay")}</CardTitle>
<CardDescription>
{i18n.t("admin.analytics.tokensByDayHint")}
</CardDescription>
</CardHeader>
<CardContent>
{#await import("$lib/components/AdminSeriesChart.svelte") then mod}
<mod.default
points={tokenPoints}
primaryLabel={i18n.t("admin.analytics.seriesTokens")}
secondaryLabel={i18n.t("admin.analytics.seriesProducts")}
emptyMessage={i18n.t("admin.analytics.emptyTokens")}
/>
{/await}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{i18n.t("admin.analytics.tokensByProvider")}</CardTitle>
<CardDescription>
{i18n.t("admin.analytics.tokensByProviderHint")}
</CardDescription>
</CardHeader>
<CardContent>
{#if !providerHasData}
<div
class="flex h-[180px] items-center justify-center rounded-lg border border-dashed border-border bg-muted/30 text-sm text-muted-foreground"
>
{i18n.t("admin.analytics.emptyProviderTokens")}
</div>
{:else}
<div class="mb-2.5 flex flex-wrap gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
<span class="inline-flex items-center gap-1.5">
<span class="h-2.5 w-2.5 rounded-sm bg-chart-sky" aria-hidden="true"></span>
{i18n.t("admin.analytics.internal")}
</span>
<span class="inline-flex items-center gap-1.5">
<span class="h-2.5 w-2.5 rounded-sm bg-chart-emerald" aria-hidden="true"></span>
{i18n.t("admin.analytics.popular")}
</span>
<span class="inline-flex items-center gap-1.5">
<span class="h-2.5 w-2.5 rounded-sm bg-chart-amber" aria-hidden="true"></span>
{i18n.t("admin.analytics.custom")}
</span>
</div>
<div
class="flex items-end gap-px overflow-x-auto rounded-lg border border-border bg-chart-plot px-2.5 pb-1.5 pt-3 shadow-sm shadow-black/5"
style="height: 180px"
role="img"
aria-label={i18n.t("admin.analytics.tokensByProviderAria")}
>
{#each providerSeries as point}
{@const hi = barH(point.internal, providerMax, 180)}
{@const hp = barH(point.popular, providerMax, 180)}
{@const hc = barH(point.custom, providerMax, 180)}
{@const tip = i18n.t("admin.analytics.providerDayTip", {
date: point.date.slice(5),
internal: point.internal.toLocaleString(),
popular: point.popular.toLocaleString(),
custom: point.custom.toLocaleString()
})}
<div
class="group relative flex min-w-[8px] flex-1 flex-col items-center justify-end"
title={tip}
>
<div
class="pointer-events-none absolute bottom-full z-10 mb-1.5 hidden whitespace-nowrap rounded-md border border-border bg-popover px-2 py-1 text-[10px] font-medium text-popover-foreground shadow-md group-hover:block"
>
{tip}
</div>
<div class="flex w-full items-end justify-center gap-px" style="height: 152px">
<div
class="w-[30%] rounded-t-sm bg-chart-sky/90"
style="height: {hi}px"
title={i18n.t("admin.analytics.internalCount", { count: point.internal.toLocaleString() })}
></div>
<div
class="w-[30%] rounded-t-sm bg-chart-emerald/90"
style="height: {hp}px"
title={i18n.t("admin.analytics.popularCount", { count: point.popular.toLocaleString() })}
></div>
<div
class="w-[30%] rounded-t-sm bg-chart-amber/90"
style="height: {hc}px"
title={i18n.t("admin.analytics.customCount", { count: point.custom.toLocaleString() })}
></div>
</div>
</div>
{/each}
</div>
<div class="mt-1.5 flex justify-between text-[10px] tabular-nums text-muted-foreground">
<span>{providerSeries[0]?.date.slice(5) ?? ""}</span>
<span>{providerSeries[providerSeries.length - 1]?.date.slice(5) ?? ""}</span>
</div>
{/if}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{i18n.t("admin.analytics.jobsByDay")}</CardTitle>
<CardDescription>{i18n.t("admin.analytics.jobsByDayHint")}</CardDescription>
</CardHeader>
<CardContent>
{#await import("$lib/components/AdminSeriesChart.svelte") then mod}
<mod.default
points={jobPoints}
primaryLabel={i18n.t("admin.analytics.seriesCreated")}
secondaryLabel={i18n.t("admin.analytics.seriesFailed")}
emptyMessage={i18n.t("admin.analytics.emptyJobsSeries")}
/>
{/await}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{i18n.t("admin.analytics.signupsByDay")}</CardTitle>
<CardDescription>{i18n.t("admin.analytics.signupsByDayHint")}</CardDescription>
</CardHeader>
<CardContent>
{#await import("$lib/components/AdminSeriesChart.svelte") then mod}
<mod.default
points={signupPoints}
primaryLabel={i18n.t("admin.analytics.seriesUsers")}
secondaryLabel={i18n.t("admin.analytics.seriesCompanies")}
emptyMessage={i18n.t("admin.analytics.emptySignups")}
/>
{/await}
</CardContent>
</Card>
</div>
</section>
<section aria-labelledby="analytics-status-heading" class="space-y-3">
<div>
<h2 id="analytics-status-heading" class="text-sm font-semibold text-foreground">
{i18n.t("admin.analytics.statusMix")}
</h2>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.analytics.statusMixHint")}
</p>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Activity class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
{i18n.t("admin.analytics.jobStatus")}
</CardTitle>
<CardDescription>{i18n.t("admin.analytics.processingJobsStatus")}</CardDescription>
</CardHeader>
<CardContent>
<AdminStatusChart
entries={jobStatusChart}
emptyMessage={i18n.t("admin.analytics.emptyJobStatus")}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Layers class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
{i18n.t("admin.analytics.feedSyncQueue")}
</CardTitle>
<CardDescription>{i18n.t("admin.analytics.feedSyncStatus")}</CardDescription>
</CardHeader>
<CardContent>
<AdminStatusChart
entries={feedSyncChart}
emptyMessage={i18n.t("admin.analytics.emptyFeedSync")}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Ticket class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
{i18n.t("admin.analytics.supportTickets")}
</CardTitle>
<CardDescription>{i18n.t("admin.analytics.ticketsStatus")}</CardDescription>
</CardHeader>
<CardContent>
<AdminStatusChart
entries={ticketChart}
emptyMessage={i18n.t("admin.analytics.emptyTickets")}
/>
</CardContent>
</Card>
</div>
</section>
<section aria-labelledby="analytics-detail-heading" class="space-y-3">
<div>
<h2 id="analytics-detail-heading" class="text-sm font-semibold text-foreground">
{i18n.t("admin.analytics.usageDetail")}
</h2>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.analytics.usageDetailHint")}
</p>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>{i18n.t("admin.analytics.providerDetail")}</CardTitle>
<CardDescription>{i18n.t("admin.analytics.providerDetailHint")}</CardDescription>
</CardHeader>
<CardContent>
{#if providerDetail.length === 0}
<EmptyState
title={i18n.t("admin.analytics.noProviderRows")}
message={i18n.t("empty.admin.noProviderMessage")}
/>
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.analytics.provider")}</TableHead>
<TableHead>{i18n.t("admin.analytics.class")}</TableHead>
<TableHead class="text-right">{i18n.t("admin.analytics.tokensCol")}</TableHead>
<TableHead class="text-right">{i18n.t("admin.analytics.productsCol")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each providerDetail as row}
<TableRow>
<TableCell class="font-medium">{row.provider}</TableCell>
<TableCell class="text-muted-foreground">{row.class}</TableCell>
<TableCell class="text-right tabular-nums"
>{formatCredits(row.tokens)}</TableCell
>
<TableCell class="text-right tabular-nums"
>{formatCredits(row.products)}</TableCell
>
</TableRow>
{/each}
</TableBody>
</TableShell>
{/if}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Building2 class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
{i18n.t("admin.analytics.companyUsage")}
</CardTitle>
<CardDescription>{i18n.t("admin.analytics.companiesHint")}</CardDescription>
</CardHeader>
<CardContent>
{#if data.companies.length === 0}
<EmptyState title={i18n.t("empty.admin.noCompaniesTitle")} message={i18n.t("empty.admin.noCompaniesMessage")} />
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.analytics.company")}</TableHead>
<TableHead class="text-right">{i18n.t("admin.analytics.tokensCol")}</TableHead>
<TableHead class="text-right">{i18n.t("admin.analytics.internal")}</TableHead>
<TableHead class="text-right">{i18n.t("admin.analytics.popular")}</TableHead>
<TableHead class="text-right">{i18n.t("admin.analytics.custom")}</TableHead>
<TableHead class="text-right">{i18n.t("admin.analytics.jobsCol")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each data.companies as company}
<TableRow>
<TableCell class="font-medium">{company.name}</TableCell>
<TableCell class="text-right tabular-nums"
>{formatCredits(company.tokens)}</TableCell
>
<TableCell class="text-right tabular-nums"
>{formatCredits(company.providers?.internal?.tokens ?? 0)}</TableCell
>
<TableCell class="text-right tabular-nums"
>{formatCredits(company.providers?.popular?.tokens ?? 0)}</TableCell
>
<TableCell class="text-right tabular-nums"
>{formatCredits(company.providers?.custom?.tokens ?? 0)}</TableCell
>
<TableCell class="text-right tabular-nums"
>{formatCredits(company.jobs)}</TableCell
>
</TableRow>
{/each}
</TableBody>
</TableShell>
{/if}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>{i18n.t("admin.analytics.billingCycles")}</CardTitle>
<CardDescription>
{i18n.t("admin.analytics.billingCyclesHint")}
</CardDescription>
</CardHeader>
<CardContent>
{#if data.billing_cycles.length === 0}
<EmptyState
title={i18n.t("admin.analytics.noBillingCycles")}
message={i18n.t("empty.admin.noCyclesMessage")}
/>
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.analytics.company")}</TableHead>
<TableHead>{i18n.t("admin.analytics.periodCol")}</TableHead>
<TableHead class="text-right">{i18n.t("admin.analytics.creditsCol")}</TableHead>
<TableHead class="text-right">{i18n.t("admin.analytics.productsCol")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each data.billing_cycles as cycle}
<TableRow>
<TableCell class="font-medium">{cycle.company_name}</TableCell>
<TableCell class="text-xs text-muted-foreground">
{formatDate(cycle.start_date)} {formatDate(cycle.end_date)}
</TableCell>
<TableCell class="text-right tabular-nums"
>{formatCredits(cycle.credits_used)}</TableCell
>
<TableCell class="text-right tabular-nums"
>{formatCredits(cycle.products_processed)}</TableCell
>
</TableRow>
{/each}
</TableBody>
</TableShell>
{/if}
</CardContent>
</Card>
</section>
{#if data.notes?.length}
<section aria-labelledby="analytics-notes-heading">
<Card>
<CardHeader>
<CardTitle id="analytics-notes-heading" class="text-sm"
>{i18n.t("admin.analytics.dataNotes")}</CardTitle
>
</CardHeader>
<CardContent>
<ul class="list-inside list-disc space-y-1 text-sm text-muted-foreground">
{#each data.notes as note}
<li>{note}</li>
{/each}
</ul>
</CardContent>
</Card>
</section>
{/if}
</div>
{/if}
</PageShell>
@@ -0,0 +1,717 @@
<script module lang="ts">
/** Module-scoped so remounted page instances share one cold bootstrap. */
let billingBootstrapInflight: Promise<void> | null = null;
</script>
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { api, failureMessage } from "$lib/api";
import { requirePlatformAdmin } from "$lib/admin-gate";
import {
assignAdminPlan,
classifyAdminPlanVisibility,
countAdminPlansByVisibility,
fetchAdminCompaniesList,
fetchAdminPlansList,
invalidateAdminBillingLists,
planOptionLabel,
upsertAdminPlan,
type AdminBillingCompany,
type AdminBillingPlan
} from "$lib/admin-billing-plans";
import { formatCredits } from "$lib/utils";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import AdminPlansPanel from "$lib/components/admin/AdminPlansPanel.svelte";
import PlanPermissionsPanel from "$lib/components/admin/PlanPermissionsPanel.svelte";
import GlobalFeatureGatesPanel from "$lib/components/admin/GlobalFeatureGatesPanel.svelte";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Checkbox,
Dialog,
Input,
Label,
Select,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableShell,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Textarea
} from "$lib/components/ui";
import { CreditCard, Coins, UserPlus, Users } from "@lucide/svelte";
type Company = AdminBillingCompany;
type PlanFormState = {
id: number;
name: string;
description: string;
monthly_credits: number;
yearly_credits: string;
max_products: string;
is_custom: boolean;
term: string;
};
const emptyPlanForm = (): PlanFormState => ({
id: 0,
name: "",
description: "",
monthly_credits: 1000,
yearly_credits: "",
max_products: "",
is_custom: true,
term: "monthly"
});
let loading = $state(true);
let accessDenied = $state(false);
let busy = $state(false);
let error = $state("");
let success = $state("");
let tab = $state("plans");
let permissionsPlanId = $state("");
let plans = $state<AdminBillingPlan[]>([]);
let companies = $state<Company[]>([]);
let companySearch = $state("");
let companyPlanFilter = $state<"all" | "with_plan" | "without_plan">("all");
/** Coalesce remount storms so cold load does not double-hit plans/companies. */
// billingBootstrapInflight lives in <script module>
let assignOpen = $state(false);
let creditsOpen = $state(false);
let planFormOpen = $state(false);
let planForm = $state<PlanFormState>(emptyPlanForm());
let assignCompanyId = $state("");
let assignPlanId = $state("");
let assignIsTrial = $state(false);
let assignTrialCredits = $state(0);
let creditCompanyId = $state("");
let creditAmount = $state(100);
const totalAllocated = $derived(companies.reduce((s, c) => s + Number(c.total_credits ?? 0), 0));
const totalUsed = $derived(companies.reduce((s, c) => s + Number(c.used_credits ?? 0), 0));
const visibilityCounts = $derived(countAdminPlansByVisibility(plans));
const withoutPlanCount = $derived(companies.filter((c) => c.has_active_plan === false).length);
const planFormMode = $derived(planForm.id > 0 ? "edit" : "create");
const filteredCompanies = $derived.by(() => {
const q = companySearch.trim().toLowerCase();
return companies.filter((c) => {
if (companyPlanFilter === "with_plan" && c.has_active_plan === false) return false;
if (companyPlanFilter === "without_plan" && c.has_active_plan !== false) return false;
if (!q) return true;
return c.name.toLowerCase().includes(q) || c.id.toLowerCase().includes(q);
});
});
onMount(() => {
if (!billingBootstrapInflight) {
billingBootstrapInflight = (async () => {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
if (gate.reason === "auth") {
await goto("/login");
return;
}
if (gate.reason === "forbidden") {
accessDenied = true;
} else {
error = gate.message;
}
return;
}
try {
await loadBillingData();
} catch (err) {
error = failureMessage(err, i18n.t("admin.billing.loadFailed"));
}
})().finally(() => {
billingBootstrapInflight = null;
});
}
void (billingBootstrapInflight ?? Promise.resolve())
.then(async () => {
if (accessDenied) return;
// Remounts that joined an in-flight bootstrap still need local state filled.
if (plans.length === 0 || companies.length === 0) {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
if (gate.reason === "forbidden") accessDenied = true;
return;
}
await loadBillingData();
}
})
.catch((err) => {
error = failureMessage(err, i18n.t("admin.billing.loadFailed"));
})
.finally(() => {
loading = false;
});
});
async function loadBillingData(opts: { fresh?: boolean } = {}) {
if (opts.fresh) invalidateAdminBillingLists();
const [p, c] = await Promise.all([fetchAdminPlansList(), fetchAdminCompaniesList()]);
plans = p;
companies = c;
}
async function reload() {
await loadBillingData({ fresh: true });
}
function openCreatePlan() {
planForm = emptyPlanForm();
planFormOpen = true;
}
function openEditPlan(plan: AdminBillingPlan) {
planForm = {
id: Number(plan.id) || 0,
name: plan.name ?? "",
description: plan.description ?? "",
monthly_credits: Number(plan.monthly_credits ?? 0),
yearly_credits:
plan.yearly_credits == null || plan.yearly_credits === undefined
? ""
: String(plan.yearly_credits),
max_products:
plan.max_products == null || plan.max_products === undefined
? ""
: String(plan.max_products),
is_custom: Boolean(plan.is_custom),
term: plan.term || "monthly"
};
planFormOpen = true;
}
function openAssign(plan?: AdminBillingPlan, companyId?: string) {
assignPlanId = plan ? String(plan.id) : assignPlanId || "";
assignCompanyId = companyId ?? assignCompanyId;
assignIsTrial = false;
assignTrialCredits = 0;
assignOpen = true;
}
function openCreditsFor(companyId: string) {
creditCompanyId = companyId;
creditsOpen = true;
}
function openPermissionsFor(planId: number | string) {
permissionsPlanId = String(planId);
tab = "permissions";
}
async function savePlan(event: Event) {
event.preventDefault();
busy = true;
error = "";
success = "";
try {
const yearlyRaw = planForm.yearly_credits.trim();
const maxRaw = planForm.max_products.trim();
await upsertAdminPlan({
id: planForm.id > 0 ? planForm.id : undefined,
name: planForm.name,
description: planForm.description.trim() || null,
monthly_credits: Number(planForm.monthly_credits),
yearly_credits: yearlyRaw === "" ? null : Number(yearlyRaw),
max_products: maxRaw === "" ? null : Number(maxRaw),
is_custom: planForm.is_custom,
term: planForm.term
});
success = planFormMode === "edit" ? i18n.t("admin.billing.planUpdated") : i18n.t("admin.billing.planCreated");
planFormOpen = false;
planForm = emptyPlanForm();
await reload();
} catch (err) {
error = failureMessage(err, planFormMode === "edit" ? i18n.t("admin.billing.updateFailed") : i18n.t("admin.billing.createFailed"));
} finally {
busy = false;
}
}
async function assignPlan(event: Event) {
event.preventDefault();
busy = true;
error = "";
success = "";
try {
await assignAdminPlan({
company_id: assignCompanyId,
plan_id: Number(assignPlanId),
is_trial: assignIsTrial,
trial_credits: assignIsTrial ? Number(assignTrialCredits) : 0
});
success = i18n.t("flash.admin.planAssignedShort");
assignOpen = false;
await reload();
} catch (err) {
error = failureMessage(err, i18n.t("admin.billing.assignFailed"));
} finally {
busy = false;
}
}
async function addCredits(event: Event) {
event.preventDefault();
busy = true;
error = "";
success = "";
try {
await api("/api/admin/credits", {
method: "POST",
body: { company_id: creditCompanyId, amount: creditAmount }
});
success = i18n.t("flash.admin.creditsUpdated");
creditsOpen = false;
await reload();
} catch (err) {
error = failureMessage(err, i18n.t("admin.billing.creditsFailed"));
} finally {
busy = false;
}
}
async function runCycles() {
busy = true;
error = "";
success = "";
try {
const res = await api<{ processed: number }>("/api/admin/billing/run-cycles", {
method: "POST",
body: {}
});
success = i18n.t("flash.admin.cyclesProcessed", { count: res.processed });
} catch (err) {
error = failureMessage(err, i18n.t("admin.billing.cyclesFailed"));
} finally {
busy = false;
}
}
</script>
<PageShell
title={i18n.t("admin.billing.title")}
description={i18n.t("admin.billing.description")}
>
{#snippet actions()}
<Button size="sm" onclick={openCreatePlan}>{i18n.t("admin.billing.createPlan")}</Button>
<Button variant="outline" size="sm" onclick={() => openAssign()}>{i18n.t("admin.billing.assignPlanBtn")}</Button>
{/snippet}
{#if loading}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else}
<Alert message={error} />
<Alert tone="success" message={success} />
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<Card class="border-border bg-card">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium text-foreground">{i18n.t("admin.billing.stat.plans")}</CardTitle>
<CreditCard class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums text-foreground">
{formatCredits(plans.length)}
</div>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.billing.stat.catalogHidden", { catalog: visibilityCounts.catalog, hidden: visibilityCounts.hidden })}
</p>
</CardContent>
</Card>
<Card class="border-border bg-card">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium text-foreground"
>{i18n.t("admin.billing.stat.creditsAllocated")}</CardTitle
>
<CreditCard class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums text-foreground">
{formatCredits(totalAllocated)}
</div>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.billing.stat.acrossCompanies")}
</p>
</CardContent>
</Card>
<Card class="border-border bg-card">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium text-foreground">{i18n.t("admin.billing.stat.creditsUsed")}</CardTitle>
<Users class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums text-foreground">
{formatCredits(totalUsed)}
</div>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.billing.stat.creditsConsumed")}
</p>
</CardContent>
</Card>
<Card class="border-border bg-card">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium text-foreground"
>{i18n.t("admin.billing.stat.noActivePlan")}</CardTitle
>
<Users class="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold tabular-nums text-foreground">
{formatCredits(withoutPlanCount)}
</div>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.billing.stat.noActivePlanHint")}
</p>
</CardContent>
</Card>
</div>
<Tabs bind:value={tab} class="space-y-4">
<TabsList>
<TabsTrigger value="plans">{i18n.t("admin.billing.tab.plans")}</TabsTrigger>
<TabsTrigger value="permissions">{i18n.t("admin.billing.tab.permissions")}</TabsTrigger>
<TabsTrigger value="global">{i18n.t("admin.billing.tab.global")}</TabsTrigger>
<TabsTrigger value="companies">{i18n.t("admin.billing.tab.companies")}</TabsTrigger>
</TabsList>
<TabsContent value="plans" class="mt-0 space-y-4">
<AdminPlansPanel
{plans}
{busy}
onEdit={openEditPlan}
onAssign={(plan) => openAssign(plan)}
onPermissions={openPermissionsFor}
/>
<Card class="border-border bg-card">
<CardHeader>
<CardTitle class="text-foreground">{i18n.t("admin.billing.cyclesTitle")}</CardTitle>
<CardDescription class="text-muted-foreground">
{i18n.t("admin.billing.cyclesDesc")}
</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" loading={busy} onclick={runCycles}
>{i18n.t("admin.billing.runCycles")}</Button
>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="permissions" class="mt-0 space-y-4">
<PlanPermissionsPanel initialPlans={plans} selectedPlanId={permissionsPlanId} />
</TabsContent>
<TabsContent value="global" class="mt-0 space-y-4">
<GlobalFeatureGatesPanel />
</TabsContent>
<TabsContent value="companies" class="mt-0 space-y-4">
<Card class="border-border bg-card">
<CardHeader class="gap-4 space-y-0 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-1.5">
<CardTitle class="text-foreground">{i18n.t("admin.billing.companiesTitle")}</CardTitle>
<CardDescription class="text-muted-foreground">
{i18n.t("admin.billing.companiesDesc")}
</CardDescription>
</div>
<div class="flex w-full flex-col gap-2 sm:max-w-md sm:flex-row">
<label class="sr-only" for="admin-company-search">{i18n.t("admin.billing.searchCompaniesAria")}</label>
<Input
id="admin-company-search"
type="search"
placeholder={i18n.t("admin.billing.searchCompany")}
bind:value={companySearch}
autocomplete="off"
/>
<label class="sr-only" for="admin-company-plan-filter">{i18n.t("admin.billing.planStatusAria")}</label>
<Select id="admin-company-plan-filter" bind:value={companyPlanFilter}>
<option value="all">{i18n.t("admin.billing.filter.all")}</option>
<option value="with_plan">{i18n.t("admin.billing.filter.withPlan")}</option>
<option value="without_plan">{i18n.t("admin.billing.filter.withoutPlan")}</option>
</Select>
</div>
</CardHeader>
<CardContent>
{#if companies.length === 0}
<EmptyState message={i18n.t("empty.admin.noCompaniesFound")} />
{:else if filteredCompanies.length === 0}
<EmptyState message={i18n.t("empty.admin.noCompaniesMatch")} />
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.billing.col.company")}</TableHead>
<TableHead>{i18n.t("admin.billing.col.planStatus")}</TableHead>
<TableHead>{i18n.t("admin.billing.col.creditsRemaining")}</TableHead>
<TableHead class="hidden lg:table-cell">{i18n.t("admin.billing.col.total")}</TableHead>
<TableHead class="hidden lg:table-cell">{i18n.t("admin.billing.col.used")}</TableHead>
<TableHead class="hidden md:table-cell">{i18n.t("admin.billing.col.usagePct")}</TableHead>
<TableHead stickyRight>{i18n.t("common.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each filteredCompanies as company (company.id)}
{@const total = Number(company.total_credits ?? 0)}
{@const used = Number(company.used_credits ?? 0)}
{@const remaining = Math.max(total - used, 0)}
<TableRow>
<TableCell class="font-medium">
<p class="text-foreground">{company.name}</p>
<p class="font-mono text-xs text-muted-foreground">
{company.id}
</p>
</TableCell>
<TableCell>
{#if company.has_active_plan === false}
<Badge variant="warning">{i18n.t("admin.billing.badge.noPlan")}</Badge>
{:else}
<Badge variant="outline">{i18n.t("admin.billing.badge.active")}</Badge>
{/if}
</TableCell>
<TableCell class="tabular-nums text-foreground"
>{formatCredits(remaining)}</TableCell
>
<TableCell class="hidden tabular-nums text-foreground lg:table-cell"
>{formatCredits(total)}</TableCell
>
<TableCell class="hidden tabular-nums text-foreground lg:table-cell"
>{formatCredits(used)}</TableCell
>
<TableCell class="hidden tabular-nums text-foreground md:table-cell"
>{total > 0 ? Math.round((used / total) * 100) : 0}%</TableCell
>
<TableCell stickyRight>
<div class="flex flex-wrap justify-end gap-1">
<Button
variant="ghost"
size="sm"
onclick={() => openAssign(undefined, company.id)}
aria-label={i18n.t("admin.billing.assignPlanAria", { name: company.name })}
>
<UserPlus class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.billing.assignPlanBtn")}</span>
</Button>
<Button
variant="ghost"
size="sm"
onclick={() => openCreditsFor(company.id)}
aria-label={i18n.t("admin.billing.addCreditsAria", { name: company.name })}
>
<Coins class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.billing.addCredits")}</span>
</Button>
</div>
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
<p class="mt-3 text-xs text-muted-foreground">
{i18n.t("admin.billing.showingCompanies", { filtered: filteredCompanies.length, total: companies.length })}
</p>
{/if}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/if}
</PageShell>
<Dialog
bind:open={planFormOpen}
title={planFormMode === "edit" ? i18n.t("admin.billing.editPlanTitle") : i18n.t("admin.billing.createPlanTitle")}
description={planFormMode === "edit"
? i18n.t("admin.billing.editPlanDesc")
: i18n.t("admin.billing.createPlanDesc")}
>
<form class="space-y-4" onsubmit={savePlan}>
{#if error}
<p class="text-sm text-destructive" role="alert">{error}</p>
{/if}
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2 sm:col-span-2">
<Label for="plan-name">{i18n.t("admin.billing.field.name")}</Label>
<Input id="plan-name" bind:value={planForm.name} required data-dialog-initial-focus />
</div>
<div class="space-y-2 sm:col-span-2">
<Label for="plan-description">{i18n.t("admin.billing.field.description")}</Label>
<Textarea
id="plan-description"
bind:value={planForm.description}
rows={2}
placeholder={i18n.t("admin.billing.field.descriptionPlaceholder")}
/>
</div>
<div class="space-y-2">
<Label for="plan-credits">{i18n.t("admin.billing.field.monthlyCredits")}</Label>
<Input
id="plan-credits"
type="number"
min="0"
bind:value={planForm.monthly_credits}
required
/>
</div>
<div class="space-y-2">
<Label for="plan-yearly">{i18n.t("admin.billing.field.yearlyCredits")}</Label>
<Input
id="plan-yearly"
type="number"
min="0"
bind:value={planForm.yearly_credits}
placeholder={i18n.t("common.optional")}
/>
</div>
<div class="space-y-2">
<Label for="plan-max-products">{i18n.t("admin.billing.field.maxProducts")}</Label>
<Input
id="plan-max-products"
type="number"
min="0"
bind:value={planForm.max_products}
placeholder={i18n.t("admin.billing.field.maxProductsPlaceholder")}
/>
</div>
<div class="space-y-2">
<Label for="plan-term">{i18n.t("admin.billing.field.term")}</Label>
<Select id="plan-term" bind:value={planForm.term}>
<option value="monthly">{i18n.t("admin.billing.term.monthly")}</option>
<option value="yearly">{i18n.t("admin.billing.term.yearly")}</option>
</Select>
</div>
</div>
<div class="flex items-start gap-3 rounded-md border border-border p-3">
<Checkbox id="plan-custom" bind:checked={planForm.is_custom} />
<div class="space-y-1">
<Label for="plan-custom" class="cursor-pointer font-medium">{i18n.t("admin.billing.customPackage")}</Label>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.billing.customPackageHint")}
{#if planForm.name.trim()}
{@const preview = classifyAdminPlanVisibility({
name: planForm.name,
is_custom: planForm.is_custom
})}
{i18n.t("admin.billing.previewBadge", { preview })}
{/if}
</p>
</div>
</div>
<div class="flex flex-wrap justify-end gap-2 pt-1">
<Button type="button" variant="outline" disabled={busy} onclick={() => (planFormOpen = false)}
>{i18n.t("common.cancel")}</Button
>
<Button type="submit" loading={busy}
>{planFormMode === "edit" ? i18n.t("common.save") : i18n.t("admin.billing.createPlan")}</Button
>
</div>
</form>
</Dialog>
<Dialog
bind:open={assignOpen}
title={i18n.t("admin.billing.assignPlanTitle")}
description={i18n.t("admin.billing.assignPlanDesc")}
>
<form class="space-y-4" onsubmit={assignPlan}>
{#if error}
<p class="text-sm text-destructive" role="alert">{error}</p>
{/if}
<div class="space-y-2">
<Label for="assign-company">{i18n.t("admin.billing.company")}</Label>
<Select id="assign-company" bind:value={assignCompanyId} required data-dialog-initial-focus>
<option value="">{i18n.t("admin.billing.selectCompany")}</option>
{#each companies as c}
<option value={c.id}>
{c.name}{c.has_active_plan === false ? i18n.t("admin.billing.noPlanSuffix") : ""}
</option>
{/each}
</Select>
</div>
<div class="space-y-2">
<Label for="assign-plan">{i18n.t("admin.billing.plan")}</Label>
<Select id="assign-plan" bind:value={assignPlanId} required>
<option value="">{i18n.t("admin.billing.selectPlan")}</option>
{#each plans as p}
<option value={String(p.id)}>{planOptionLabel(p)}</option>
{/each}
</Select>
</div>
<div class="flex items-start gap-3 rounded-md border border-border p-3">
<Checkbox id="assign-trial" bind:checked={assignIsTrial} />
<div class="space-y-1">
<Label for="assign-trial" class="cursor-pointer font-medium">{i18n.t("admin.billing.trialAssignment")}</Label>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.billing.trialHint")}
</p>
</div>
</div>
{#if assignIsTrial}
<div class="space-y-2">
<Label for="assign-trial-credits">{i18n.t("admin.billing.trialCredits")}</Label>
<Input id="assign-trial-credits" type="number" min="0" bind:value={assignTrialCredits} />
</div>
{/if}
<div class="flex flex-wrap justify-end gap-2 pt-1">
<Button type="button" variant="outline" disabled={busy} onclick={() => (assignOpen = false)}
>{i18n.t("common.cancel")}</Button
>
<Button type="submit" loading={busy}>{i18n.t("admin.billing.assign")}</Button>
</div>
</form>
</Dialog>
<Dialog bind:open={creditsOpen} title={i18n.t("admin.billing.creditsTitle")} description={i18n.t("admin.billing.creditsDesc")}>
<form class="space-y-4" onsubmit={addCredits}>
{#if error}
<p class="text-sm text-destructive" role="alert">{error}</p>
{/if}
<div class="space-y-2">
<Label for="credit-company">{i18n.t("admin.billing.company")}</Label>
<Select id="credit-company" bind:value={creditCompanyId} required data-dialog-initial-focus>
<option value="">{i18n.t("admin.billing.selectCompany")}</option>
{#each companies as c}
<option value={c.id}>{c.name}</option>
{/each}
</Select>
</div>
<div class="space-y-2">
<Label for="credit-amount">{i18n.t("admin.billing.amount")}</Label>
<Input id="credit-amount" type="number" bind:value={creditAmount} required />
<p class="text-xs text-muted-foreground">{i18n.t("admin.billing.amountHint")}</p>
</div>
<div class="flex flex-wrap justify-end gap-2 pt-1">
<Button type="button" variant="outline" disabled={busy} onclick={() => (creditsOpen = false)}
>{i18n.t("common.cancel")}</Button
>
<Button type="submit" loading={busy}>{i18n.t("admin.billing.apply")}</Button>
</div>
</form>
</Dialog>
@@ -0,0 +1,94 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { api, failureMessage, isForbidden, isUnauthorized } from "$lib/api";
import type { MeResponse } from "$lib/types";
import PageShell from "$lib/components/PageShell.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import {
Button,
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle
} from "$lib/components/ui";
import { i18n } from "$lib/i18n";
let loading = $state(true);
let accessDenied = $state(false);
let error = $state("");
let me = $state<MeResponse | null>(null);
onMount(async () => {
try {
me = await api<MeResponse>("/api/auth/me");
} catch (err) {
if (isUnauthorized(err)) {
await goto("/login");
return;
}
if (isForbidden(err)) {
accessDenied = true;
return;
}
error = failureMessage(err, i18n.t("admin.bootstrap.loadFailed"));
} finally {
loading = false;
}
});
</script>
<PageShell
title={i18n.t("admin.bootstrap.title")}
description={i18n.t("admin.bootstrap.description")}
>
{#if loading}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else}
<Card class="mx-auto max-w-md">
<CardHeader>
<CardTitle>{i18n.t("admin.bootstrap.statusTitle")}</CardTitle>
<CardDescription>
{i18n.t("admin.bootstrap.statusDesc")}
</CardDescription>
</CardHeader>
<CardContent>
{#if me?.user?.is_platform_admin}
<div class="rounded-md border border-green-500/30 bg-green-500/10 p-4 text-sm">
<p class="font-medium">{i18n.t("admin.bootstrap.alreadyAdmin")}</p>
<p class="mt-2 text-muted-foreground">
{i18n.t("admin.bootstrap.signedInAs", { email: me.user.email })}
</p>
</div>
{:else if error}
<div class="rounded-md border border-destructive/30 bg-destructive/10 p-4 text-sm">
<p>{error}</p>
</div>
{:else}
<ForbiddenEmptyState
kind="platform"
title={i18n.t("admin.bootstrap.notAdminTitle")}
message={i18n.t("admin.bootstrap.notAdminMsg")}
/>
{#if me?.user}
<p class="mt-3 text-center text-sm text-muted-foreground">{i18n.t("admin.bootstrap.signedInAsShort", { email: me.user.email })}</p>
{/if}
{/if}
</CardContent>
<CardFooter class="flex justify-between gap-2">
{#if me?.user?.is_platform_admin}
<Button class="w-full" onclick={() => goto("/admin")}>{i18n.t("admin.bootstrap.goToPanel")}</Button>
{:else}
<Button variant="outline" class="w-full" onclick={() => goto("/dashboard")}>
{i18n.t("admin.chrome.backToApp")}
</Button>
{/if}
</CardFooter>
</Card>
{/if}
</PageShell>
@@ -0,0 +1,740 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { failureMessage } from "$lib/api";
import { requirePlatformAdmin } from "$lib/admin-gate";
import { formatCredits, formatDate } from "$lib/utils";
import {
checkStatusVariant,
CONFIG_FLAG_LABELS,
isDiagnosticsRateLimited,
isDiagnosticsUnavailable,
isDiagnosticsUnreachable,
loadAdminDiagnostics,
type AdminDiagnostics,
type DiagCheck
} from "$lib/admin-diagnostics";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import {
Badge,
Button,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableShell,
type BadgeVariant
} from "$lib/components/ui";
import {
Activity,
ArrowRight,
CreditCard,
Database,
HardDrive,
Mail,
RefreshCw,
Server,
ShieldCheck,
Wrench
} from "@lucide/svelte";
const jobFilters = [
{ value: "failed", labelKey: "status.failed" },
{ value: "running", labelKey: "status.running" },
{ value: "pending", labelKey: "status.pending" },
{ value: "completed", labelKey: "status.completed" },
{ value: "cancelled", labelKey: "status.cancelled" }
] as const;
const tools = [
{
href: "/admin/stuck-products",
titleKey: "admin.diagnostics.toolStuckTitle",
descriptionKey: "admin.diagnostics.toolStuckDesc"
},
{
href: "/admin/orphan-processed",
titleKey: "admin.diagnostics.toolOrphanTitle",
descriptionKey: "admin.diagnostics.toolOrphanDesc"
},
{
href: "/admin/analytics",
titleKey: "admin.diagnostics.toolAnalyticsTitle",
descriptionKey: "admin.diagnostics.toolAnalyticsDesc"
},
{
href: "/admin/bootstrap",
titleKey: "admin.diagnostics.toolBootstrapTitle",
descriptionKey: "admin.diagnostics.toolBootstrapDesc"
},
{
href: "/admin/settings",
titleKey: "admin.diagnostics.toolSettingsTitle",
descriptionKey: "admin.diagnostics.toolSettingsDesc"
}
] as const;
let loading = $state(true);
let refreshing = $state(false);
let accessDenied = $state(false);
let unavailable = $state(false);
let error = $state("");
let statusFilter = $state("failed");
let data = $state<AdminDiagnostics | null>(null);
const overallVariant = $derived(checkStatusVariant(data?.status ?? "outline") as BadgeVariant);
const queueStatuses = $derived.by(() => {
const by = data?.queue?.by_status ?? {};
return Object.entries(by).sort(([a], [b]) => a.localeCompare(b));
});
const stuckCount = $derived(Number(data?.queue?.stuck_running ?? 0));
const queueTotal = $derived(Number(data?.queue?.total ?? 0));
const cutoverGooseRequired = $derived.by(() => {
const required = data?.cutover?.goose?.required ?? {};
return Object.entries(required).sort(([a], [b]) => a.localeCompare(b));
});
const cutoverPlansMissing = $derived(
data?.cutover && "companies_without_plan" in (data.cutover ?? {})
? Number(data.cutover?.companies_without_plan ?? 0)
: null
);
const cutoverApiKeysMissing = $derived(
data?.cutover && "companies_without_api_keys" in (data.cutover ?? {})
? Number(data.cutover?.companies_without_api_keys ?? 0)
: null
);
const migrationInv = $derived(data?.migration_inventory ?? null);
onMount(async () => {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
if (gate.reason === "auth") {
await goto("/login");
return;
}
if (gate.reason === "forbidden") {
accessDenied = true;
} else {
error = gate.message;
}
loading = false;
return;
}
await reload();
loading = false;
});
async function reload() {
refreshing = true;
error = "";
unavailable = false;
try {
data = await loadAdminDiagnostics({
status: statusFilter,
failuresLimit: 25
});
} catch (err) {
if (isDiagnosticsRateLimited(err)) {
error = i18n.t("flash.admin.diagnosticsRateLimit");
} else if (isDiagnosticsUnavailable(err)) {
unavailable = true;
error = i18n.t("flash.admin.diagnosticsUnavailable");
} else if (isDiagnosticsUnreachable(err)) {
error = i18n.t("flash.admin.diagnosticsUnreachable");
} else {
error = failureMessage(err, i18n.t("flash.admin.diagnosticsLoadFailed"));
}
data = null;
} finally {
refreshing = false;
}
}
function checkVariant(check: DiagCheck): BadgeVariant {
return checkStatusVariant(check.status);
}
function flagLabel(value: unknown): string {
if (value === true) return i18n.t("common.yes");
if (value === false) return i18n.t("common.no");
return i18n.t("status.emDash");
}
function checkIcon(name: string) {
switch (String(name).toLowerCase()) {
case "database":
return Database;
case "queue":
return Activity;
case "storage":
return HardDrive;
case "mail":
return Mail;
case "cache":
return Server;
case "stripe":
return CreditCard;
case "cutover":
return ShieldCheck;
default:
return Wrench;
}
}
function statusTone(status: string): string {
switch (String(status).toLowerCase()) {
case "ok":
case "ready":
return "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
case "warn":
case "degraded":
case "warning":
return "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:text-amber-200";
case "fail":
case "failed":
case "error":
return "border-destructive/40 bg-destructive/10 text-destructive";
default:
return "border-border bg-muted/40 text-muted-foreground";
}
}
</script>
<PageShell
eyebrow={i18n.t("admin.diagnostics.eyebrow")}
title={i18n.t("admin.diagnostics.title")}
description={i18n.t("admin.diagnostics.description")}
>
{#snippet actions()}
<div class="flex flex-wrap items-center gap-2" role="group" aria-label={i18n.t("admin.diagnostics.filterAria")}>
{#each jobFilters as filter}
<Button
size="sm"
variant={statusFilter === filter.value ? "default" : "outline"}
aria-pressed={statusFilter === filter.value}
onclick={() => {
statusFilter = filter.value;
void reload();
}}
>
{i18n.t(filter.labelKey)}
</Button>
{/each}
</div>
<Button variant="outline" size="sm" loading={refreshing} onclick={reload}>
<RefreshCw class="mr-2 h-4 w-4" />
{i18n.t("common.refresh")}
</Button>
{/snippet}
{#if loading}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else}
<div class="space-y-8">
<Alert message={error} />
{#if !data}
<section
aria-labelledby="diagnostics-empty-heading"
class="rounded-xl border border-dashed border-border bg-card/40 px-6 py-12 text-center"
data-admin-slot="diagnostics-panels"
>
<h2 id="diagnostics-empty-heading" class="text-base font-semibold text-foreground">
{unavailable
? i18n.t("admin.diagnostics.endpointNotMounted")
: i18n.t("admin.diagnostics.noPayload")}
</h2>
<p class="mx-auto mt-2 max-w-md text-sm text-muted-foreground">
{#if unavailable}
{i18n.t("admin.diagnostics.unavailableShort")}
{:else if error}
{i18n.t("admin.diagnostics.fixThenRefresh")}
{:else}
{i18n.t("admin.diagnostics.refreshOrConfirm")}
{/if}
</p>
</section>
{:else}
<!-- Status strip -->
<section
aria-labelledby="diagnostics-status-heading"
class="overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-sm"
data-admin-slot="diagnostics-panels"
>
<div
class="flex flex-wrap items-center justify-between gap-3 border-b border-border bg-muted/30 px-4 py-3 sm:px-5"
>
<div class="flex flex-wrap items-center gap-3">
<h2 id="diagnostics-status-heading" class="text-sm font-semibold tracking-wide">
{i18n.t("admin.diagnostics.systemStatus")}
</h2>
<span
class={`inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold uppercase tracking-wide ${statusTone(data.status)}`}
>
{data.status}
</span>
</div>
{#if data.generated_at}
<p class="text-xs text-muted-foreground">
{i18n.t("admin.diagnostics.snapshot", { when: formatDate(data.generated_at) })}
</p>
{/if}
</div>
<div class="grid gap-px bg-border sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
{#each data.checks ?? [] as check}
{@const Icon = checkIcon(check.name)}
<article class="bg-card p-4">
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<span
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-border bg-muted/50 text-muted-foreground"
aria-hidden="true"
>
<Icon class="h-4 w-4" />
</span>
<div class="min-w-0">
<h3 class="truncate text-sm font-medium capitalize text-foreground">
{check.name}
</h3>
{#if check.detail}
<p class="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
{check.detail}
</p>
{/if}
</div>
</div>
<Badge variant={checkVariant(check)}>{check.status}</Badge>
</div>
{#if check.latency_ms != null}
<p class="mt-3 text-[11px] tabular-nums text-muted-foreground">
{check.latency_ms} ms
</p>
{/if}
</article>
{/each}
</div>
</section>
{#if data.cutover}
<section
aria-labelledby="diagnostics-cutover-heading"
class="rounded-xl border border-border bg-card text-card-foreground shadow-sm"
data-testid="diagnostics-cutover"
>
<div class="border-b border-border px-4 py-3 sm:px-5">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 id="diagnostics-cutover-heading" class="text-sm font-semibold">
{i18n.t("admin.diagnostics.cutoverTitle")}
</h2>
{#if data.cutover.status}
<Badge variant={checkStatusVariant(data.cutover.status)}>{data.cutover.status}</Badge>
{/if}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.diagnostics.cutoverHint")}
</p>
</div>
<div class="grid gap-px bg-border sm:grid-cols-3">
<article class="bg-card p-4">
<h3 class="text-sm font-medium text-foreground">{i18n.t("admin.diagnostics.cutoverGoose")}</h3>
<p class="mt-1 text-xs text-muted-foreground">
{#if data.cutover.goose?.version_max != null}
{i18n.t("admin.diagnostics.cutoverGooseVersion", {
max: data.cutover.goose.version_max,
min: data.cutover.goose.expected_min ?? 41
})}
{:else}
{data.cutover.goose?.detail ?? i18n.t("status.emDash")}
{/if}
</p>
{#if cutoverGooseRequired.length > 0}
<ul class="mt-3 space-y-1.5" aria-label={i18n.t("admin.diagnostics.cutoverGooseRequired")}>
{#each cutoverGooseRequired as [name, applied]}
<li class="flex items-center justify-between gap-2 text-xs">
<span class="truncate font-mono text-muted-foreground">{name}</span>
<span
class={`shrink-0 rounded-md px-2 py-0.5 font-semibold ${
applied
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300"
: "bg-amber-500/15 text-amber-800 dark:text-amber-200"
}`}
>
{applied
? i18n.t("admin.diagnostics.cutoverApplied")
: i18n.t("admin.diagnostics.cutoverPending")}
</span>
</li>
{/each}
</ul>
{/if}
</article>
<article class="bg-card p-4">
<div class="flex items-start justify-between gap-2">
<h3 class="text-sm font-medium text-foreground">{i18n.t("admin.diagnostics.cutoverWorker")}</h3>
{#if data.cutover.worker?.status}
<Badge variant={checkStatusVariant(data.cutover.worker.status)}>
{data.cutover.worker.status}
</Badge>
{/if}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{#if data.cutover.worker?.last_seen_age_s != null}
{i18n.t("admin.diagnostics.cutoverWorkerAge", {
age: data.cutover.worker.last_seen_age_s,
stale: data.cutover.worker.stale_after_s ?? 60
})}
{:else}
{i18n.t("admin.diagnostics.cutoverWorkerAgeUnknown", {
stale: data.cutover.worker?.stale_after_s ?? 60
})}
{/if}
</p>
</article>
<article class="bg-card p-4">
<h3 class="text-sm font-medium text-foreground">
{i18n.t("admin.diagnostics.cutoverPlansMissing")}
</h3>
{#if cutoverPlansMissing != null}
<p class="mt-2 text-2xl font-semibold tabular-nums text-foreground">
{formatCredits(cutoverPlansMissing)}
</p>
{:else}
<p class="mt-2 text-sm text-muted-foreground">
{i18n.t("admin.diagnostics.cutoverPlansUnavailable")}
</p>
{/if}
</article>
<article class="bg-card p-4" data-testid="diagnostics-cutover-api-keys">
<h3 class="text-sm font-medium text-foreground">
{i18n.t("admin.diagnostics.cutoverApiKeysMissing")}
</h3>
{#if cutoverApiKeysMissing != null}
<p class="mt-2 text-2xl font-semibold tabular-nums text-foreground">
{formatCredits(cutoverApiKeysMissing)}
</p>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.diagnostics.cutoverApiKeysHint")}
</p>
<a
class="mt-2 inline-block text-sm underline-offset-2 hover:underline"
href="/admin/users?tab=companies&without_api_keys=1"
>
{i18n.t("admin.diagnostics.cutoverApiKeysLink")}
</a>
{:else}
<p class="mt-2 text-sm text-muted-foreground">
{i18n.t("admin.diagnostics.cutoverApiKeysUnavailable")}
</p>
{/if}
</article>
</div>
</section>
{/if}
{#if migrationInv}
<section
aria-labelledby="diagnostics-etl-inventory-heading"
class="rounded-xl border border-border bg-card text-card-foreground shadow-sm"
data-testid="diagnostics-migration-inventory"
>
<div class="border-b border-border px-4 py-3 sm:px-5">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 id="diagnostics-etl-inventory-heading" class="text-sm font-semibold">
{i18n.t("admin.diagnostics.etlInventoryTitle")}
</h2>
{#if migrationInv.status}
<Badge variant={checkStatusVariant(migrationInv.status)}>{migrationInv.status}</Badge>
{/if}
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.diagnostics.etlInventoryHint")}
</p>
</div>
<div class="grid gap-px bg-border sm:grid-cols-2 lg:grid-cols-3">
<article class="bg-card p-4">
<h3 class="text-sm font-medium text-foreground">
{i18n.t("admin.diagnostics.etlInventoryFiles")}
</h3>
<p class="mt-2 text-2xl font-semibold tabular-nums text-foreground">
{formatCredits(Number(migrationInv.files_metadata_only ?? 0))}
<span class="text-sm font-normal text-muted-foreground">
/ {formatCredits(Number(migrationInv.files_total ?? 0))}
</span>
</p>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.diagnostics.etlInventoryFilesDetail")}
</p>
</article>
<article class="bg-card p-4">
<h3 class="text-sm font-medium text-foreground">
{i18n.t("admin.diagnostics.etlInventoryJobs")}
</h3>
<p class="mt-2 text-2xl font-semibold tabular-nums text-foreground">
{formatCredits(Number(migrationInv.processing_jobs_migrated ?? 0))}
<span class="text-sm font-normal text-muted-foreground">
/ {formatCredits(Number(migrationInv.processing_jobs_total ?? 0))}
</span>
</p>
<p class="mt-1 text-xs text-muted-foreground">
{migrationInv.jobs_domain_ran
? i18n.t("admin.diagnostics.etlInventoryJobsRan")
: i18n.t("admin.diagnostics.etlInventoryJobsDefault")}
</p>
</article>
<article class="bg-card p-4">
<h3 class="text-sm font-medium text-foreground">
{i18n.t("admin.diagnostics.etlInventoryTasks")}
</h3>
<p class="mt-2 text-2xl font-semibold tabular-nums text-foreground">
{formatCredits(Number(migrationInv.tasks_total ?? 0))}
</p>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.diagnostics.etlInventoryTasksDetail")}
</p>
</article>
</div>
</section>
{/if}
<div class="grid gap-6 lg:grid-cols-5">
<!-- Queue -->
<section
aria-labelledby="diagnostics-queue-heading"
class="lg:col-span-2 rounded-xl border border-border bg-card text-card-foreground shadow-sm"
>
<div class="border-b border-border px-4 py-3 sm:px-5">
<div class="flex items-center justify-between gap-2">
<h2 id="diagnostics-queue-heading" class="text-sm font-semibold">{i18n.t("admin.diagnostics.queue")}</h2>
<span class="text-xs tabular-nums text-muted-foreground">
{i18n.t("admin.diagnostics.queueTotal", { total: formatCredits(queueTotal) })}
</span>
</div>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.diagnostics.queueStuck")}
<span
class={`ml-1 font-medium tabular-nums ${stuckCount > 0 ? "text-chart-amber" : "text-foreground"}`}
>
{formatCredits(stuckCount)}
</span>
</p>
</div>
<div class="p-4 sm:p-5">
{#if queueStatuses.length === 0}
<p class="text-sm text-muted-foreground">{i18n.t("admin.diagnostics.noJobsInQueue")}</p>
{:else}
<ul class="space-y-2">
{#each queueStatuses as [status, count]}
<li
class="flex items-center justify-between gap-3 rounded-lg border border-border/70 bg-muted/20 px-3 py-2 text-sm"
>
<span class="capitalize text-muted-foreground">{status}</span>
<span class="tabular-nums font-semibold text-foreground"
>{formatCredits(Number(count))}</span
>
</li>
{/each}
</ul>
{/if}
<p class="mt-4 text-[11px] text-muted-foreground">
{i18n.t("admin.diagnostics.driver", {
driver: data.queue?.driver ?? "postgres_processing_jobs"
})}
</p>
<a
href="/admin/stuck-products"
class="mt-3 inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{i18n.t("admin.diagnostics.openStuck")}
<ArrowRight class="h-3.5 w-3.5" />
</a>
</div>
</section>
<!-- Config flags -->
<section
aria-labelledby="diagnostics-config-heading"
class="lg:col-span-3 rounded-xl border border-border bg-card text-card-foreground shadow-sm"
>
<div class="border-b border-border px-4 py-3 sm:px-5">
<h2 id="diagnostics-config-heading" class="text-sm font-semibold">
{i18n.t("admin.diagnostics.configPresence")}
</h2>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.diagnostics.configHint", {
env: data.config?.app_env ?? i18n.t("status.emDash"),
rpm: data.config?.processing_rpm ?? i18n.t("status.emDash"),
batch: data.config?.processing_batch_size ?? i18n.t("status.emDash"),
retries: data.config?.processing_max_retries ?? i18n.t("status.emDash")
})}
</p>
</div>
<ul class="grid gap-2 p-4 sm:grid-cols-2 sm:p-5">
{#each CONFIG_FLAG_LABELS as row}
<li
class="flex items-center justify-between gap-3 rounded-lg border border-border/70 bg-muted/15 px-3 py-2 text-sm"
>
<span class="text-muted-foreground">{i18n.t(row.labelKey)}</span>
<span
class={`shrink-0 rounded-md px-2 py-0.5 text-xs font-semibold tabular-nums ${
data.config?.[row.key] === true
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300"
: data.config?.[row.key] === false
? "bg-muted text-muted-foreground"
: "bg-muted text-muted-foreground"
}`}
>
{flagLabel(data.config?.[row.key])}
</span>
</li>
{/each}
</ul>
</section>
</div>
<!-- Jobs -->
<section
aria-labelledby="diagnostics-jobs-heading"
class="rounded-xl border border-border bg-card text-card-foreground shadow-sm"
>
<div class="border-b border-border px-4 py-3 sm:px-5">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 id="diagnostics-jobs-heading" class="text-sm font-semibold">
{i18n.t("admin.diagnostics.recentJobs")}
<span class="ml-1 font-normal text-muted-foreground">({statusFilter})</span>
</h2>
<p class="text-xs text-muted-foreground">{i18n.t("admin.diagnostics.sanitizedErrors")}</p>
</div>
</div>
<div class="p-2 sm:p-4">
{#if (data.recent_failures ?? []).length === 0}
<EmptyState message={i18n.t("empty.admin.noJobsFilter")} />
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.diagnostics.colId")}</TableHead>
<TableHead>{i18n.t("admin.diagnostics.colCompany")}</TableHead>
<TableHead>{i18n.t("admin.diagnostics.colStatus")}</TableHead>
<TableHead>{i18n.t("admin.diagnostics.colProgress")}</TableHead>
<TableHead>{i18n.t("admin.diagnostics.colUpdated")}</TableHead>
<TableHead>{i18n.t("admin.diagnostics.colError")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each data.recent_failures as job}
<TableRow>
<TableCell class="font-mono text-xs text-muted-foreground"
>{job.id}</TableCell
>
<TableCell class="font-mono text-xs"
>{job.company_id || i18n.t("status.emDash")}</TableCell
>
<TableCell>
<Badge variant={checkStatusVariant(job.status)}>{job.status}</Badge>
</TableCell>
<TableCell class="tabular-nums">
{formatCredits(Number(job.processed_products ?? 0))}/{formatCredits(
Number(job.total_products ?? 0)
)}
</TableCell>
<TableCell>{formatDate(job.updated_at || job.created_at)}</TableCell>
<TableCell class="max-w-xs truncate text-sm text-destructive">
{job.error || i18n.t("status.emDash")}
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
{/if}
</div>
</section>
{#if (data.recent_ai_failures ?? []).length > 0}
<section
aria-labelledby="diagnostics-ai-heading"
class="rounded-xl border border-border bg-card text-card-foreground shadow-sm"
>
<div class="border-b border-border px-4 py-3 sm:px-5">
<h2 id="diagnostics-ai-heading" class="text-sm font-semibold">
{i18n.t("admin.diagnostics.recentAiFailures")}
</h2>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("admin.diagnostics.aiFailuresHint")}
</p>
</div>
<div class="p-2 sm:p-4">
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.diagnostics.colTicket")}</TableHead>
<TableHead>{i18n.t("admin.diagnostics.colCompany")}</TableHead>
<TableHead>{i18n.t("admin.diagnostics.colWhen")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each data.recent_ai_failures ?? [] as row}
<TableRow>
<TableCell class="font-mono text-xs">{row.ticket_id}</TableCell>
<TableCell class="font-mono text-xs">{row.company_id}</TableCell>
<TableCell>{formatDate(row.created_at)}</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
</div>
</section>
{/if}
{#if (data.notes ?? []).length > 0}
<ul class="list-disc space-y-1 pl-5 text-xs text-muted-foreground">
{#each data.notes ?? [] as note}
<li>{note}</li>
{/each}
</ul>
{/if}
{/if}
<section aria-labelledby="diagnostics-tools-heading" class="space-y-3">
<h2 id="diagnostics-tools-heading" class="text-sm font-semibold text-foreground">
{i18n.t("admin.diagnostics.relatedTools")}
</h2>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{#each tools as tool}
<a
href={tool.href}
class="group flex flex-col rounded-xl border border-border bg-card p-4 text-card-foreground transition hover:border-primary/40 hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span class="text-sm font-semibold group-hover:text-primary"
>{i18n.t(tool.titleKey)}</span
>
<span class="mt-1 flex-1 text-xs text-muted-foreground"
>{i18n.t(tool.descriptionKey)}</span
>
<span
class="mt-3 inline-flex items-center gap-1 text-xs font-medium text-muted-foreground group-hover:text-primary"
>
{i18n.t("common.open")}
<ArrowRight class="h-3 w-3" />
</span>
</a>
{/each}
</div>
</section>
</div>
{/if}
</PageShell>
@@ -0,0 +1,12 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { i18n } from "$lib/i18n";
// Prefer Diagnostics (health + queue + failures). Keep this URL as a redirect.
onMount(() => {
void goto("/admin/diagnostics", { replaceState: true });
});
</script>
<p class="p-6 text-sm text-muted-foreground">{i18n.t("admin.redirect.diagnostics")}</p>
@@ -0,0 +1,71 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { requirePlatformAdmin } from "$lib/admin-gate";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import {
Button,
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle
} from "$lib/components/ui";
import { i18n } from "$lib/i18n";
let loading = $state(true);
let accessDenied = $state(false);
let error = $state("");
onMount(async () => {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
if (gate.reason === "auth") {
await goto("/login");
return;
}
if (gate.reason === "forbidden") {
accessDenied = true;
} else {
error = gate.message;
}
}
loading = false;
});
</script>
<PageShell
title={i18n.t("admin.migrate.title")}
description={i18n.t("admin.migrate.description")}
>
{#if loading}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else}
<div class="space-y-4">
<Alert message={error} />
<Card>
<CardHeader>
<CardTitle>{i18n.t("admin.migrate.notAvailable")}</CardTitle>
<CardDescription>
{i18n.t("admin.migrate.body")}
</CardDescription>
</CardHeader>
<CardContent class="text-sm text-muted-foreground">
<p>
{i18n.t("admin.migrate.hint")}
</p>
</CardContent>
<CardFooter class="flex flex-wrap gap-2">
<a href="/admin"><Button variant="outline" size="sm">{i18n.t("admin.migrate.adminHome")}</Button></a>
<a href="/admin/users"><Button variant="outline" size="sm">{i18n.t("admin.migrate.users")}</Button></a>
</CardFooter>
</Card>
</div>
{/if}
</PageShell>
@@ -0,0 +1,273 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { api, failureMessage } from "$lib/api";
import { requirePlatformAdmin } from "$lib/admin-gate";
import {
ORPHAN_CLEANUP_API,
ORPHAN_REPORT_API,
canConfirmOrphanDelete,
normalizeOrphanReport,
orphanCleanupBody,
orphanReasonLabelKey,
parseOrphanCleanupResponse,
type OrphanProcessedReport
} from "$lib/admin-orphan-processed";
import { formatCredits } from "$lib/utils";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import ConfirmationDialog from "$lib/components/categories/formula/ConfirmationDialog.svelte";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableShell
} from "$lib/components/ui";
import { RefreshCw } from "@lucide/svelte";
let loading = $state(true);
let accessDenied = $state(false);
let refreshing = $state(false);
let busy = $state(false);
let error = $state("");
let success = $state("");
let report = $state<OrphanProcessedReport | null>(null);
let confirmOpen = $state(false);
const canDelete = $derived(canConfirmOrphanDelete({ report, busy: busy || refreshing }));
onMount(async () => {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
if (gate.reason === "auth") {
await goto("/login");
return;
}
if (gate.reason === "forbidden") {
accessDenied = true;
} else {
error = gate.message;
}
loading = false;
return;
}
await reloadReport();
loading = false;
});
async function reloadReport() {
refreshing = true;
error = "";
try {
const data = await api<unknown>(ORPHAN_REPORT_API);
report = normalizeOrphanReport(data);
} catch (err) {
error = failureMessage(err, i18n.t("admin.orphan.loadFailed"));
report = null;
} finally {
refreshing = false;
}
}
async function runDryRunCleanup() {
busy = true;
error = "";
success = "";
try {
const data = await api<unknown>(ORPHAN_CLEANUP_API, {
method: "POST",
body: orphanCleanupBody(false)
});
const out = parseOrphanCleanupResponse(data);
report = out.report;
success = i18n.t("flash.admin.orphanDryRun", { total: out.report.total });
} catch (err) {
error = failureMessage(err, i18n.t("admin.orphan.dryRunFailed"));
} finally {
busy = false;
}
}
function openConfirm() {
if (!canDelete) return;
confirmOpen = true;
}
async function confirmDelete() {
confirmOpen = false;
if (!canConfirmOrphanDelete({ report, busy: false })) return;
busy = true;
error = "";
success = "";
try {
const data = await api<unknown>(ORPHAN_CLEANUP_API, {
method: "POST",
body: orphanCleanupBody(true)
});
const out = parseOrphanCleanupResponse(data);
report = out.report;
success = i18n.t("flash.admin.orphanDeleted", {
deleted: out.deleted,
remaining: out.report.total
});
} catch (err) {
error = failureMessage(err, i18n.t("admin.orphan.deleteFailed"));
} finally {
busy = false;
}
}
</script>
<PageShell
title={i18n.t("admin.orphan.title")}
description={i18n.t("admin.orphan.description")}
>
{#snippet actions()}
<Button variant="outline" size="sm" loading={refreshing} disabled={busy} onclick={reloadReport}>
<RefreshCw class="mr-2 h-4 w-4" />
{i18n.t("admin.orphan.refreshReport")}
</Button>
<Button
variant="secondary"
size="sm"
loading={busy}
disabled={refreshing}
onclick={runDryRunCleanup}
>
{i18n.t("admin.orphan.dryRunBtn")}
</Button>
<Button
variant="destructive"
size="sm"
disabled={!canDelete}
loading={busy}
onclick={openConfirm}
>
{i18n.t("admin.orphan.deleteBtn")}
</Button>
{/snippet}
{#if loading}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else}
<Alert message={error} />
<Alert tone="success" message={success} />
<p class="mb-4 text-sm text-muted-foreground">{i18n.t("admin.orphan.cutoverHint")}</p>
<div class="mb-4 grid gap-3 sm:grid-cols-3">
<Card>
<CardHeader class="pb-2">
<CardDescription>{i18n.t("admin.orphan.metric.total")}</CardDescription>
<CardTitle class="tabular-nums text-2xl">
{formatCredits(Number(report?.total ?? 0))}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader class="pb-2">
<CardDescription>{i18n.t("admin.orphan.metric.missingRaw")}</CardDescription>
<CardTitle class="tabular-nums text-2xl">
{formatCredits(Number(report?.missing_raw ?? 0))}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader class="pb-2">
<CardDescription>{i18n.t("admin.orphan.metric.unprocessedRaw")}</CardDescription>
<CardTitle class="tabular-nums text-2xl">
{formatCredits(Number(report?.unprocessed_raw ?? 0))}
</CardTitle>
</CardHeader>
</Card>
</div>
<Card>
<CardHeader>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle>{i18n.t("admin.orphan.samplesTitle")}</CardTitle>
<CardDescription>{i18n.t("admin.orphan.samplesDesc")}</CardDescription>
</div>
{#if report && report.total === 0}
<Badge variant="success">{i18n.t("admin.orphan.cleanBadge")}</Badge>
{:else if report && report.total > 0}
<Badge variant="warning">{i18n.t("admin.orphan.needsCleanupBadge")}</Badge>
{/if}
</div>
</CardHeader>
<CardContent>
{#if !report || report.samples.length === 0}
<EmptyState
message={report && report.total === 0
? i18n.t("admin.orphan.noneFound")
: i18n.t("admin.orphan.noSamples")}
/>
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.orphan.colProcessed")}</TableHead>
<TableHead>{i18n.t("admin.orphan.colCompany")}</TableHead>
<TableHead>{i18n.t("admin.orphan.colRaw")}</TableHead>
<TableHead>{i18n.t("admin.orphan.colReason")}</TableHead>
<TableHead>{i18n.t("admin.orphan.colRawStatus")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each report.samples as sample}
<TableRow>
<TableCell class="font-mono text-xs text-muted-foreground">
{sample.processed_id || "—"}
</TableCell>
<TableCell class="font-mono text-xs">{sample.company_id || "—"}</TableCell>
<TableCell class="font-mono text-xs">
{sample.raw_product_id || "—"}
</TableCell>
<TableCell>
<Badge variant="outline">{i18n.t(orphanReasonLabelKey(sample.reason))}</Badge>
</TableCell>
<TableCell class="text-sm text-muted-foreground">
{sample.raw_processing_status ||
(sample.raw_is_processed == null
? "—"
: sample.raw_is_processed
? i18n.t("admin.orphan.rawProcessedYes")
: i18n.t("admin.orphan.rawProcessedNo"))}
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
{/if}
</CardContent>
</Card>
{/if}
</PageShell>
<ConfirmationDialog
bind:open={confirmOpen}
title={i18n.t("admin.orphan.confirmTitle")}
description={i18n.t("admin.orphan.confirmDesc", { total: report?.total ?? 0 })}
confirmLabel={i18n.t("admin.orphan.confirmDelete")}
confirmAriaLabel={i18n.t("admin.orphan.confirmDelete")}
onClose={() => {
confirmOpen = false;
}}
onConfirm={() => void confirmDelete()}
/>
@@ -0,0 +1,187 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { failureMessage } from "$lib/api";
import { requirePlatformAdmin } from "$lib/admin-gate";
import { formatDateTime } from "$lib/utils";
import { listAdminSalesLeads, type SalesLead } from "$lib/sales-contact";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableShell,
type BadgeVariant
} from "$lib/components/ui";
import { RefreshCw, Search } from "@lucide/svelte";
const STATUS_FILTERS = $derived([
{ value: "all", label: i18n.t("admin.sales.statusAll") },
{ value: "new", label: i18n.t("admin.sales.status.new") },
{ value: "contacted", label: i18n.t("admin.sales.status.contacted") },
{ value: "quoted", label: i18n.t("admin.sales.status.quoted") },
{ value: "won", label: i18n.t("admin.sales.status.won") },
{ value: "closed", label: i18n.t("admin.sales.status.closed") }
] as const);
let loading = $state(true);
let accessDenied = $state(false);
let refreshing = $state(false);
let error = $state("");
let leads = $state<SalesLead[]>([]);
let total = $state(0);
let search = $state("");
let statusFilter = $state("new");
function statusVariant(status: string): BadgeVariant {
switch (status) {
case "new":
return "default";
case "quoted":
return "secondary";
case "won":
return "success";
case "closed":
return "outline";
default:
return "secondary";
}
}
async function load(opts?: { quiet?: boolean }) {
if (!opts?.quiet) loading = true;
else refreshing = true;
error = "";
try {
const res = await listAdminSalesLeads({
status: statusFilter,
q: search.trim() || undefined,
limit: 50
});
leads = res.leads ?? [];
total = res.total ?? 0;
} catch (err) {
error = failureMessage(err, i18n.t("admin.sales.loadError"));
} finally {
loading = false;
refreshing = false;
}
}
onMount(async () => {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
accessDenied = true;
loading = false;
return;
}
await load();
});
</script>
{#if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else}
<PageShell title={i18n.t("admin.sales.title")} description={i18n.t("admin.sales.description")}>
{#if error}
<Alert message={error} />
{/if}
<Card>
<CardHeader class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<CardTitle>{i18n.t("admin.sales.listTitle")}</CardTitle>
<CardDescription>{i18n.t("admin.sales.listDesc", { total })}</CardDescription>
</div>
<div class="flex flex-wrap items-center gap-2">
<div class="relative">
<Search class="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
class="pl-8 w-56"
placeholder={i18n.t("admin.sales.searchPlaceholder")}
bind:value={search}
onkeydown={(e) => {
if (e.key === "Enter") void load({ quiet: true });
}}
/>
</div>
<select
class="h-10 rounded-md border border-border bg-background px-3 text-sm"
bind:value={statusFilter}
onchange={() => void load({ quiet: true })}
>
{#each STATUS_FILTERS as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
<Button
variant="outline"
size="sm"
disabled={refreshing}
onclick={() => void load({ quiet: true })}
>
<RefreshCw class={`mr-1 h-4 w-4 ${refreshing ? "animate-spin" : ""}`} />
{i18n.t("common.refresh")}
</Button>
</div>
</CardHeader>
<CardContent>
{#if loading}
<div class="flex justify-center py-12"><Spinner /></div>
{:else if leads.length === 0}
<EmptyState title={i18n.t("admin.sales.empty")} />
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.sales.col.name")}</TableHead>
<TableHead>{i18n.t("admin.sales.col.email")}</TableHead>
<TableHead>{i18n.t("admin.sales.col.company")}</TableHead>
<TableHead>{i18n.t("admin.sales.col.status")}</TableHead>
<TableHead>{i18n.t("admin.sales.col.created")}</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each leads as lead}
<TableRow>
<TableCell class="font-medium">{lead.name}</TableCell>
<TableCell>{lead.email}</TableCell>
<TableCell>{lead.company_name || "—"}</TableCell>
<TableCell>
<Badge variant={statusVariant(lead.status)}>{lead.status}</Badge>
</TableCell>
<TableCell class="text-muted-foreground">{formatDateTime(lead.created_at)}</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
onclick={() => goto(`/admin/sales/${lead.id}`)}
>
{i18n.t("common.open")}
</Button>
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
{/if}
</CardContent>
</Card>
</PageShell>
{/if}
@@ -0,0 +1,351 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { page } from "$app/state";
import { failureMessage } from "$lib/api";
import { requirePlatformAdmin } from "$lib/admin-gate";
import { formatDateTime } from "$lib/utils";
import { fetchAdminCompaniesList, type AdminBillingCompany } from "$lib/admin-billing-plans";
import {
createAdminSalesQuote,
getAdminSalesLead,
markAdminSalesQuoteSent,
prepareAdminSalesQuoteCheckout,
updateAdminSalesLead,
type SalesLead,
type SalesQuote
} from "$lib/sales-contact";
import { notifySuccess, notifyApiError } from "$lib/notify";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
Select,
Textarea
} from "$lib/components/ui";
let loading = $state(true);
let accessDenied = $state(false);
let error = $state("");
let lead = $state<SalesLead | null>(null);
let quotes = $state<SalesQuote[]>([]);
let companies = $state<AdminBillingCompany[]>([]);
let saving = $state(false);
let quoteBusy = $state(false);
let status = $state("new");
let companyId = $state("");
let adminNotes = $state("");
let planName = $state("");
let monthlyCredits = $state("5000");
let maxProducts = $state("");
let totalUsd = $state("1200");
let installmentCount = $state("1");
let installmentInterval = $state("month");
let termMonths = $state("12");
let prepareCheckout = $state(true);
const leadId = $derived(page.params.id ?? "");
async function load() {
loading = true;
error = "";
try {
const [detail, companyList] = await Promise.all([
getAdminSalesLead(leadId),
fetchAdminCompaniesList()
]);
lead = detail.lead;
quotes = detail.quotes ?? [];
companies = companyList;
status = detail.lead.status;
companyId = detail.lead.company_id ?? "";
adminNotes = detail.lead.admin_notes ?? "";
if (!planName && detail.lead.company_name) {
planName = `${detail.lead.company_name} Deal`;
}
} catch (err) {
error = failureMessage(err, i18n.t("admin.sales.loadError"));
} finally {
loading = false;
}
}
async function saveLead() {
if (!lead) return;
saving = true;
try {
lead = await updateAdminSalesLead(lead.id, {
status,
company_id: companyId || undefined,
clear_company: !companyId,
admin_notes: adminNotes
});
notifySuccess(i18n.t("admin.sales.saved"));
} catch (err) {
notifyApiError(err, i18n.t("admin.sales.saveError"));
} finally {
saving = false;
}
}
async function createQuote() {
if (!lead || !companyId) {
error = i18n.t("admin.sales.companyRequired");
return;
}
quoteBusy = true;
error = "";
try {
const totalCents = Math.round(Number.parseFloat(totalUsd) * 100);
const count = Number.parseInt(installmentCount, 10) || 1;
const credits = Number.parseInt(monthlyCredits, 10) || 0;
const maxP = maxProducts.trim() ? Number.parseInt(maxProducts, 10) : null;
const term = termMonths.trim() ? Number.parseInt(termMonths, 10) : null;
const quote = await createAdminSalesQuote(lead.id, {
company_id: companyId,
plan_name: planName.trim() || "Custom Deal",
monthly_credits: credits,
max_products: maxP != null && Number.isFinite(maxP) ? maxP : null,
currency: "usd",
total_amount_cents: totalCents,
installment_count: count,
installment_interval: installmentInterval,
term_months: term != null && Number.isFinite(term) ? term : null,
prepare_checkout: prepareCheckout
});
quotes = [quote, ...quotes];
notifySuccess(i18n.t("admin.sales.quoteCreated"));
await load();
} catch (err) {
error = failureMessage(err, i18n.t("admin.sales.quoteError"));
notifyApiError(err, i18n.t("admin.sales.quoteError"));
} finally {
quoteBusy = false;
}
}
async function prepareCheckoutFor(quote: SalesQuote) {
quoteBusy = true;
try {
const updated = await prepareAdminSalesQuoteCheckout(quote.id);
quotes = quotes.map((q) => (q.id === updated.id ? updated : q));
notifySuccess(i18n.t("admin.sales.checkoutReady"));
} catch (err) {
notifyApiError(err, i18n.t("admin.sales.checkoutError"));
} finally {
quoteBusy = false;
}
}
async function markSent(quote: SalesQuote) {
try {
const updated = await markAdminSalesQuoteSent(quote.id);
quotes = quotes.map((q) => (q.id === updated.id ? updated : q));
notifySuccess(i18n.t("admin.sales.markedSent"));
} catch (err) {
notifyApiError(err, i18n.t("admin.sales.saveError"));
}
}
function money(cents: number, currency: string): string {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: (currency || "usd").toUpperCase()
}).format(cents / 100);
}
onMount(async () => {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
accessDenied = true;
loading = false;
return;
}
await load();
});
</script>
{#if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else if loading}
<div class="flex justify-center py-24"><Spinner /></div>
{:else if !lead}
<PageShell title={i18n.t("admin.sales.title")}>
<Alert message={error || i18n.t("admin.sales.notFound")} />
</PageShell>
{:else}
<PageShell title={lead.name} description={lead.email}>
{#if error}
<Alert message={error} />
{/if}
<div class="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>{i18n.t("admin.sales.leadTitle")}</CardTitle>
<CardDescription>{formatDateTime(lead.created_at)} · {lead.source}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<p class="whitespace-pre-wrap text-sm text-text">{lead.message}</p>
{#if lead.estimated_skus != null}
<p class="text-sm text-muted-foreground">
{i18n.t("admin.sales.estimatedSkus")}: {lead.estimated_skus.toLocaleString()}
</p>
{/if}
<div class="grid gap-3 sm:grid-cols-2">
<div class="space-y-2">
<Label for="lead-status">{i18n.t("admin.sales.col.status")}</Label>
<Select id="lead-status" bind:value={status}>
<option value="new">{i18n.t("admin.sales.status.new")}</option>
<option value="contacted">{i18n.t("admin.sales.status.contacted")}</option>
<option value="quoted">{i18n.t("admin.sales.status.quoted")}</option>
<option value="won">{i18n.t("admin.sales.status.won")}</option>
<option value="closed">{i18n.t("admin.sales.status.closed")}</option>
</Select>
</div>
<div class="space-y-2">
<Label for="lead-company">{i18n.t("admin.sales.linkCompany")}</Label>
<Select id="lead-company" bind:value={companyId}>
<option value="">{i18n.t("admin.sales.noCompany")}</option>
{#each companies as c}
<option value={c.id}>{c.name}</option>
{/each}
</Select>
</div>
</div>
<div class="space-y-2">
<Label for="lead-notes">{i18n.t("admin.sales.adminNotes")}</Label>
<Textarea id="lead-notes" bind:value={adminNotes} rows={4} />
</div>
<Button disabled={saving} onclick={() => void saveLead()}>
{saving ? i18n.t("common.saving") : i18n.t("common.save")}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{i18n.t("admin.sales.prepareQuote")}</CardTitle>
<CardDescription>{i18n.t("admin.sales.prepareQuoteDesc")}</CardDescription>
</CardHeader>
<CardContent class="space-y-3">
<div class="space-y-2">
<Label for="q-plan">{i18n.t("admin.sales.planName")}</Label>
<Input id="q-plan" bind:value={planName} maxlength={120} />
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="space-y-2">
<Label for="q-credits">{i18n.t("admin.sales.monthlyCredits")}</Label>
<Input id="q-credits" bind:value={monthlyCredits} inputmode="numeric" />
</div>
<div class="space-y-2">
<Label for="q-skus">{i18n.t("admin.sales.maxProducts")}</Label>
<Input id="q-skus" bind:value={maxProducts} inputmode="numeric" placeholder={i18n.t("admin.sales.unlimitedHint")} />
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="space-y-2">
<Label for="q-total">{i18n.t("admin.sales.totalUsd")}</Label>
<Input id="q-total" bind:value={totalUsd} inputmode="decimal" />
</div>
<div class="space-y-2">
<Label for="q-term">{i18n.t("admin.sales.termMonths")}</Label>
<Input id="q-term" bind:value={termMonths} inputmode="numeric" />
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="space-y-2">
<Label for="q-count">{i18n.t("admin.sales.installmentCount")}</Label>
<Input id="q-count" bind:value={installmentCount} inputmode="numeric" />
</div>
<div class="space-y-2">
<Label for="q-interval">{i18n.t("admin.sales.installmentInterval")}</Label>
<Select id="q-interval" bind:value={installmentInterval}>
<option value="month">{i18n.t("admin.sales.interval.month")}</option>
<option value="quarter">{i18n.t("admin.sales.interval.quarter")}</option>
<option value="year">{i18n.t("admin.sales.interval.year")}</option>
</Select>
</div>
</div>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" bind:checked={prepareCheckout} />
{i18n.t("admin.sales.prepareCheckoutNow")}
</label>
<Button disabled={quoteBusy || !companyId} onclick={() => void createQuote()}>
{quoteBusy ? i18n.t("common.saving") : i18n.t("admin.sales.createQuote")}
</Button>
</CardContent>
</Card>
</div>
<Card class="mt-6">
<CardHeader>
<CardTitle>{i18n.t("admin.sales.quotesTitle")}</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
{#if quotes.length === 0}
<p class="text-sm text-muted-foreground">{i18n.t("admin.sales.noQuotes")}</p>
{:else}
{#each quotes as quote}
<div class="rounded-lg border border-border p-4 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium">{quote.plan_name}</span>
<Badge>{quote.status}</Badge>
<span class="text-sm text-muted-foreground">
{money(quote.total_amount_cents, quote.currency)}
· {quote.installment_count}× {money(quote.installment_amount_cents, quote.currency)}
/ {quote.installment_interval}
</span>
</div>
<p class="text-sm text-muted-foreground">
{i18n.t("admin.sales.monthlyCredits")}: {quote.monthly_credits.toLocaleString()}
{#if quote.max_products != null}
· SKUs {quote.max_products.toLocaleString()}
{/if}
</p>
{#if quote.checkout_url}
<a
href={quote.checkout_url}
target="_blank"
rel="noopener noreferrer"
class="text-sm font-medium text-link underline-offset-4 hover:underline break-all"
>
{quote.checkout_url}
</a>
{/if}
<div class="flex flex-wrap gap-2 pt-1">
{#if quote.status === "draft" || (!quote.checkout_url && quote.status !== "paid" && quote.status !== "canceled")}
<Button
size="sm"
variant="outline"
disabled={quoteBusy}
onclick={() => void prepareCheckoutFor(quote)}
>
{i18n.t("admin.sales.prepareCheckout")}
</Button>
{/if}
{#if quote.checkout_url && quote.status !== "paid"}
<Button size="sm" variant="outline" onclick={() => void markSent(quote)}>
{i18n.t("admin.sales.markSent")}
</Button>
{/if}
</div>
</div>
{/each}
{/if}
</CardContent>
</Card>
</PageShell>
{/if}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,184 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { api, failureMessage } from "$lib/api";
import { requirePlatformAdmin } from "$lib/admin-gate";
import {
STORE_RECONNECT_NEEDED_API,
normalizeStoreReconnectInventory,
storeReconnectChannelLabelKey,
storeReconnectReasonLabelKey,
type AdminStoreReconnectInventory
} from "$lib/admin-store-reconnect";
import { formatCredits } from "$lib/utils";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableShell
} from "$lib/components/ui";
import { RefreshCw } from "@lucide/svelte";
let loading = $state(true);
let accessDenied = $state(false);
let refreshing = $state(false);
let error = $state("");
let inventory = $state<AdminStoreReconnectInventory | null>(null);
onMount(async () => {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
if (gate.reason === "auth") {
await goto("/login");
return;
}
if (gate.reason === "forbidden") {
accessDenied = true;
} else {
error = gate.message;
}
loading = false;
return;
}
await reload();
loading = false;
});
async function reload() {
refreshing = true;
error = "";
try {
const params = new URLSearchParams();
params.set("limit", "100");
params.set("offset", "0");
const data = await api<unknown>(`${STORE_RECONNECT_NEEDED_API}?${params}`);
inventory = normalizeStoreReconnectInventory(data);
} catch (err) {
error = failureMessage(err, i18n.t("admin.storeReconnect.loadFailed"));
inventory = null;
} finally {
refreshing = false;
}
}
</script>
<PageShell
title={i18n.t("admin.storeReconnect.title")}
description={i18n.t("admin.storeReconnect.description")}
>
{#snippet actions()}
<Button variant="outline" size="sm" loading={refreshing} onclick={reload}>
<RefreshCw class="mr-2 h-4 w-4" />
{i18n.t("admin.storeReconnect.refresh")}
</Button>
{/snippet}
{#if loading}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else}
<Alert message={error} />
<p class="mb-4 text-sm text-muted-foreground">{i18n.t("admin.storeReconnect.cutoverHint")}</p>
<div class="mb-4 grid gap-3 sm:grid-cols-2">
<Card>
<CardHeader class="pb-2">
<CardDescription>{i18n.t("admin.storeReconnect.metric.total")}</CardDescription>
<CardTitle class="tabular-nums text-2xl">
{formatCredits(Number(inventory?.total ?? 0))}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader class="pb-2">
<CardDescription>{i18n.t("admin.storeReconnect.metric.showing")}</CardDescription>
<CardTitle class="tabular-nums text-2xl">
{formatCredits(Number(inventory?.stores.length ?? 0))}
</CardTitle>
</CardHeader>
</Card>
</div>
<Card>
<CardHeader>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle>{i18n.t("admin.storeReconnect.listTitle")}</CardTitle>
<CardDescription>{i18n.t("admin.storeReconnect.listDesc")}</CardDescription>
</div>
{#if inventory && inventory.total === 0}
<Badge variant="success">{i18n.t("admin.storeReconnect.cleanBadge")}</Badge>
{:else if inventory && inventory.total > 0}
<Badge variant="warning">{i18n.t("admin.storeReconnect.needsBadge")}</Badge>
{/if}
</div>
</CardHeader>
<CardContent>
{#if !inventory || inventory.stores.length === 0}
<EmptyState
message={inventory && inventory.total === 0
? i18n.t("admin.storeReconnect.noneFound")
: i18n.t("admin.storeReconnect.noRows")}
/>
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.storeReconnect.colCompany")}</TableHead>
<TableHead>{i18n.t("admin.storeReconnect.colChannel")}</TableHead>
<TableHead>{i18n.t("admin.storeReconnect.colIdentity")}</TableHead>
<TableHead>{i18n.t("admin.storeReconnect.colReason")}</TableHead>
<TableHead>{i18n.t("admin.storeReconnect.colEnabled")}</TableHead>
<TableHead>{i18n.t("admin.storeReconnect.colLastTest")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each inventory.stores as row (row.company_id + row.channel)}
<TableRow data-testid="admin-store-reconnect-row">
<TableCell>
<div class="font-medium">{row.company_name || "—"}</div>
<div class="font-mono text-xs text-muted-foreground">
{row.company_id || "—"}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{i18n.t(storeReconnectChannelLabelKey(row.channel))}</Badge>
</TableCell>
<TableCell class="font-mono text-xs">{row.identity || "—"}</TableCell>
<TableCell>
<Badge variant="warning">{i18n.t(storeReconnectReasonLabelKey(row.reason))}</Badge>
</TableCell>
<TableCell class="text-sm text-muted-foreground">
{row.is_enabled
? i18n.t("admin.storeReconnect.enabledYes")
: i18n.t("admin.storeReconnect.enabledNo")}
</TableCell>
<TableCell class="text-sm text-muted-foreground">
{row.last_test_status || "—"}
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
{/if}
</CardContent>
</Card>
{/if}
</PageShell>
@@ -0,0 +1,215 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { api, failureMessage } from "$lib/api";
import { requirePlatformAdmin } from "$lib/admin-gate";
import { formatCredits, formatDate } from "$lib/utils";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableShell,
type BadgeVariant
} from "$lib/components/ui";
import { RefreshCw } from "@lucide/svelte";
type Job = {
id: string;
company_id?: string;
status?: string;
total_products?: number;
processed_products?: number;
error?: string | null;
created_at?: string;
updated_at?: string;
};
let loading = $state(true);
let accessDenied = $state(false);
let refreshing = $state(false);
let busy = $state(false);
let error = $state("");
let success = $state("");
let filter = $state("all");
let jobs = $state<Job[]>([]);
const stuckJobs = $derived.by(() => {
if (filter === "all") return jobs;
return jobs.filter((j) => String(j.status ?? "").toLowerCase() === filter);
});
const runningCount = $derived(
jobs.filter((j) => String(j.status ?? "").toLowerCase() === "running").length
);
onMount(async () => {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
if (gate.reason === "auth") {
await goto("/login");
return;
}
if (gate.reason === "forbidden") {
accessDenied = true;
} else {
error = gate.message;
}
loading = false;
return;
}
await reload();
loading = false;
});
async function reload() {
refreshing = true;
error = "";
try {
const res = await api<{ jobs: Job[] }>("/api/admin/jobs?limit=100");
jobs = res.jobs ?? [];
} catch (err) {
error = failureMessage(err, i18n.t("admin.stuck.loadFailed"));
jobs = [];
} finally {
refreshing = false;
}
}
async function stuckCleanup() {
busy = true;
error = "";
success = "";
try {
const res = await api<{ jobs_marked_failed: number; products_reset: number }>(
"/api/admin/jobs/stuck-cleanup",
{ method: "POST", body: {} }
);
success = i18n.t("flash.admin.cleanup", { jobs: res.jobs_marked_failed, products: res.products_reset });
await reload();
} catch (err) {
error = failureMessage(err, i18n.t("admin.stuck.cleanupFailed"));
} finally {
busy = false;
}
}
function jobStatusVariant(status: string): BadgeVariant {
switch (status.toLowerCase()) {
case "completed":
return "success";
case "failed":
return "destructive";
case "running":
return "warning";
case "pending":
return "outline";
case "cancelled":
return "secondary";
default:
return "default";
}
}
</script>
<PageShell
title={i18n.t("admin.stuck.title")}
description={i18n.t("admin.stuck.description")}
>
{#snippet actions()}
<Button variant="outline" size="sm" loading={refreshing} onclick={reload}>
<RefreshCw class="mr-2 h-4 w-4" />
{i18n.t("common.refresh")}
</Button>
<Button variant="destructive" size="sm" loading={busy} onclick={stuckCleanup}>
{i18n.t("admin.stuck.cleanupBtn")}
</Button>
{/snippet}
{#if loading}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else}
<Alert message={error} />
<Alert tone="success" message={success} />
<Card>
<CardHeader>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle>{i18n.t("admin.stuck.jobsTitle")}</CardTitle>
<CardDescription>
{i18n.t("admin.stuck.jobsDesc", { running: runningCount })}
</CardDescription>
</div>
<select
class="h-9 rounded-md border border-input bg-background px-3 text-sm"
aria-label={i18n.t("admin.stuck.filterAria")}
bind:value={filter}
>
<option value="all">{i18n.t("admin.stuck.filterAll")}</option>
<option value="running">{i18n.t("admin.stuck.filterRunning")}</option>
<option value="failed">{i18n.t("admin.stuck.filterFailed")}</option>
<option value="pending">{i18n.t("admin.stuck.filterPending")}</option>
<option value="completed">{i18n.t("admin.stuck.filterCompleted")}</option>
</select>
</div>
</CardHeader>
<CardContent>
{#if stuckJobs.length === 0}
<EmptyState message={i18n.t("empty.admin.noJobs")} />
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.stuck.colId")}</TableHead>
<TableHead>{i18n.t("admin.stuck.colCompany")}</TableHead>
<TableHead>{i18n.t("admin.stuck.colStatus")}</TableHead>
<TableHead>{i18n.t("admin.stuck.colProgress")}</TableHead>
<TableHead>{i18n.t("admin.stuck.colUpdated")}</TableHead>
<TableHead>{i18n.t("admin.stuck.colError")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each stuckJobs as job}
<TableRow>
<TableCell class="font-mono text-xs text-muted-foreground">{job.id}</TableCell>
<TableCell class="font-mono text-xs">{job.company_id || "—"}</TableCell>
<TableCell>
<Badge variant={jobStatusVariant(String(job.status ?? ""))}>
{job.status ?? "—"}
</Badge>
</TableCell>
<TableCell class="tabular-nums">
{formatCredits(Number(job.processed_products ?? 0))}/{formatCredits(
Number(job.total_products ?? 0)
)}
</TableCell>
<TableCell>{formatDate(job.updated_at || job.created_at)}</TableCell>
<TableCell class="max-w-xs truncate text-sm text-destructive">
{job.error || "—"}
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
{/if}
</CardContent>
</Card>
{/if}
</PageShell>
@@ -0,0 +1,462 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { ApiError, failureMessage } from "$lib/api";
import { requireSupportDesk } from "$lib/admin-gate";
import { formatDateTime } from "$lib/utils";
import {
claimAdminSupportTicket,
isAdminSupportUnavailable,
listAdminSupportTickets,
type SupportListScope,
type SupportQueueFlag,
type SupportTicket
} from "$lib/support/admin-api";
import {
autoReplyStatusLabel,
normalizeAutoReplyStatus
} from "$lib/support/auto-assist";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableShell,
type BadgeVariant
} from "$lib/components/ui";
import { RefreshCw, Search, UserPlus } from "@lucide/svelte";
const STATUS_FILTERS = $derived([
{ value: "all", label: i18n.t("admin.support.statusAll") },
{ value: "open", label: i18n.t("support.status.open") },
{ value: "pending", label: i18n.t("support.status.pending") },
{ value: "resolved", label: i18n.t("support.status.resolved") },
{ value: "closed", label: i18n.t("support.status.closed") }
] as const);
const SCOPE_FILTERS_FULL = $derived([
{ value: "all" as const, label: i18n.t("admin.support.scopeAll") },
{ value: "inbox" as const, label: i18n.t("admin.support.scopeInbox") },
{ value: "mine" as const, label: i18n.t("admin.support.scopeMine") },
{ value: "unassigned" as const, label: i18n.t("admin.support.scopeUnassigned") }
]);
const SCOPE_FILTERS_STAFF = $derived([
{ value: "inbox" as const, label: i18n.t("admin.support.scopeInbox") },
{ value: "mine" as const, label: i18n.t("admin.support.scopeMine") },
{ value: "unassigned" as const, label: i18n.t("admin.support.scopeUnassigned") }
]);
const FLAG_FILTERS = $derived([
{ value: "" as const, label: i18n.t("admin.support.flagAny") },
{ value: "needs_human" as const, label: i18n.t("admin.support.flagNeedsHuman") },
{ value: "ai_draft" as const, label: i18n.t("admin.support.flagAiDraft") }
]);
let loading = $state(true);
let accessDenied = $state(false);
let refreshing = $state(false);
let error = $state("");
let unavailable = $state(false);
let tickets = $state<SupportTicket[]>([]);
let total = $state(0);
let search = $state("");
let statusFilter = $state("open");
let scopeFilter = $state<SupportListScope>("inbox");
let flagFilter = $state<SupportQueueFlag>("");
let fullAdmin = $state(false);
let meId = $state("");
let claimingId = $state<string | null>(null);
const scopeOptions = $derived(fullAdmin ? SCOPE_FILTERS_FULL : SCOPE_FILTERS_STAFF);
onMount(async () => {
const initialStatus = page.url.searchParams.get("status");
if (initialStatus && STATUS_FILTERS.some((s) => s.value === initialStatus)) {
statusFilter = initialStatus;
}
const initialScope = page.url.searchParams.get("scope");
if (
initialScope &&
["inbox", "mine", "unassigned", "all"].includes(initialScope)
) {
scopeFilter = initialScope as SupportListScope;
}
const initialFlag = page.url.searchParams.get("flag");
if (initialFlag === "needs_human" || initialFlag === "ai_draft") {
flagFilter = initialFlag;
}
const gate = await requireSupportDesk();
if (!gate.ok) {
if (gate.reason === "auth") {
await goto("/login");
return;
}
if (gate.reason === "forbidden") {
accessDenied = true;
} else {
error = gate.message;
}
loading = false;
return;
}
fullAdmin = gate.staff.full_admin;
meId = gate.me.user.id;
if (!fullAdmin && scopeFilter === "all") {
scopeFilter = "inbox";
}
if (fullAdmin && !initialScope && !page.url.searchParams.get("scope")) {
scopeFilter = "all";
}
await reload();
loading = false;
});
async function syncUrl() {
const url = new URL(page.url);
if (statusFilter === "all") url.searchParams.delete("status");
else url.searchParams.set("status", statusFilter);
url.searchParams.set("scope", scopeFilter);
if (!flagFilter) url.searchParams.delete("flag");
else url.searchParams.set("flag", flagFilter);
await goto(`${url.pathname}${url.search}`, { replaceState: true, noScroll: true });
}
async function reload() {
refreshing = true;
error = "";
unavailable = false;
try {
const res = await listAdminSupportTickets({
status: statusFilter,
scope: scopeFilter,
q: search,
flag: flagFilter,
limit: 100
});
tickets = res.tickets;
total = res.total ?? res.tickets.length;
} catch (err) {
tickets = [];
total = 0;
if (isAdminSupportUnavailable(err)) {
unavailable = true;
error = i18n.t("flash.support.queueUnavailable");
} else if (err instanceof ApiError && err.status === 403) {
accessDenied = true;
} else {
error = failureMessage(err, i18n.t("admin.support.loadQueueFailed"));
}
} finally {
refreshing = false;
}
}
async function setStatusFilter(value: string) {
statusFilter = value;
await syncUrl();
await reload();
}
async function setScopeFilter(value: SupportListScope) {
scopeFilter = value;
await syncUrl();
await reload();
}
async function setFlagFilter(value: SupportQueueFlag) {
flagFilter = value;
await syncUrl();
await reload();
}
function statusVariant(status: string): BadgeVariant {
switch (status.toLowerCase()) {
case "open":
return "warning";
case "pending":
return "outline";
case "resolved":
return "success";
case "closed":
return "secondary";
default:
return "default";
}
}
function priorityLabel(priority: string): string {
const p = priority.trim().toLowerCase();
return p || "normal";
}
function canClaim(ticket: SupportTicket): boolean {
if (ticket.assignee_admin_user_id) return false;
const st = String(ticket.status ?? "").toLowerCase();
return st === "open" || st === "pending";
}
async function claimTicket(ticket: SupportTicket, e: MouseEvent) {
e.stopPropagation();
if (claimingId || !canClaim(ticket)) return;
claimingId = ticket.id;
error = "";
try {
const updated = await claimAdminSupportTicket(ticket.id);
tickets = tickets.map((t) => (t.id === ticket.id ? updated : t));
} catch (err) {
error = failureMessage(err, i18n.t("admin.support.claimFailed"));
} finally {
claimingId = null;
}
}
function assigneeLabel(ticket: SupportTicket): string {
if (!ticket.assignee_admin_user_id) return i18n.t("admin.support.unassigned");
if (ticket.assignee_admin_user_id === meId) return i18n.t("admin.support.you");
return ticket.assignee_email || i18n.t("admin.support.assigned");
}
function autoBadgeVariant(status: string): BadgeVariant {
switch (normalizeAutoReplyStatus(status)) {
case "ai_draft":
return "warning";
case "handed_off":
case "failed":
return "destructive";
case "matched":
case "ai_sent":
return "success";
case "skipped":
return "outline";
default:
return "secondary";
}
}
</script>
<PageShell
title={i18n.t("admin.support.title")}
description={i18n.t("admin.support.description")}
>
{#snippet actions()}
<div class="flex flex-wrap items-center gap-2">
{#if fullAdmin}
<a href="/admin/support/knowledge">
<Button variant="outline" size="sm">{i18n.t("admin.support.knowledgeLink")}</Button>
</a>
{/if}
{#if !accessDenied}
<Button variant="outline" size="sm" loading={refreshing} onclick={reload}>
<RefreshCw class="mr-2 h-4 w-4" />
{i18n.t("common.refresh")}
</Button>
{/if}
</div>
{/snippet}
{#if loading}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="support" />
{:else}
<div class="space-y-4">
<Alert message={error} />
<Card>
<CardHeader>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle>{i18n.t("admin.support.queueTitle")}</CardTitle>
<CardDescription>
{i18n.t("admin.support.queueMeta", {
total:
total === 1
? i18n.t("admin.support.ticketCountOne", { count: total })
: i18n.t("admin.support.ticketCountMany", { count: total }),
scope: scopeFilter
})}
{#if statusFilter !== "all"}
{" "}{i18n.t("admin.support.queueMetaStatus", { status: statusFilter })}
{/if}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex flex-wrap items-center gap-2" role="group" aria-label={i18n.t("admin.support.queueScopeAria")}>
{#each scopeOptions as opt}
<button
type="button"
class="rounded-md border px-3 py-1.5 text-sm font-medium transition-colors {scopeFilter ===
opt.value
? 'border-primary bg-primary text-primary-foreground shadow-sm'
: 'border-border text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
onclick={() => setScopeFilter(opt.value)}
>
{opt.label}
</button>
{/each}
</div>
<div class="flex flex-wrap items-center gap-2" role="group" aria-label={i18n.t("admin.support.statusFilterAria")}>
{#each STATUS_FILTERS as opt}
<button
type="button"
class="rounded-md border px-3 py-1.5 text-sm font-medium transition-colors {statusFilter ===
opt.value
? 'border-primary bg-primary text-primary-foreground shadow-sm'
: 'border-border text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
onclick={() => setStatusFilter(opt.value)}
>
{opt.label}
</button>
{/each}
</div>
<div class="flex flex-wrap items-center gap-2" role="group" aria-label={i18n.t("admin.support.autoAssistFilterAria")}>
{#each FLAG_FILTERS as opt}
<button
type="button"
class="rounded-md border px-3 py-1.5 text-sm font-medium transition-colors {flagFilter ===
opt.value
? 'border-primary bg-primary text-primary-foreground shadow-sm'
: 'border-border text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
onclick={() => setFlagFilter(opt.value)}
>
{opt.label}
</button>
{/each}
</div>
<form
class="flex flex-wrap gap-2"
onsubmit={(e) => {
e.preventDefault();
void reload();
}}
>
<div class="relative min-w-0 flex-1">
<Search
class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
class="pl-9"
placeholder={i18n.t("admin.support.searchPlaceholder")}
bind:value={search}
aria-label={i18n.t("admin.support.searchAria")}
/>
</div>
<Button type="submit" variant="secondary" size="sm" loading={refreshing}>{i18n.t("common.search")}</Button>
</form>
{#if unavailable}
<EmptyState message={i18n.t("empty.admin.queueUnavailable")} />
{:else if tickets.length === 0}
<EmptyState message={i18n.t("empty.admin.noTicketsQueue")} />
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.support.colSubject")}</TableHead>
<TableHead>{i18n.t("admin.support.colStatus")}</TableHead>
<TableHead class="hidden md:table-cell">{i18n.t("admin.support.colAuto")}</TableHead>
<TableHead class="hidden lg:table-cell">{i18n.t("admin.support.colPriority")}</TableHead>
<TableHead class="hidden lg:table-cell">{i18n.t("admin.support.colAssignee")}</TableHead>
<TableHead class="hidden xl:table-cell">{i18n.t("admin.support.colCompany")}</TableHead>
<TableHead class="hidden xl:table-cell">{i18n.t("admin.support.colUpdated")}</TableHead>
<TableHead stickyRight>{i18n.t("admin.support.colActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each tickets as ticket (ticket.id)}
<TableRow class="hover:bg-muted/40">
<TableCell>
<a
href={`/admin/support/${encodeURIComponent(ticket.id)}`}
class="block font-medium text-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{ticket.subject}
</a>
<div class="text-xs text-muted-foreground capitalize">
{ticket.category || "other"}
{#if ticket.created_by_email}
· {ticket.created_by_email}
{/if}
</div>
<div class="mt-1 flex flex-wrap gap-1 md:hidden">
{#if ticket.auto_reply_disabled}
<Badge variant="outline">{i18n.t("admin.support.autoOff")}</Badge>
{:else if ticket.auto_reply_status && normalizeAutoReplyStatus(ticket.auto_reply_status) !== "none"}
<Badge variant={autoBadgeVariant(String(ticket.auto_reply_status))}>
{autoReplyStatusLabel(ticket.auto_reply_status)}
</Badge>
{/if}
</div>
</TableCell>
<TableCell>
<Badge variant={statusVariant(String(ticket.status ?? ""))}>
{ticket.status}
</Badge>
</TableCell>
<TableCell class="hidden md:table-cell">
{#if ticket.auto_reply_disabled}
<Badge variant="outline">{i18n.t("admin.support.autoOff")}</Badge>
{:else if ticket.auto_reply_status && normalizeAutoReplyStatus(ticket.auto_reply_status) !== "none"}
<Badge variant={autoBadgeVariant(String(ticket.auto_reply_status))}>
{autoReplyStatusLabel(ticket.auto_reply_status)}
</Badge>
{:else}
<span class="text-xs text-muted-foreground"></span>
{/if}
</TableCell>
<TableCell class="hidden capitalize text-sm lg:table-cell">{priorityLabel(String(ticket.priority ?? ""))}</TableCell>
<TableCell class="hidden max-w-[10rem] truncate text-sm lg:table-cell">
{assigneeLabel(ticket)}
</TableCell>
<TableCell class="hidden max-w-[10rem] truncate text-sm xl:table-cell">
{ticket.company_name || "—"}
</TableCell>
<TableCell class="hidden whitespace-nowrap text-sm text-muted-foreground xl:table-cell">
{formatDateTime(ticket.last_message_at || ticket.updated_at)}
</TableCell>
<TableCell stickyRight>
{#if canClaim(ticket)}
<Button
variant="outline"
size="sm"
loading={claimingId === ticket.id}
onclick={(e) => claimTicket(ticket, e)}
aria-label={i18n.t("admin.support.claimAria", { subject: ticket.subject })}
>
<UserPlus class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.support.claim")}</span>
</Button>
{/if}
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
{/if}
</CardContent>
</Card>
</div>
{/if}
</PageShell>
@@ -0,0 +1,720 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { ApiError, failureMessage } from "$lib/api";
import { requireSupportDesk } from "$lib/admin-gate";
import { staffRoleLabel } from "$lib/admin-orgs";
import { formatDateTime } from "$lib/utils";
import {
approveAdminSupportAIDraft,
claimAdminSupportTicket,
discardAdminSupportAIDraft,
getAdminSupportTicket,
isAdminSupportUnavailable,
listSupportAgents,
releaseAdminSupportTicket,
replyAdminSupportTicket,
updateAdminSupportTicket,
type SupportAgent,
type SupportMessage,
type SupportTicket,
type SupportTicketStatus
} from "$lib/support/admin-api";
import {
autoReplyStatusLabel,
autoSourceLabel,
findAIDraftMessage,
formatConfidence,
normalizeAutoReplyStatus
} from "$lib/support/auto-assist";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
Textarea,
type BadgeVariant
} from "$lib/components/ui";
import { ArrowLeft, Bot, CheckCircle2, RefreshCw, UserMinus, UserPlus } from "@lucide/svelte";
let loading = $state(true);
let accessDenied = $state(false);
let refreshing = $state(false);
let busy = $state(false);
let error = $state("");
let success = $state("");
let unavailable = $state(false);
let notFound = $state(false);
let ticket = $state<SupportTicket | null>(null);
let replyBody = $state("");
let internalNote = $state(false);
let replyStatus = $state<"" | SupportTicketStatus>("");
let fullAdmin = $state(false);
let meId = $state("");
let agents = $state<SupportAgent[]>([]);
let assignTo = $state("");
let draftBody = $state("");
let draftStatus = $state<"" | SupportTicketStatus>("pending");
const ticketId = $derived(page.params.id ?? "");
const messages = $derived.by(() => {
const list = ticket?.messages ?? [];
return Array.isArray(list) ? list : [];
});
const isMine = $derived(
Boolean(ticket?.assignee_admin_user_id && ticket.assignee_admin_user_id === meId)
);
const isUnassigned = $derived(!ticket?.assignee_admin_user_id);
const canClaim = $derived.by(() => {
if (!ticket || !isUnassigned) return false;
const st = String(ticket.status ?? "").toLowerCase();
return st === "open" || st === "pending";
});
const canRelease = $derived(Boolean(ticket && (isMine || (fullAdmin && !isUnassigned))));
const aiDraft = $derived.by(() => {
if (!ticket) return null;
if (normalizeAutoReplyStatus(ticket.auto_reply_status) !== "ai_draft") return null;
return findAIDraftMessage(ticket.messages, ticket.auto_reply_message_id);
});
const showAutoPanel = $derived(Boolean(ticket));
onMount(async () => {
const gate = await requireSupportDesk();
if (!gate.ok) {
if (gate.reason === "auth") {
await goto("/login");
return;
}
if (gate.reason === "forbidden") {
accessDenied = true;
} else {
error = gate.message;
}
loading = false;
return;
}
fullAdmin = gate.staff.full_admin;
meId = gate.me.user.id;
if (fullAdmin) {
try {
const res = await listSupportAgents({ limit: 100 });
agents = res.agents;
} catch {
agents = [];
}
}
await reload();
loading = false;
});
async function reload() {
if (!ticketId) {
notFound = true;
return;
}
refreshing = true;
error = "";
unavailable = false;
notFound = false;
try {
ticket = await getAdminSupportTicket(ticketId);
assignTo = ticket.assignee_admin_user_id ?? "";
const draft = findAIDraftMessage(ticket.messages, ticket.auto_reply_message_id);
if (normalizeAutoReplyStatus(ticket.auto_reply_status) === "ai_draft" && draft) {
draftBody = draft.body;
}
} catch (err) {
ticket = null;
if (isAdminSupportUnavailable(err)) {
unavailable = true;
error = i18n.t("flash.support.deploymentUnavailable");
} else if (err instanceof ApiError && err.status === 404) {
notFound = true;
} else if (err instanceof ApiError && err.status === 403) {
accessDenied = true;
} else {
error = failureMessage(err, i18n.t("admin.support.loadTicketFailed"));
}
} finally {
refreshing = false;
}
}
function statusVariant(status: string): BadgeVariant {
switch (status.toLowerCase()) {
case "open":
return "warning";
case "pending":
return "outline";
case "resolved":
return "success";
case "closed":
return "secondary";
default:
return "default";
}
}
function authorLabel(msg: SupportMessage): string {
if (msg.is_internal_note && msg.is_auto_reply) return i18n.t("admin.support.authorAiDraft");
if (msg.is_internal_note) return i18n.t("admin.support.authorInternal");
const role = String(msg.author_role ?? "").toLowerCase();
if (msg.is_auto_reply || role === "system") {
return autoSourceLabel(msg.auto_source);
}
if (role === "agent") return i18n.t("admin.support.authorStaff");
return i18n.t("admin.support.authorCustomer");
}
async function sendReply() {
if (!ticket || busy) return;
const body = replyBody.trim();
if (!body) {
error = i18n.t("flash.support.replyRequired");
return;
}
busy = true;
error = "";
success = "";
const wasInternal = internalNote;
try {
const status = replyStatus || undefined;
ticket = await replyAdminSupportTicket(ticket.id, {
body,
is_internal_note: wasInternal,
status
});
replyBody = "";
internalNote = false;
replyStatus = "";
success = wasInternal
? i18n.t("admin.support.flash.noteSaved")
: i18n.t("admin.support.flash.replySent");
} catch (err) {
error = failureMessage(err, i18n.t("admin.support.replyFailed"));
} finally {
busy = false;
}
}
async function setStatus(status: SupportTicketStatus) {
if (!ticket || busy) return;
busy = true;
error = "";
success = "";
try {
ticket = await updateAdminSupportTicket(ticket.id, { status });
success =
status === "resolved"
? i18n.t("admin.support.flash.resolved")
: status === "pending"
? i18n.t("admin.support.flash.pending")
: status === "open"
? i18n.t("admin.support.flash.reopened")
: i18n.t("admin.support.flash.statusSet", { status });
} catch (err) {
error = failureMessage(err, i18n.t("admin.support.statusFailed"));
} finally {
busy = false;
}
}
async function resolveTicket() {
await setStatus("resolved");
}
async function claimTicket() {
if (!ticket || busy) return;
busy = true;
error = "";
success = "";
try {
ticket = await claimAdminSupportTicket(ticket.id);
assignTo = ticket.assignee_admin_user_id ?? "";
success = i18n.t("flash.support.claimed");
} catch (err) {
error = failureMessage(err, i18n.t("admin.support.claimFailed"));
} finally {
busy = false;
}
}
async function releaseTicket() {
if (!ticket || busy) return;
busy = true;
error = "";
success = "";
try {
ticket = await releaseAdminSupportTicket(ticket.id);
assignTo = "";
success = i18n.t("flash.support.unassigned");
} catch (err) {
error = failureMessage(err, i18n.t("admin.support.unassignFailed"));
} finally {
busy = false;
}
}
async function applyAssign() {
if (!ticket || busy || !fullAdmin) return;
busy = true;
error = "";
success = "";
try {
if (!assignTo) {
ticket = await updateAdminSupportTicket(ticket.id, { clear_assignee: true });
success = i18n.t("flash.support.assigneeCleared");
} else {
ticket = await updateAdminSupportTicket(ticket.id, {
assignee_admin_user_id: assignTo
});
success = i18n.t("flash.support.assigneeUpdated");
}
} catch (err) {
error = failureMessage(err, i18n.t("admin.support.assignFailed"));
} finally {
busy = false;
}
}
async function setAutoDisabled(disabled: boolean) {
if (!ticket || busy) return;
busy = true;
error = "";
success = "";
try {
ticket = await updateAdminSupportTicket(ticket.id, { auto_reply_disabled: disabled });
success = disabled
? i18n.t("admin.support.flash.autoDisabled")
: i18n.t("admin.support.flash.autoEnabled");
} catch (err) {
error = failureMessage(err, i18n.t("admin.support.autoFailed"));
} finally {
busy = false;
}
}
async function approveDraft() {
if (!ticket || busy) return;
const body = draftBody.trim();
if (!body) {
error = i18n.t("flash.support.draftRequired");
return;
}
busy = true;
error = "";
success = "";
try {
ticket = await approveAdminSupportAIDraft(ticket.id, {
body,
status: draftStatus || undefined
});
draftBody = "";
success = i18n.t("flash.support.draftApproved");
} catch (err) {
if (err instanceof ApiError && (err.status === 404 || err.status === 501)) {
// Fallback when approve route is not mounted yet: send as staff reply.
ticket = await replyAdminSupportTicket(ticket.id, {
body,
status: draftStatus || "pending"
});
try {
ticket = await updateAdminSupportTicket(ticket.id, { auto_reply_disabled: true });
} catch {
/* optional */
}
draftBody = "";
success = i18n.t("flash.support.draftSent");
} else {
error = failureMessage(err, i18n.t("admin.support.approveFailed"));
}
} finally {
busy = false;
}
}
async function discardDraft() {
if (!ticket || busy) return;
busy = true;
error = "";
success = "";
try {
ticket = await discardAdminSupportAIDraft(ticket.id);
draftBody = "";
success = i18n.t("flash.support.draftDiscarded");
} catch (err) {
if (err instanceof ApiError && (err.status === 404 || err.status === 501)) {
ticket = await updateAdminSupportTicket(ticket.id, { auto_reply_disabled: true });
draftBody = "";
success = i18n.t("flash.support.autoReplyDisabled");
} else {
error = failureMessage(err, i18n.t("admin.support.discardFailed"));
}
} finally {
busy = false;
}
}
</script>
<PageShell
title={ticket?.subject ?? i18n.t("admin.support.ticketFallbackTitle")}
description={ticket
? i18n.t("admin.support.ticketMeta", {
company: ticket.company_name || i18n.t("admin.support.companyFallback"),
requester: ticket.created_by_email || i18n.t("admin.support.requesterFallback")
})
: i18n.t("admin.support.ticketMetaFallback")}
>
{#snippet actions()}
<a href="/admin/support">
<Button variant="outline" size="sm">
<ArrowLeft class="mr-2 h-4 w-4" />
{i18n.t("admin.support.backQueue")}
</Button>
</a>
<Button variant="outline" size="sm" loading={refreshing} onclick={reload} disabled={accessDenied || !ticketId}>
<RefreshCw class="mr-2 h-4 w-4" />
{i18n.t("common.refresh")}
</Button>
{#if ticket && ticket.status !== "resolved" && ticket.status !== "closed"}
<Button variant="default" size="sm" loading={busy} onclick={resolveTicket}>
<CheckCircle2 class="mr-2 h-4 w-4" />
{i18n.t("admin.support.resolve")}
</Button>
{/if}
{/snippet}
{#if loading}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="support" />
{:else if notFound}
<EmptyState message={i18n.t("empty.admin.ticketNotFound")} />
{:else if unavailable}
<div class="space-y-4">
<Alert message={error} />
<EmptyState message={i18n.t("empty.admin.supportUnavailable")} />
</div>
{:else if !ticket}
<div class="space-y-4">
<Alert message={error} />
<EmptyState message={i18n.t("empty.admin.ticketLoadFailed")} />
</div>
{:else}
<div class="space-y-4">
<Alert message={error} />
<Alert tone="success" message={success} />
<Card>
<CardHeader>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<CardTitle class="text-lg">{ticket.subject}</CardTitle>
<CardDescription class="space-x-2">
<span class="capitalize">{ticket.category || "other"}</span>
<span>·</span>
<span class="capitalize">{i18n.t("admin.support.priorityMeta", { priority: ticket.priority || "normal" })}</span>
<span>·</span>
<span class="font-mono text-xs">{ticket.id}</span>
</CardDescription>
</div>
<div class="flex flex-wrap items-center gap-2">
<Badge variant={statusVariant(String(ticket.status ?? ""))}>{ticket.status}</Badge>
{#if ticket.status !== "pending"}
<Button variant="outline" size="sm" loading={busy} onclick={() => setStatus("pending")}>
{i18n.t("admin.support.markPending")}
</Button>
{/if}
{#if ticket.status === "resolved" || ticket.status === "closed"}
<Button variant="outline" size="sm" loading={busy} onclick={() => setStatus("open")}>
{i18n.t("admin.support.reopen")}
</Button>
{/if}
</div>
</div>
</CardHeader>
<CardContent class="space-y-3 border-t border-border pt-4">
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="text-muted-foreground">{i18n.t("admin.support.assigneeLabel")}</span>
<span class="font-medium text-foreground">
{#if ticket.assignee_admin_user_id}
{#if isMine}
{i18n.t("admin.support.you")}
{:else}
{ticket.assignee_email || ticket.assignee_admin_user_id}
{/if}
{:else}
{i18n.t("admin.support.unassigned")}
{/if}
</span>
{#if canClaim}
<Button variant="secondary" size="sm" loading={busy} onclick={claimTicket}>
<UserPlus class="mr-1.5 h-3.5 w-3.5" />
{i18n.t("admin.support.claim")}
</Button>
{/if}
{#if canRelease}
<Button variant="outline" size="sm" loading={busy} onclick={releaseTicket}>
<UserMinus class="mr-1.5 h-3.5 w-3.5" />
{i18n.t("admin.support.unassign")}
</Button>
{/if}
</div>
{#if fullAdmin}
<form
class="flex flex-wrap items-end gap-2"
onsubmit={(e) => {
e.preventDefault();
void applyAssign();
}}
>
<label class="space-y-1 text-sm">
<span class="text-muted-foreground">{i18n.t("admin.support.assignToStaff")}</span>
<select
class="flex h-9 min-w-[220px] rounded-md border border-input bg-background px-2 text-sm"
bind:value={assignTo}
disabled={busy}
aria-label={i18n.t("admin.support.assignStaffAria")}
>
<option value="">{i18n.t("admin.support.unassigned")}</option>
{#each agents as agent (agent.id)}
<option value={agent.id}>
{agent.name ? `${agent.name} (${agent.email})` : agent.email}
{#if agent.staff_role}
· {staffRoleLabel(agent.staff_role)}
{/if}
</option>
{/each}
</select>
</label>
<Button type="submit" variant="secondary" size="sm" loading={busy}>{i18n.t("admin.support.apply")}</Button>
</form>
{/if}
</CardContent>
</Card>
{#if showAutoPanel}
<Card>
<CardHeader>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle class="flex items-center gap-2">
<Bot class="h-4 w-4" />
{i18n.t("admin.support.autoPanelTitle")}
</CardTitle>
<CardDescription>
{i18n.t("admin.support.autoPanelDesc")}
</CardDescription>
</div>
<div class="flex flex-wrap items-center gap-2">
{#if ticket.auto_reply_status}
<Badge variant="outline">{autoReplyStatusLabel(ticket.auto_reply_status)}</Badge>
{/if}
{#if ticket.auto_reply_disabled}
<Badge variant="secondary">{i18n.t("admin.support.autoDisabledBadge")}</Badge>
{/if}
</div>
</div>
</CardHeader>
<CardContent class="space-y-3">
<div class="grid gap-2 text-sm sm:grid-cols-2">
<div>
<span class="text-muted-foreground">{i18n.t("admin.support.autoStatusLabel")}</span>
<span class="ml-1 font-medium">{autoReplyStatusLabel(ticket.auto_reply_status ?? "none")}</span>
</div>
<div>
<span class="text-muted-foreground">{i18n.t("admin.support.lastAttempt")}</span>
<span class="ml-1">{ticket.auto_reply_attempted_at ? formatDateTime(ticket.auto_reply_attempted_at) : "—"}</span>
</div>
</div>
<div class="flex flex-wrap gap-2">
{#if ticket.auto_reply_disabled}
<Button variant="outline" size="sm" loading={busy} onclick={() => setAutoDisabled(false)}>
{i18n.t("admin.support.reenableAuto")}
</Button>
{:else}
<Button variant="secondary" size="sm" loading={busy} onclick={() => setAutoDisabled(true)}>
{i18n.t("admin.support.disableAuto")}
</Button>
{/if}
</div>
</CardContent>
</Card>
{/if}
{#if aiDraft}
<Card>
<CardHeader>
<CardTitle>{i18n.t("admin.support.aiDraftTitle")}</CardTitle>
<CardDescription>
{i18n.t("admin.support.aiDraftDesc")}
</CardDescription>
</CardHeader>
<CardContent class="space-y-3">
{#if typeof aiDraft.auto_confidence === "number"}
<p class="text-sm text-muted-foreground">
{i18n.t("admin.support.confidence", { value: formatConfidence(aiDraft.auto_confidence) })}
{#if aiDraft.auto_source}
· {autoSourceLabel(aiDraft.auto_source)}
{/if}
</p>
{/if}
<Textarea
bind:value={draftBody}
rows={6}
maxlength={10000}
aria-label={i18n.t("admin.support.aiDraftAria")}
disabled={busy}
/>
<label class="flex items-center gap-2 text-sm text-muted-foreground">
<span>{i18n.t("admin.support.statusAfterSend")}</span>
<select
class="h-9 rounded-md border border-input bg-background px-2 text-sm"
bind:value={draftStatus}
disabled={busy}
>
<option value="pending">{i18n.t("support.status.pending")}</option>
<option value="open">{i18n.t("support.status.open")}</option>
<option value="resolved">{i18n.t("support.status.resolved")}</option>
</select>
</label>
</CardContent>
<CardFooter class="flex flex-wrap gap-2">
<Button loading={busy} onclick={approveDraft} disabled={!draftBody.trim()}>
{i18n.t("admin.support.approveSend")}
</Button>
<Button variant="outline" loading={busy} onclick={discardDraft}
>{i18n.t("admin.support.discardDraft")}</Button
>
</CardFooter>
</Card>
{/if}
<Card>
<CardHeader>
<CardTitle>{i18n.t("admin.support.threadTitle")}</CardTitle>
<CardDescription>
{i18n.t("admin.support.threadMeta", {
count:
messages.length === 1
? i18n.t("admin.support.messageCountOne", { count: messages.length })
: i18n.t("admin.support.messageCountMany", { count: messages.length }),
when: formatDateTime(ticket.last_message_at || ticket.updated_at)
})}
</CardDescription>
</CardHeader>
<CardContent class="space-y-3">
{#if messages.length === 0}
<EmptyState message={i18n.t("empty.admin.noMessages")} />
{:else}
{#each messages as msg (msg.id)}
{@const staff = String(msg.author_role).toLowerCase() === "agent" || msg.is_internal_note}
{@const automated = Boolean(msg.is_auto_reply) || String(msg.author_role).toLowerCase() === "system"}
<div
class="rounded-md border px-3 py-3 text-sm {msg.is_internal_note
? 'border-dashed border-amber-500/40 bg-amber-500/5'
: automated
? 'border-sky-500/30 bg-sky-500/5'
: staff
? 'border-primary/20 bg-primary/5'
: 'border-border bg-card'}"
>
<div class="mb-1 flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-foreground">{authorLabel(msg)}</span>
{#if automated && !msg.is_internal_note}
<Badge variant="outline">{autoSourceLabel(msg.auto_source)}</Badge>
{/if}
{#if typeof msg.auto_confidence === "number"}
<span class="text-xs text-muted-foreground">{formatConfidence(msg.auto_confidence)}</span>
{/if}
</div>
<span class="text-xs text-muted-foreground">{formatDateTime(msg.created_at)}</span>
</div>
<p class="whitespace-pre-wrap break-words text-foreground">{msg.body}</p>
</div>
{/each}
{/if}
</CardContent>
</Card>
{#if ticket.status !== "closed"}
<Card>
<CardHeader>
<CardTitle>{i18n.t("admin.support.replyTitle")}</CardTitle>
<CardDescription>
{i18n.t("admin.support.replyDesc")}
</CardDescription>
</CardHeader>
<CardContent class="space-y-3">
<Textarea
bind:value={replyBody}
rows={5}
maxlength={10000}
placeholder={i18n.t("admin.support.replyPlaceholder")}
aria-label={i18n.t("admin.support.replyAria")}
disabled={busy}
/>
<div class="flex flex-wrap items-center gap-4">
<label class="flex items-center gap-2 text-sm text-muted-foreground">
<input type="checkbox" bind:checked={internalNote} disabled={busy} class="rounded border-input" />
{i18n.t("admin.support.internalNote")}
</label>
<label class="flex items-center gap-2 text-sm text-muted-foreground">
<span>{i18n.t("admin.support.setStatus")}</span>
<select
class="h-9 rounded-md border border-input bg-background px-2 text-sm"
bind:value={replyStatus}
disabled={busy || internalNote}
>
<option value="">{i18n.t("admin.support.statusDefault")}</option>
<option value="open">{i18n.t("support.status.open")}</option>
<option value="pending">{i18n.t("support.status.pending")}</option>
<option value="resolved">{i18n.t("support.status.resolved")}</option>
</select>
</label>
</div>
</CardContent>
<CardFooter class="flex flex-wrap gap-2">
<Button loading={busy} onclick={sendReply} disabled={!replyBody.trim()}>
{internalNote ? i18n.t("admin.support.saveNote") : i18n.t("admin.support.sendReply")}
</Button>
{#if !internalNote}
<Button
variant="secondary"
loading={busy}
disabled={!replyBody.trim()}
onclick={async () => {
replyStatus = "resolved";
await sendReply();
}}
>
{i18n.t("admin.support.replyAndResolve")}
</Button>
{/if}
</CardFooter>
</Card>
{:else}
<Card>
<CardContent class="py-6 text-sm text-muted-foreground">
{i18n.t("admin.support.closedBanner")}
</CardContent>
</Card>
{/if}
</div>
{/if}
</PageShell>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { requirePlatformAdmin } from "$lib/admin-gate";
import Spinner from "$lib/components/Spinner.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import { i18n } from "$lib/i18n";
let loading = $state(true);
let accessDenied = $state(false);
// Prefer Stuck Products (job list + same cleanup action). Keep this URL as a redirect.
onMount(async () => {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
if (gate.reason === "auth") {
await goto("/login");
return;
}
accessDenied = true;
loading = false;
return;
}
await goto("/admin/stuck-products", { replaceState: true });
});
</script>
{#if loading}
<div class="p-6"><Spinner /></div>
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else}
<p class="p-6 text-sm text-muted-foreground">{i18n.t("admin.redirect.stuck")}</p>
{/if}
@@ -0,0 +1,363 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { failureMessage } from "$lib/api";
import { requirePlatformAdmin } from "$lib/admin-gate";
import {
isTranslationsUnavailable,
loadTranslationsCatalog,
saveTranslationUpdates,
type TranslationsCatalogResponse
} from "$lib/admin-translations";
import { i18n, DEFAULT_UI_LOCALE } from "$lib/i18n";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import {
Badge,
Button,
Input,
Label,
Select,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableShell,
Textarea
} from "$lib/components/ui";
import { Languages, RefreshCw, Save, Search } from "@lucide/svelte";
type FilterMode = "all" | "missing" | "translated";
let loading = $state(true);
let refreshing = $state(false);
let saving = $state(false);
let accessDenied = $state(false);
let unavailable = $state(false);
let error = $state("");
let success = $state("");
let data = $state<TranslationsCatalogResponse | null>(null);
let locale = $state(DEFAULT_UI_LOCALE === "en" ? "es" : DEFAULT_UI_LOCALE);
let filter = $state<FilterMode>("missing");
let query = $state("");
let draft = $state<Record<string, string>>({});
let dirtyKeys = $state<Set<string>>(new Set());
const coverage = $derived(data?.coverage ?? []);
const baseKeys = $derived(data?.keys ?? []);
const baseDict = $derived(data?.catalog?.[DEFAULT_UI_LOCALE] ?? {});
const localeDict = $derived(data?.catalog?.[locale] ?? {});
const activeCoverage = $derived(coverage.find((c) => c.code === locale) ?? null);
const rows = $derived.by(() => {
const q = query.trim().toLowerCase();
const missingSet = new Set(activeCoverage?.missing_keys ?? []);
return baseKeys.filter((key) => {
const translated = !missingSet.has(key);
if (filter === "missing" && translated) return false;
if (filter === "translated" && !translated) return false;
if (!q) return true;
const en = (baseDict[key] ?? "").toLowerCase();
const loc = (draft[key] ?? localeDict[key] ?? "").toLowerCase();
return key.toLowerCase().includes(q) || en.includes(q) || loc.includes(q);
});
});
const dirtyCount = $derived(dirtyKeys.size);
onMount(async () => {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
if (gate.reason === "auth") {
await goto("/login");
return;
}
if (gate.reason === "forbidden") {
accessDenied = true;
} else {
error = gate.message;
}
loading = false;
return;
}
await reload();
loading = false;
});
function resetDraft(next: TranslationsCatalogResponse) {
const pack = next.catalog[locale] ?? {};
const nextDraft: Record<string, string> = {};
for (const key of next.keys) {
nextDraft[key] = pack[key] ?? "";
}
draft = nextDraft;
dirtyKeys = new Set();
}
async function reload() {
refreshing = true;
error = "";
success = "";
unavailable = false;
try {
const next = await loadTranslationsCatalog();
data = next;
const preferred =
next.locales.find((l) => l.code === locale)?.code ??
next.locales.find((l) => l.code !== next.base_locale)?.code ??
next.base_locale;
locale = preferred;
resetDraft(next);
} catch (err) {
if (isTranslationsUnavailable(err)) {
unavailable = true;
} else {
error = failureMessage(err, "Could not load translation catalog.");
}
} finally {
refreshing = false;
}
}
function onDraftInput(key: string, value: string) {
draft = { ...draft, [key]: value };
const original = localeDict[key] ?? "";
const next = new Set(dirtyKeys);
if (value === original) next.delete(key);
else next.add(key);
dirtyKeys = next;
}
async function saveDirty() {
if (!dirtyCount) return;
saving = true;
error = "";
success = "";
const updates: Record<string, string> = {};
for (const key of dirtyKeys) {
updates[key] = draft[key] ?? "";
}
try {
const saved = await saveTranslationUpdates(locale, updates);
if (data) {
data = {
...data,
catalog: { ...data.catalog, [locale]: saved.messages }
};
}
await reload();
success = i18n.t("flash.admin.translationsSaved", { count: Object.keys(updates).length, locale });
} catch (err) {
error = failureMessage(err, "Could not save translations.");
} finally {
saving = false;
}
}
</script>
{#if loading}
<div class="flex min-h-[40vh] items-center justify-center">
<Spinner label="Checking access…" />
</div>
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else}
<PageShell
title={i18n.t("admin.translations.title")}
description={i18n.t("admin.translations.description")}
eyebrow={i18n.t("admin.translations.eyebrow")}
>
{#snippet actions()}
<Button type="button" variant="outline" onclick={() => reload()} disabled={refreshing || saving}>
<RefreshCw class="size-4 {refreshing ? 'animate-spin' : ''}" />
Refresh
</Button>
<Button type="button" onclick={saveDirty} disabled={saving || dirtyCount === 0}>
<Save class="size-4" />
{saving ? "Saving…" : `Save${dirtyCount ? ` (${dirtyCount})` : ""}`}
</Button>
{/snippet}
<div class="space-y-6">
{#if error}
<Alert message={error} />
{/if}
{#if success}
<Alert tone="success" message={success} />
{/if}
{#if unavailable}
<EmptyState
title={i18n.t("empty.admin.translationsUnavailableTitle")}
message={i18n.t("empty.admin.translationsUnavailableMessage")}
/>
{:else if data}
<section
class="rounded-xl border border-border bg-card/40 px-4 py-3 text-sm text-muted-foreground"
aria-label={i18n.t("admin.translations.sourceOfTruth")}
>
<p class="flex items-start gap-2">
<Languages class="mt-0.5 size-4 shrink-0 text-foreground" />
<span>
<strong class="font-medium text-foreground">{i18n.t("admin.translations.sourceOfTruthLabel")}</strong>
{data.source_of_truth}
</span>
</p>
</section>
<section aria-labelledby="coverage-heading" class="space-y-3">
<h2 id="coverage-heading" class="text-sm font-semibold tracking-wide text-foreground">
Locale coverage
</h2>
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.translations.colLocale")}</TableHead>
<TableHead>{i18n.t("admin.translations.colTranslated")}</TableHead>
<TableHead>{i18n.t("admin.translations.colMissing")}</TableHead>
<TableHead>{i18n.t("admin.translations.colStatus")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each coverage as row}
<TableRow>
<TableCell>
<button
type="button"
class="font-medium text-foreground underline-offset-2 hover:underline"
onclick={() => {
locale = row.code;
if (data) resetDraft(data);
}}
>
{row.label}
<span class="text-muted-foreground">({row.code})</span>
</button>
</TableCell>
<TableCell>{row.translated}/{row.total}</TableCell>
<TableCell>{row.missing}</TableCell>
<TableCell>
{#if row.code === data.base_locale}
<Badge variant="secondary">{i18n.t("admin.translations.badgeBase")}</Badge>
{:else if row.missing === 0}
<Badge variant="success">{i18n.t("admin.translations.badgeComplete")}</Badge>
{:else if !row.registered}
<Badge variant="outline">{i18n.t("admin.translations.badgeUnregistered")}</Badge>
{:else}
<Badge variant="warning">{i18n.t("admin.translations.badgeGaps")}</Badge>
{/if}
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
</section>
<section aria-labelledby="editor-heading" class="space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 id="editor-heading" class="text-sm font-semibold tracking-wide text-foreground">
Edit strings
</h2>
<p class="mt-1 text-sm text-muted-foreground">
Clear a non-English value to fall back to English. English values cannot be cleared.
</p>
</div>
<div class="flex flex-wrap gap-3">
<div class="min-w-[10rem] space-y-1.5">
<Label for="translation-locale">{i18n.t("admin.translations.language")}</Label>
<Select
id="translation-locale"
bind:value={locale}
onchange={() => {
if (data) resetDraft(data);
success = "";
error = "";
}}
>
{#each data.locales as item}
<option value={item.code}>{item.label}</option>
{/each}
</Select>
</div>
<div class="min-w-[10rem] space-y-1.5">
<Label for="translation-filter">{i18n.t("admin.translations.show")}</Label>
<Select id="translation-filter" bind:value={filter}>
<option value="missing">{i18n.t("admin.translations.filterMissing")}</option>
<option value="translated">{i18n.t("admin.translations.filterTranslated")}</option>
<option value="all">{i18n.t("admin.translations.filterAll")}</option>
</Select>
</div>
<div class="min-w-[14rem] flex-1 space-y-1.5">
<Label for="translation-search">{i18n.t("admin.translations.search")}</Label>
<div class="relative">
<Search
class="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
id="translation-search"
class="pl-8"
placeholder={i18n.t("admin.translations.searchPlaceholder")}
bind:value={query}
/>
</div>
</div>
</div>
</div>
{#if rows.length === 0}
<EmptyState
title={i18n.t("empty.admin.noKeysMatchTitle")}
message={i18n.t("empty.admin.noKeysMatchMessage")}
/>
{:else}
<div class="space-y-4">
{#each rows as key (key)}
{@const missing = !(localeDict[key] ?? "").trim()}
<div class="rounded-xl border border-border bg-card/30 p-4 space-y-3">
<div class="flex flex-wrap items-center gap-2">
<code class="text-xs text-foreground">{key}</code>
{#if missing}
<Badge variant="warning">{i18n.t("admin.translations.badgeMissing")}</Badge>
{/if}
{#if dirtyKeys.has(key)}
<Badge variant="secondary">{i18n.t("admin.translations.badgeUnsaved")}</Badge>
{/if}
</div>
<div class="grid gap-3 lg:grid-cols-2">
<div class="space-y-1.5">
<Label for={`en-${key}`}>{i18n.t("admin.translations.english")}</Label>
<Textarea
id={`en-${key}`}
rows={2}
readonly
value={baseDict[key] ?? ""}
class="bg-muted/40"
/>
</div>
<div class="space-y-1.5">
<Label for={`loc-${key}`}>{activeCoverage?.label ?? locale}</Label>
<Textarea
id={`loc-${key}`}
rows={2}
value={draft[key] ?? ""}
oninput={(e) =>
onDraftInput(key, (e.currentTarget as HTMLTextAreaElement).value)}
/>
</div>
</div>
</div>
{/each}
</div>
{/if}
</section>
{/if}
</div>
</PageShell>
{/if}
@@ -0,0 +1,78 @@
import { json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { DEFAULT_UI_LOCALE, UI_LOCALES, isUILocale, normalizeUILocale } from "$lib/i18n/locales";
import {
applyLocaleUpdates,
loadCatalogFromDisk,
MAX_PATCH_BODY_BYTES,
sanitizeUpdates,
sourceOfTruthNote
} from "$lib/server/i18n-messages";
import { readLimitedJsonBody } from "$lib/server/read-limited-json-body";
import {
assertSameOrigin,
requirePlatformAdminServer
} from "$lib/server/require-platform-admin";
export const GET: RequestHandler = async (event) => {
await requirePlatformAdminServer(event);
const disk = await loadCatalogFromDisk();
return json({
base_locale: DEFAULT_UI_LOCALE,
source_of_truth: sourceOfTruthNote(),
locales: UI_LOCALES,
keys: disk.keys,
catalog: disk.catalog,
coverage: disk.coverage
});
};
export const PATCH: RequestHandler = async (event) => {
assertSameOrigin(event);
await requirePlatformAdminServer(event);
const parsed = await readLimitedJsonBody(event.request, MAX_PATCH_BODY_BYTES);
if (!parsed.ok) {
if (parsed.kind === "payload_too_large") {
return json(
{ error: "payload_too_large", message: "Request body too large." },
{ status: 413 }
);
}
return json({ error: "invalid_json", message: "Invalid JSON body." }, { status: 400 });
}
const body = parsed.body;
const record = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
const rawLocale = typeof record.locale === "string" ? record.locale.trim().toLowerCase() : "";
if (!isUILocale(rawLocale)) {
return json({ error: "invalid_locale", message: "Unsupported locale." }, { status: 400 });
}
const locale = normalizeUILocale(rawLocale);
let updates;
try {
updates = sanitizeUpdates(record.updates);
} catch (err) {
return json(
{
error: "invalid_updates",
message: err instanceof Error ? err.message : "Invalid updates."
},
{ status: 400 }
);
}
try {
const messages = await applyLocaleUpdates(locale, updates);
return json({
locale,
messages,
source_of_truth: sourceOfTruthNote()
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to save translations.";
const status = /English key|Unsupported locale|Invalid/.test(message) ? 400 : 500;
return json({ error: "save_failed", message }, { status });
}
};
@@ -0,0 +1,790 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { api, ApiError, failureMessage } from "$lib/api";
import { requirePlatformAdmin } from "$lib/admin-gate";
import { formatCredits, formatDate } from "$lib/utils";
import {
PAGE_SIZE,
STAFF_ROLE_OPTIONS,
assignAdminPlan,
companyPlanBadge,
isStaffRoleApiUnavailable,
listAdminCompanies,
listAdminPlansForAssign,
listAdminUsers,
planOptionLabel,
setAdminStaffRole,
staffRoleBadgeVariant,
staffRoleLabel,
type AdminBillingPlan,
type AdminOrgCompany,
type AdminOrgUser,
type PlatformStaffRole
} from "$lib/admin-orgs";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import ForbiddenEmptyState from "$lib/components/ForbiddenEmptyState.svelte";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Dialog,
Input,
Label,
Select,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableShell,
Tabs,
TabsContent,
TabsList,
TabsTrigger
} from "$lib/components/ui";
import { Building2, KeyRound, Search, Shield, UserPlus, Users } from "@lucide/svelte";
type TabKey = "users" | "companies";
let loading = $state(true);
let accessDenied = $state(false);
let busy = $state(false);
let busyUserId = $state<string | null>(null);
let error = $state("");
let success = $state("");
let tab = $state<TabKey>("users");
let search = $state("");
let staffOnly = $state(false);
let withoutPlan = $state(false);
let withoutApiKeys = $state(false);
let users = $state<AdminOrgUser[]>([]);
let usersTotal = $state(0);
let usersOffset = $state(0);
let companies = $state<AdminOrgCompany[]>([]);
let companiesTotal = $state(0);
let companiesOffset = $state(0);
let plans = $state<AdminBillingPlan[]>([]);
let devTools = $state(false);
let staffRoleApiOk = $state(true);
let roleOpen = $state(false);
let roleUser = $state<AdminOrgUser | null>(null);
let roleValue = $state<"" | PlatformStaffRole>("");
let assignOpen = $state(false);
let assignCompany = $state<AdminOrgCompany | null>(null);
let assignPlanId = $state("");
const usersPage = $derived(Math.floor(usersOffset / PAGE_SIZE) + 1);
const usersPages = $derived(Math.max(1, Math.ceil(usersTotal / PAGE_SIZE)));
const companiesPage = $derived(Math.floor(companiesOffset / PAGE_SIZE) + 1);
const companiesPages = $derived(Math.max(1, Math.ceil(companiesTotal / PAGE_SIZE)));
onMount(async () => {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
if (gate.reason === "auth") {
await goto("/login");
return;
}
if (gate.reason === "forbidden") {
accessDenied = true;
loading = false;
return;
}
error = gate.message;
loading = false;
return;
}
const initialTab = page.url.searchParams.get("tab");
if (initialTab === "companies") tab = "companies";
if (page.url.searchParams.get("without_api_keys") === "1") {
withoutApiKeys = true;
tab = "companies";
}
if (page.url.searchParams.get("without_active_plan") === "1") {
withoutPlan = true;
tab = "companies";
}
try {
await Promise.all([reloadUsers(), reloadCompanies(), loadPlans()]);
// Local-only password/impersonation helpers — never show in production builds.
devTools = !import.meta.env.PROD;
} catch (err) {
error = failureMessage(err, "Failed to load directory");
} finally {
loading = false;
}
});
async function loadPlans() {
try {
plans = await listAdminPlansForAssign();
} catch {
plans = [];
}
}
async function reloadUsers() {
const res = await listAdminUsers({
limit: PAGE_SIZE,
offset: usersOffset,
q: search,
staff_only: staffOnly
});
users = res.users;
usersTotal = res.total;
usersOffset = res.offset;
}
async function reloadCompanies() {
const res = await listAdminCompanies({
limit: PAGE_SIZE,
offset: companiesOffset,
q: search,
without_active_plan: withoutPlan,
without_api_keys: withoutApiKeys
});
companies = res.companies;
companiesTotal = res.total;
companiesOffset = res.offset;
}
async function applySearch() {
busy = true;
error = "";
usersOffset = 0;
companiesOffset = 0;
try {
if (tab === "users") await reloadUsers();
else await reloadCompanies();
} catch (err) {
error = failureMessage(err, "Search failed");
} finally {
busy = false;
}
}
async function changeUsersPage(delta: number) {
const next = Math.max(0, usersOffset + delta * PAGE_SIZE);
if (next >= usersTotal && usersTotal > 0) return;
usersOffset = next;
busy = true;
error = "";
try {
await reloadUsers();
} catch (err) {
error = failureMessage(err, "Failed to load users");
} finally {
busy = false;
}
}
async function changeCompaniesPage(delta: number) {
const next = Math.max(0, companiesOffset + delta * PAGE_SIZE);
if (next >= companiesTotal && companiesTotal > 0) return;
companiesOffset = next;
busy = true;
error = "";
try {
await reloadCompanies();
} catch (err) {
error = failureMessage(err, "Failed to load companies");
} finally {
busy = false;
}
}
async function onTabChange(value: string) {
tab = value === "companies" ? "companies" : "users";
error = "";
busy = true;
try {
if (tab === "users") await reloadUsers();
else await reloadCompanies();
} catch (err) {
error = failureMessage(err, "Failed to load");
} finally {
busy = false;
}
}
async function toggleStaffOnly() {
staffOnly = !staffOnly;
usersOffset = 0;
busy = true;
error = "";
try {
await reloadUsers();
} catch (err) {
error = failureMessage(err, "Failed to filter users");
} finally {
busy = false;
}
}
async function toggleWithoutPlan() {
const next = !withoutPlan;
withoutPlan = next;
companiesOffset = 0;
busy = true;
error = "";
try {
await reloadCompanies();
// Keep toggle in sync if a concurrent reload raced (should be rare).
withoutPlan = next;
} catch (err) {
withoutPlan = !next;
error = failureMessage(err, "Failed to filter companies");
} finally {
busy = false;
}
}
async function toggleWithoutApiKeys() {
const next = !withoutApiKeys;
withoutApiKeys = next;
companiesOffset = 0;
busy = true;
error = "";
try {
await reloadCompanies();
withoutApiKeys = next;
} catch (err) {
withoutApiKeys = !next;
error = failureMessage(err, "Failed to filter companies");
} finally {
busy = false;
}
}
function openRoleDialog(user: AdminOrgUser) {
roleUser = user;
const resolved = (user.staff_role || user.resolved_role || "") as "" | PlatformStaffRole;
roleValue =
resolved === "admin" || resolved === "developer" || resolved === "support_staff"
? resolved
: "";
roleOpen = true;
}
async function saveStaffRole(event: Event) {
event.preventDefault();
if (!roleUser) return;
busy = true;
error = "";
success = "";
try {
const updated = await setAdminStaffRole(roleUser.id, roleValue);
users = users.map((u) =>
u.id === updated.id
? {
...u,
...updated,
staff_role: updated.staff_role,
resolved_role: updated.resolved_role,
is_platform_admin: updated.is_platform_admin
}
: u
);
success = i18n.t("flash.admin.staffRoleUpdated", { email: updated.email });
roleOpen = false;
staffRoleApiOk = true;
} catch (err) {
if (isStaffRoleApiUnavailable(err)) {
staffRoleApiOk = false;
error = i18n.t("flash.admin.staffRoleUnavailable");
} else {
error = failureMessage(err, "Could not update staff role");
}
} finally {
busy = false;
}
}
function openAssignDialog(company: AdminOrgCompany) {
assignCompany = company;
assignPlanId = company.plan_id != null ? String(company.plan_id) : "";
assignOpen = true;
}
async function saveAssignPlan(event: Event) {
event.preventDefault();
if (!assignCompany || !assignPlanId) return;
busy = true;
error = "";
success = "";
try {
await assignAdminPlan({
company_id: assignCompany.id,
plan_id: Number(assignPlanId)
});
success = i18n.t("flash.admin.planAssigned", { name: assignCompany.name });
assignOpen = false;
await reloadCompanies();
} catch (err) {
error = failureMessage(err, "Assign plan failed");
} finally {
busy = false;
}
}
async function sendSetPasswordEmails(userId?: string) {
busy = true;
error = "";
success = "";
try {
const body = userId ? { user_id: userId } : {};
const res = await api<{
sent: number;
issued?: number;
skipped: number;
skipped_synthetic?: number;
skipped_rate_limited?: number;
smtp_enabled: boolean;
token?: string;
}>("/api/admin/emails/set-password", { method: "POST", body });
const parts = [
`sent ${res.sent}`,
`issued ${res.issued ?? 0}`,
`skipped ${res.skipped}`
];
if (res.skipped_synthetic) parts.push(`skipped test accounts ${res.skipped_synthetic}`);
if (res.skipped_rate_limited) parts.push(`rate-limited ${res.skipped_rate_limited}`);
success = i18n.t("flash.admin.invitesSummary", { parts: parts.join(", "), smtp: res.smtp_enabled ? i18n.t("flash.admin.smtpOn") : i18n.t("flash.admin.smtpOff") });
if (res.token) {
success += " Invite link available — copy and share it securely (email delivery is off).";
}
} catch (err) {
error = failureMessage(err, "Send failed");
} finally {
busy = false;
}
}
async function setDevPassword(userId: string) {
busyUserId = userId;
error = "";
success = "";
try {
const res = await api<{ email?: string }>(`/api/admin/users/${userId}/dev-password`, {
method: "POST",
body: {}
});
success = i18n.t("flash.admin.localPasswordSet", { email: res.email ?? "user" });
users = users.map((u) => (u.id === userId ? { ...u, must_set_password: false } : u));
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
devTools = false;
error = i18n.t("flash.admin.localPasswordUnavailable");
} else {
error = failureMessage(err, "Could not set local password");
}
} finally {
busyUserId = null;
}
}
async function switchToUser(userId: string) {
busyUserId = userId;
error = "";
success = "";
try {
await api(`/api/admin/users/${userId}/impersonate`, { method: "POST", body: {} });
success = i18n.t("flash.admin.switchedUser");
window.location.assign("/dashboard");
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
devTools = false;
error = i18n.t("flash.admin.switchUnavailable");
} else {
error = failureMessage(err, "Could not switch user");
}
busyUserId = null;
}
}
function displayRole(user: AdminOrgUser): string {
return staffRoleLabel(user.staff_role || user.resolved_role);
}
</script>
<PageShell
title={i18n.t("admin.users.title")}
description={i18n.t("admin.users.description")}
eyebrow={i18n.t("admin.users.eyebrow")}
>
{#snippet actions()}
<Button size="sm" variant="outline" loading={busy} onclick={() => sendSetPasswordEmails()}>
<UserPlus class="mr-2 h-4 w-4" />
Re-issue set-password invites
</Button>
{/snippet}
{#if loading}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else}
<Alert message={error} />
<Alert tone="success" message={success} />
{#if !staffRoleApiOk}
<Alert
tone="info"
message={i18n.t("admin.users.staffRoleUnavailable")}
/>
{/if}
<div class="mb-4 flex flex-wrap items-end gap-3">
<div class="relative min-w-0 flex-1 basis-full sm:basis-auto">
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder={tab === "users" ? "Search users..." : "Search companies..."}
class="h-9 pl-8"
aria-label={tab === "users" ? "Search users" : "Search companies"}
bind:value={search}
onkeydown={(e) => {
if (e.key === "Enter") void applySearch();
}}
/>
</div>
<Button size="sm" variant="outline" loading={busy} onclick={() => applySearch()}>{i18n.t("admin.users.search")}</Button>
{#if tab === "users"}
<Button
size="sm"
variant={staffOnly ? "default" : "outline"}
aria-pressed={staffOnly}
onclick={() => toggleStaffOnly()}
>
Staff only
</Button>
{:else}
<Button
size="sm"
variant={withoutPlan ? "default" : "outline"}
aria-pressed={withoutPlan}
onclick={() => toggleWithoutPlan()}
>
{i18n.t("admin.users.filterWithoutPlan")}
</Button>
<Button
size="sm"
variant={withoutApiKeys ? "default" : "outline"}
aria-pressed={withoutApiKeys}
data-testid="admin-companies-without-api-keys"
onclick={() => toggleWithoutApiKeys()}
>
{i18n.t("admin.users.filterWithoutApiKeys")}
</Button>
{/if}
</div>
<Tabs
value={tab}
onValueChange={(v) => onTabChange(v)}
class="mb-8 w-full"
>
<TabsList class="mb-4 grid w-full max-w-md grid-cols-2">
<TabsTrigger value="users">{i18n.t("admin.users.tabUsers")}</TabsTrigger>
<TabsTrigger value="companies">{i18n.t("admin.users.tabCompanies")}</TabsTrigger>
</TabsList>
<TabsContent value="users" class="mt-0">
<Card>
<CardHeader class="pb-2">
<CardTitle>Users ({formatCredits(usersTotal)})</CardTitle>
<CardDescription>
Assign platform staff roles (Admin, Developer, or Support staff).
</CardDescription>
</CardHeader>
<CardContent>
{#if users.length === 0}
<EmptyState message={i18n.t("empty.admin.noUsersMatch")} />
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.users.colName")}</TableHead>
<TableHead class="hidden md:table-cell">{i18n.t("admin.users.colStaffRole")}</TableHead>
<TableHead class="hidden lg:table-cell">{i18n.t("admin.users.colStatus")}</TableHead>
<TableHead class="hidden lg:table-cell">{i18n.t("admin.users.colCreated")}</TableHead>
<TableHead stickyRight>{i18n.t("admin.users.colActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each users as user}
{@const role = user.staff_role || user.resolved_role}
<TableRow>
<TableCell>
<p class="font-medium text-foreground">{user.name || "—"}</p>
<p class="text-xs text-muted-foreground">{user.email}</p>
<div class="mt-1 flex flex-wrap gap-1 md:hidden">
<Badge variant={staffRoleBadgeVariant(role)}>{displayRole(user)}</Badge>
{#if user.must_set_password}
<Badge variant="warning">{i18n.t("admin.users.mustSetPassword")}</Badge>
{/if}
{#if user.is_active === false}
<Badge variant="outline">{i18n.t("admin.users.inactive")}</Badge>
{/if}
</div>
</TableCell>
<TableCell class="hidden md:table-cell">
<Badge variant={staffRoleBadgeVariant(role)}>{displayRole(user)}</Badge>
{#if user.must_set_password}
<Badge variant="warning" class="ml-1">{i18n.t("admin.users.mustSetPassword")}</Badge>
{/if}
</TableCell>
<TableCell class="hidden lg:table-cell">
{#if user.is_active !== false}
<span class="inline-flex items-center gap-2 text-sm">
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>
Active
</span>
{:else}
<Badge variant="outline">{i18n.t("admin.users.inactive")}</Badge>
{/if}
</TableCell>
<TableCell class="hidden text-muted-foreground lg:table-cell">{formatDate(user.created_at)}</TableCell>
<TableCell stickyRight>
<div class="flex flex-wrap items-center justify-end gap-1">
<Button
size="sm"
variant="outline"
onclick={() => openRoleDialog(user)}
aria-label={`Staff role for ${user.email}`}
>
<Shield class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.users.colStaffRole")}</span>
</Button>
{#if user.must_set_password}
<Button
size="sm"
variant="outline"
loading={busy}
onclick={() => sendSetPasswordEmails(user.id)}
aria-label={`Re-issue invite for ${user.email}`}
>
<UserPlus class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.users.reissueInvite")}</span>
</Button>
{/if}
{#if devTools}
<Button
size="sm"
variant="outline"
loading={busyUserId === user.id}
onclick={() => setDevPassword(user.id)}
aria-label={`Set local password for ${user.email}`}
>
<KeyRound class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.users.setLocalPassword")}</span>
</Button>
<Button
size="sm"
loading={busyUserId === user.id}
onclick={() => switchToUser(user.id)}
aria-label={`Switch to ${user.email}`}
>
<Users class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.users.switchToUser")}</span>
</Button>
{/if}
</div>
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
<div class="mt-4 flex items-center justify-between gap-3 text-sm text-muted-foreground">
<span>
Page {usersPage} of {usersPages} · {formatCredits(usersTotal)} total
</span>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={usersOffset <= 0 || busy}
onclick={() => changeUsersPage(-1)}
>
Previous
</Button>
<Button
size="sm"
variant="outline"
disabled={usersOffset + PAGE_SIZE >= usersTotal || busy}
onclick={() => changeUsersPage(1)}
>
Next
</Button>
</div>
</div>
{/if}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="companies" class="mt-0">
<Card>
<CardHeader class="pb-2">
<div class="flex items-center gap-2">
<Building2 class="h-5 w-5 text-muted-foreground" />
<CardTitle>Companies ({formatCredits(companiesTotal)})</CardTitle>
</div>
<CardDescription>
View active plan (public / legacy / custom) and assign a plan. Credits from company balance.
</CardDescription>
</CardHeader>
<CardContent>
{#if companies.length === 0}
<EmptyState message={i18n.t("empty.admin.noCompaniesMatchFilter")} />
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.users.colCompany")}</TableHead>
<TableHead>{i18n.t("admin.users.colPlan")}</TableHead>
<TableHead class="hidden sm:table-cell">{i18n.t("admin.users.colCredits")}</TableHead>
<TableHead class="hidden lg:table-cell">{i18n.t("admin.users.colLanguage")}</TableHead>
<TableHead stickyRight>{i18n.t("admin.users.colActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each companies as company}
{@const planBadge = companyPlanBadge(company)}
{@const total = Number(company.total_credits ?? 0)}
{@const used = Number(company.used_credits ?? 0)}
{@const remaining = Math.max(total - used, 0)}
<TableRow>
<TableCell>
<p class="font-medium text-foreground">{company.name}</p>
<p class="truncate font-mono text-xs text-muted-foreground" title={company.id}>
{company.id}
</p>
<p class="mt-1 text-xs tabular-nums text-muted-foreground sm:hidden">
{formatCredits(remaining)} / {formatCredits(total)} credits
</p>
</TableCell>
<TableCell>
<div class="flex flex-wrap items-center gap-1.5">
<Badge variant={planBadge.variant}>{planBadge.label}</Badge>
{#if company.has_api_key === false}
<Badge variant="warning">{i18n.t("admin.users.needsApiKeyReissue")}</Badge>
{/if}
</div>
</TableCell>
<TableCell class="hidden tabular-nums text-foreground sm:table-cell">
{formatCredits(remaining)}
<span class="text-xs text-muted-foreground">
/ {formatCredits(total)}
</span>
</TableCell>
<TableCell class="hidden text-muted-foreground lg:table-cell">{company.language || "—"}</TableCell>
<TableCell stickyRight>
<Button
size="sm"
onclick={() => openAssignDialog(company)}
aria-label={`Assign plan to ${company.name}`}
>
<UserPlus class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.users.assignPlan")}</span>
</Button>
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
<div class="mt-4 flex items-center justify-between gap-3 text-sm text-muted-foreground">
<span>
Page {companiesPage} of {companiesPages} · {formatCredits(companiesTotal)} total
</span>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={companiesOffset <= 0 || busy}
onclick={() => changeCompaniesPage(-1)}
>
Previous
</Button>
<Button
size="sm"
variant="outline"
disabled={companiesOffset + PAGE_SIZE >= companiesTotal || busy}
onclick={() => changeCompaniesPage(1)}
>
Next
</Button>
</div>
</div>
{/if}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/if}
</PageShell>
<Dialog
bind:open={roleOpen}
title={i18n.t("admin.users.assignRoleTitle")}
description={i18n.t("admin.users.assignRoleDesc")}
>
<form class="space-y-4" onsubmit={saveStaffRole}>
{#if error}
<p class="text-sm text-destructive" role="alert">{error}</p>
{/if}
{#if roleUser}
<p class="text-sm text-muted-foreground">
{roleUser.name || "—"} · {roleUser.email}
</p>
{/if}
<div class="space-y-2">
<Label for="staff-role">{i18n.t("admin.users.staffRole")}</Label>
<Select id="staff-role" bind:value={roleValue}>
{#each STAFF_ROLE_OPTIONS as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</Select>
</div>
<p class="text-xs text-muted-foreground">
You cannot change your own staff role. Clearing a role removes platform admin access.
</p>
<Button type="submit" loading={busy}>{i18n.t("admin.users.saveRole")}</Button>
</form>
</Dialog>
<Dialog
bind:open={assignOpen}
title={i18n.t("admin.users.assignPlanTitle")}
description={i18n.t("admin.users.assignPlanDesc")}
>
<form class="space-y-4" onsubmit={saveAssignPlan}>
{#if error}
<p class="text-sm text-destructive" role="alert">{error}</p>
{/if}
{#if assignCompany}
<p class="text-sm text-muted-foreground">{assignCompany.name}</p>
{/if}
<div class="space-y-2">
<Label for="assign-plan">{i18n.t("admin.users.colPlan")}</Label>
<Select id="assign-plan" bind:value={assignPlanId} required>
<option value="">{i18n.t("admin.users.selectPlan")}</option>
{#each plans as p}
<option value={String(p.id)}>{planOptionLabel(p)}</option>
{/each}
</Select>
</div>
<Button type="submit" loading={busy} disabled={!assignPlanId}>{i18n.t("admin.users.assign")}</Button>
</form>
</Dialog>