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:
@@ -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>
|
||||
Reference in New Issue
Block a user