This commit is contained in:
2026-08-23 22:52:13 +02:00
parent 8983cfc8a1
commit 296dc3c841
32 changed files with 2025 additions and 52 deletions
+6
View File
@@ -38,6 +38,12 @@ export const ADMIN_NAV_ROUTES: readonly AdminNavRoute[] = [
group: "ops",
fullAdminOnly: true
},
{
titleKey: "admin.nav.aiCosts",
href: "/admin/ai-costs",
group: "ops",
fullAdminOnly: true
},
{
titleKey: "admin.nav.stuckProducts",
href: "/admin/stuck-products",
@@ -30,6 +30,7 @@
ArrowLeft,
FileWarning,
MessagesSquare,
Wallet,
LogOut,
X
} from "@lucide/svelte";
@@ -42,6 +43,7 @@
"/admin/support/knowledge": BookOpen,
"/admin/diagnostics": Activity,
"/admin/ai-calls": MessagesSquare,
"/admin/ai-costs": Wallet,
"/admin/stuck-products": ClipboardList,
"/admin/orphan-processed": FileWarning,
"/admin/billing": CreditCard,
@@ -411,6 +411,47 @@
return unique.length === rows.length ? rows : unique;
});
// --- Review diffs -------------------------------------------------------
// Category the feed supplied vs the one processing assigned. Most catalogs ship
// no category at all, so "(none) -> Stolcki" is the change worth showing.
const feedCategoryRaw = $derived(String(product?.feed_category ?? "").trim());
const feedCategoryLabel = $derived.by(() => {
if (!feedCategoryRaw) return "";
const match = findCategoryOption(categories, feedCategoryRaw);
return match?.name || feedCategoryRaw;
});
const assignedCategoryLabel = $derived.by(() => {
if (!category || category === "none") return "";
const match = findCategoryOption(categories, category);
return match?.name || categoryFieldDisplayValue(categories, product, category) || category;
});
const categoryChanged = $derived(
kind === "processed" &&
assignedCategoryLabel.trim().toLowerCase() !== feedCategoryLabel.trim().toLowerCase()
);
/** Per-key attribute diff: what enrichment added or rewrote versus the feed. */
const attrDiff = $derived.by(() => {
const before = new Map(
productAttrEntries(product?.attributes).map((a) => [a.key.toLowerCase(), a])
);
const after = new Map(
productAttrEntries(product?.processed_attributes).map((a) => [a.key.toLowerCase(), a])
);
const keys = [...new Set([...before.keys(), ...after.keys()])].sort();
const rows = keys.map((k) => {
const b = before.get(k);
const a = after.get(k);
const state = !b ? "added" : !a ? "removed" : b.value === a.value ? "same" : "changed";
return { key: (a ?? b)?.key ?? k, before: b?.value ?? "", after: a?.value ?? "", state };
});
return {
rows,
changed: rows.filter((r) => r.state !== "same")
};
});
const attrsChanged = $derived(kind === "processed" && attrDiff.changed.length > 0);
const feedAttrs = $derived.by(() => {
const rows = productFeedAttributeEntries(product);
const covered = new Set(
@@ -748,26 +789,97 @@
{/if}
</div>
<div class="space-y-2">
<Label for="review-category">{i18n.t("products.enrichment.piece.category")}</Label>
<select
id="review-category"
class="flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm shadow-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
bind:value={category}
onchange={markDirty}
>
<option value="none">{i18n.t("products.edit.noCategory")}</option>
{#each categories as cat}
<option value={cat.uniqueId}>{cat.name}</option>
{/each}
{#if category !== "none" && !categories.some((c) => c.uniqueId === category)}
<option value={category}
>{categoryFieldDisplayValue(categories, product, category)}</option
>
<div
class="space-y-2 rounded-md border border-transparent p-1 {categoryChanged
? 'border-chart-amber/40 bg-chart-amber/10'
: ''}"
>
<div class="flex items-center justify-between gap-2">
<Label for="review-category">{i18n.t("products.enrichment.piece.category")}</Label>
{#if categoryChanged}
<Badge variant="outline" class="text-[10px]">{i18n.t("products.edit.changed")}</Badge>
{:else}
<Badge variant="secondary" class="text-[10px]">{i18n.t("products.edit.matched")}</Badge>
{/if}
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="space-y-1.5">
<p class="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{i18n.t("products.edit.original")}
</p>
<div
class="min-h-10 rounded-md border border-border bg-muted/30 px-3 py-2 text-sm text-muted-foreground"
>
{feedCategoryLabel || i18n.t("products.edit.noCategoryFromFeed")}
</div>
</div>
<div class="space-y-1.5">
<p class="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{i18n.t("products.edit.enriched")}
</p>
<select
id="review-category"
class="flex h-10 w-full rounded-md border border-border bg-muted/50 px-3 py-2 text-sm shadow-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
bind:value={category}
onchange={markDirty}
>
<option value="none">{i18n.t("products.edit.noCategory")}</option>
{#each categories as cat}
<option value={cat.uniqueId}>{cat.name}</option>
{/each}
{#if category !== "none" && !categories.some((c) => c.uniqueId === category)}
<option value={category}
>{categoryFieldDisplayValue(categories, product, category)}</option
>
{/if}
</select>
</div>
</div>
</div>
<div
class="space-y-2 rounded-md border border-transparent p-1 {attrsChanged
? 'border-chart-amber/40 bg-chart-amber/10'
: ''}"
>
<div class="flex items-center justify-between gap-2">
<Label>{i18n.t("products.enrichment.piece.attributes")}</Label>
{#if attrsChanged}
<Badge variant="outline" class="text-[10px]">
{i18n.t("products.edit.attrsChangedCount", { count: attrDiff.changed.length })}
</Badge>
{:else}
<Badge variant="secondary" class="text-[10px]">{i18n.t("products.edit.matched")}</Badge>
{/if}
</div>
{#if attrDiff.rows.length === 0}
<p class="text-xs text-muted-foreground">{i18n.t("products.edit.noAttributes")}</p>
{:else}
<div class="overflow-x-auto rounded-md border border-border">
<table class="w-full text-xs">
<thead class="bg-muted/40 text-muted-foreground">
<tr>
<th class="px-2 py-1.5 text-left font-medium">{i18n.t("products.edit.attrKey")}</th>
<th class="px-2 py-1.5 text-left font-medium">{i18n.t("products.edit.original")}</th>
<th class="px-2 py-1.5 text-left font-medium">{i18n.t("products.edit.enriched")}</th>
</tr>
</thead>
<tbody>
{#each attrDiff.rows as row (row.key)}
<tr class="border-t border-border {row.state === 'same' ? '' : 'bg-chart-amber/5'}">
<td class="px-2 py-1.5 font-mono">{row.key}</td>
<td class="px-2 py-1.5 text-muted-foreground">{row.before || "—"}</td>
<td class="px-2 py-1.5 {row.state === 'same' ? 'text-muted-foreground' : 'font-medium text-foreground'}">
{row.after || "—"}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</select>
</div>
</TabsContent>
</div>
</TabsContent>
{/if}
<TabsContent value="details" class="mt-4 space-y-5">
+31 -2
View File
@@ -42,6 +42,12 @@ export type ProductRow = {
id: string | number;
product_id?: string | null;
name?: string | null;
/**
* Pre-enrichment name. `name` is the API's display-preferred value and resolves
* to the enriched copy when one exists, so a review diff MUST use this instead
* comparing against `name` showed the AI output on both sides.
*/
original_name?: string | null;
processed_name?: string | null;
title?: string | null;
sku?: string | null;
@@ -51,6 +57,8 @@ export type ProductRow = {
category_name?: string | null;
/** Canonical categories.unique_id when resolvable. */
category_unique_id?: string | null;
/** Category the feed supplied, before categorize assigned one. */
feed_category?: string | null;
status?: string | null;
processing_status?: string | null;
raw_product_id?: string | null;
@@ -59,6 +67,8 @@ export type ProductRow = {
feed_last_synced_at?: string | null;
raw_updated_at?: string | null;
description?: string | null;
/** Pre-enrichment description (see original_name). */
original_description?: string | null;
processed_description?: string | null;
meta_title?: string | null;
meta_description?: string | null;
@@ -510,7 +520,16 @@ export function productAttrEntries(raw: unknown): { key: string; value: string }
/** Original feed description when processed_products.description was never filled (legacy). */
export function resolveOriginalDescription(product: ProductRow | null | undefined): string {
if (!product) return "";
if (typeof product.description === "string" && product.description.trim() !== "") {
if (typeof product.original_description === "string" && product.original_description.trim() !== "") {
return product.original_description;
}
// Fall back to `description` only for rows that carry no enrichment (raw products,
// older API shapes) — for processed rows it is the enriched copy.
if (
typeof product.description === "string" &&
product.description.trim() !== "" &&
product.description !== product.processed_description
) {
return product.description;
}
const mapped = asRecord(product.mapped_data);
@@ -525,7 +544,17 @@ export function resolveOriginalDescription(product: ProductRow | null | undefine
/** Original feed name when processed_products.name was never filled (legacy). */
export function resolveOriginalName(product: ProductRow | null | undefined): string {
if (!product) return "";
if (typeof product.name === "string" && product.name.trim() !== "") return product.name;
if (typeof product.original_name === "string" && product.original_name.trim() !== "") {
return product.original_name;
}
// See resolveOriginalDescription: `name` is display-preferred, not the original.
if (
typeof product.name === "string" &&
product.name.trim() !== "" &&
product.name !== product.processed_name
) {
return product.name;
}
if (typeof product.title === "string" && product.title.trim() !== "") return product.title;
const mapped = asRecord(product.mapped_data);
if (!mapped) return "";
+30
View File
@@ -5742,4 +5742,34 @@ export const de: MessageDict = {
"admin.aiCalls.loadFailed": "KI-Aufrufe konnten nicht geladen werden.",
"admin.aiCalls.copyFailed": "Kopieren fehlgeschlagen.",
"common.copy": "Kopieren",
"products.edit.noCategoryFromFeed": "Keine Kategorie im Feed",
"products.edit.attrsChangedCount": "{count} geaendert",
"products.edit.attrKey": "Attribut",
"products.edit.noAttributes": "Dieses Produkt hat keine Attribute.",
"admin.nav.aiCosts": "KI-Kosten",
"admin.aiCosts.title": "KI-Kosten",
"admin.aiCosts.description": "Was jeder Mandant und Benutzer kostet. {days} Tage aufbewahrt.",
"admin.aiCosts.loadFailed": "KI-Kosten konnten nicht geladen werden.",
"admin.aiCosts.totalTitle": "Gesamtausgaben",
"admin.aiCosts.rangeLabel": "{from} bis {to}",
"admin.aiCosts.range": "Zeitraum",
"admin.aiCosts.lastNDays": "Letzte {count} Tage",
"admin.aiCosts.grandTotal": "Gesamtsumme",
"admin.aiCosts.calls": "Aufrufe",
"admin.aiCosts.failedCalls": "{count} fehlgeschlagen",
"admin.aiCosts.tokens": "Token",
"admin.aiCosts.cachedTokens": "{count} zwischengespeichert",
"admin.aiCosts.tenants": "Mandanten",
"admin.aiCosts.usersCount": "{count} Benutzer",
"admin.aiCosts.byUser": "Kosten pro Benutzer",
"admin.aiCosts.byCompany": "Kosten pro Firma",
"admin.aiCosts.byModel": "Kosten pro Modell",
"admin.aiCosts.emptyTitle": "Keine Ausgaben erfasst",
"admin.aiCosts.emptyBody": "In diesem Zeitraum wurde nichts berechnet.",
"admin.aiCosts.colName": "Name",
"admin.aiCosts.colCompany": "Firma",
"admin.aiCosts.colIn": "Eingabe",
"admin.aiCosts.colOut": "Ausgabe",
"admin.aiCosts.colCost": "Kosten",
"admin.aiCosts.unattributed": "Nicht zugeordnet",
};
+30
View File
@@ -5828,4 +5828,34 @@ export const en: MessageDict = {
"admin.aiCalls.loadFailed": "Could not load AI calls.",
"admin.aiCalls.copyFailed": "Could not copy to clipboard.",
"common.copy": "Copy",
"products.edit.noCategoryFromFeed": "No category in feed",
"products.edit.attrsChangedCount": "{count} changed",
"products.edit.attrKey": "Attribute",
"products.edit.noAttributes": "No attributes on this product.",
"admin.nav.aiCosts": "AI costs",
"admin.aiCosts.title": "AI costs",
"admin.aiCosts.description": "What each tenant and user costs to run. Kept for {days} days.",
"admin.aiCosts.loadFailed": "Could not load AI costs.",
"admin.aiCosts.totalTitle": "Total spend",
"admin.aiCosts.rangeLabel": "{from} to {to}",
"admin.aiCosts.range": "Range",
"admin.aiCosts.lastNDays": "Last {count} days",
"admin.aiCosts.grandTotal": "Grand total",
"admin.aiCosts.calls": "Calls",
"admin.aiCosts.failedCalls": "{count} failed",
"admin.aiCosts.tokens": "Tokens",
"admin.aiCosts.cachedTokens": "{count} cached",
"admin.aiCosts.tenants": "Tenants",
"admin.aiCosts.usersCount": "{count} users",
"admin.aiCosts.byUser": "Cost by user",
"admin.aiCosts.byCompany": "Cost by company",
"admin.aiCosts.byModel": "Cost by model",
"admin.aiCosts.emptyTitle": "No spend recorded",
"admin.aiCosts.emptyBody": "Nothing was billed in this range.",
"admin.aiCosts.colName": "Name",
"admin.aiCosts.colCompany": "Company",
"admin.aiCosts.colIn": "Input",
"admin.aiCosts.colOut": "Output",
"admin.aiCosts.colCost": "Cost",
"admin.aiCosts.unattributed": "Unattributed",
};
+30
View File
@@ -5751,4 +5751,34 @@ export const es: MessageDict = {
"admin.aiCalls.loadFailed": "No se pudieron cargar las llamadas de IA.",
"admin.aiCalls.copyFailed": "No se pudo copiar al portapapeles.",
"common.copy": "Copiar",
"products.edit.noCategoryFromFeed": "Sin categoria en el feed",
"products.edit.attrsChangedCount": "{count} modificados",
"products.edit.attrKey": "Atributo",
"products.edit.noAttributes": "Este producto no tiene atributos.",
"admin.nav.aiCosts": "Costes de IA",
"admin.aiCosts.title": "Costes de IA",
"admin.aiCosts.description": "Lo que cuesta cada inquilino y usuario. Se guarda {days} dias.",
"admin.aiCosts.loadFailed": "No se pudieron cargar los costes de IA.",
"admin.aiCosts.totalTitle": "Gasto total",
"admin.aiCosts.rangeLabel": "{from} a {to}",
"admin.aiCosts.range": "Periodo",
"admin.aiCosts.lastNDays": "Ultimos {count} dias",
"admin.aiCosts.grandTotal": "Total general",
"admin.aiCosts.calls": "Llamadas",
"admin.aiCosts.failedCalls": "{count} fallidas",
"admin.aiCosts.tokens": "Tokens",
"admin.aiCosts.cachedTokens": "{count} en cache",
"admin.aiCosts.tenants": "Inquilinos",
"admin.aiCosts.usersCount": "{count} usuarios",
"admin.aiCosts.byUser": "Coste por usuario",
"admin.aiCosts.byCompany": "Coste por empresa",
"admin.aiCosts.byModel": "Coste por modelo",
"admin.aiCosts.emptyTitle": "Sin gasto registrado",
"admin.aiCosts.emptyBody": "No se facturo nada en este periodo.",
"admin.aiCosts.colName": "Nombre",
"admin.aiCosts.colCompany": "Empresa",
"admin.aiCosts.colIn": "Entrada",
"admin.aiCosts.colOut": "Salida",
"admin.aiCosts.colCost": "Coste",
"admin.aiCosts.unattributed": "Sin asignar",
};
+30
View File
@@ -5751,4 +5751,34 @@ export const fr: MessageDict = {
"admin.aiCalls.loadFailed": "Impossible de charger les appels IA.",
"admin.aiCalls.copyFailed": "Copie dans le presse-papiers impossible.",
"common.copy": "Copier",
"products.edit.noCategoryFromFeed": "Aucune categorie dans le flux",
"products.edit.attrsChangedCount": "{count} modifies",
"products.edit.attrKey": "Attribut",
"products.edit.noAttributes": "Ce produit n a pas d attributs.",
"admin.nav.aiCosts": "Couts IA",
"admin.aiCosts.title": "Couts IA",
"admin.aiCosts.description": "Ce que coute chaque locataire et utilisateur. Conserve {days} jours.",
"admin.aiCosts.loadFailed": "Impossible de charger les couts IA.",
"admin.aiCosts.totalTitle": "Depense totale",
"admin.aiCosts.rangeLabel": "Du {from} au {to}",
"admin.aiCosts.range": "Periode",
"admin.aiCosts.lastNDays": "{count} derniers jours",
"admin.aiCosts.grandTotal": "Total general",
"admin.aiCosts.calls": "Appels",
"admin.aiCosts.failedCalls": "{count} en echec",
"admin.aiCosts.tokens": "Jetons",
"admin.aiCosts.cachedTokens": "{count} en cache",
"admin.aiCosts.tenants": "Locataires",
"admin.aiCosts.usersCount": "{count} utilisateurs",
"admin.aiCosts.byUser": "Cout par utilisateur",
"admin.aiCosts.byCompany": "Cout par entreprise",
"admin.aiCosts.byModel": "Cout par modele",
"admin.aiCosts.emptyTitle": "Aucune depense enregistree",
"admin.aiCosts.emptyBody": "Rien n a ete facture sur cette periode.",
"admin.aiCosts.colName": "Nom",
"admin.aiCosts.colCompany": "Entreprise",
"admin.aiCosts.colIn": "Entree",
"admin.aiCosts.colOut": "Sortie",
"admin.aiCosts.colCost": "Cout",
"admin.aiCosts.unattributed": "Non attribue",
};
+30
View File
@@ -5751,4 +5751,34 @@ export const it: MessageDict = {
"admin.aiCalls.loadFailed": "Impossibile caricare le chiamate IA.",
"admin.aiCalls.copyFailed": "Impossibile copiare negli appunti.",
"common.copy": "Copia",
"products.edit.noCategoryFromFeed": "Nessuna categoria nel feed",
"products.edit.attrsChangedCount": "{count} modificati",
"products.edit.attrKey": "Attributo",
"products.edit.noAttributes": "Questo prodotto non ha attributi.",
"admin.nav.aiCosts": "Costi IA",
"admin.aiCosts.title": "Costi IA",
"admin.aiCosts.description": "Quanto costa ogni tenant e utente. Conservato {days} giorni.",
"admin.aiCosts.loadFailed": "Impossibile caricare i costi IA.",
"admin.aiCosts.totalTitle": "Spesa totale",
"admin.aiCosts.rangeLabel": "Dal {from} al {to}",
"admin.aiCosts.range": "Periodo",
"admin.aiCosts.lastNDays": "Ultimi {count} giorni",
"admin.aiCosts.grandTotal": "Totale generale",
"admin.aiCosts.calls": "Chiamate",
"admin.aiCosts.failedCalls": "{count} fallite",
"admin.aiCosts.tokens": "Token",
"admin.aiCosts.cachedTokens": "{count} in cache",
"admin.aiCosts.tenants": "Tenant",
"admin.aiCosts.usersCount": "{count} utenti",
"admin.aiCosts.byUser": "Costo per utente",
"admin.aiCosts.byCompany": "Costo per azienda",
"admin.aiCosts.byModel": "Costo per modello",
"admin.aiCosts.emptyTitle": "Nessuna spesa registrata",
"admin.aiCosts.emptyBody": "Nulla e stato addebitato in questo periodo.",
"admin.aiCosts.colName": "Nome",
"admin.aiCosts.colCompany": "Azienda",
"admin.aiCosts.colIn": "Input",
"admin.aiCosts.colOut": "Output",
"admin.aiCosts.colCost": "Costo",
"admin.aiCosts.unattributed": "Non attribuito",
};
+30
View File
@@ -5751,4 +5751,34 @@ export const ja: MessageDict = {
"admin.aiCalls.loadFailed": "AI 呼び出しを読み込めませんでした。",
"admin.aiCalls.copyFailed": "クリップボードにコピーできませんでした。",
"common.copy": "コピー",
"products.edit.noCategoryFromFeed": "フィードにカテゴリーがありません",
"products.edit.attrsChangedCount": "{count} 件変更",
"products.edit.attrKey": "属性",
"products.edit.noAttributes": "この商品には属性がありません。",
"admin.nav.aiCosts": "AI コスト",
"admin.aiCosts.title": "AI コスト",
"admin.aiCosts.description": "各テナントとユーザーのコスト。{days} 日間保持されます。",
"admin.aiCosts.loadFailed": "AI コストを読み込めませんでした。",
"admin.aiCosts.totalTitle": "合計支出",
"admin.aiCosts.rangeLabel": "{from} 〜 {to}",
"admin.aiCosts.range": "期間",
"admin.aiCosts.lastNDays": "過去 {count} 日",
"admin.aiCosts.grandTotal": "総計",
"admin.aiCosts.calls": "呼び出し",
"admin.aiCosts.failedCalls": "{count} 件失敗",
"admin.aiCosts.tokens": "トークン",
"admin.aiCosts.cachedTokens": "{count} 件キャッシュ",
"admin.aiCosts.tenants": "テナント",
"admin.aiCosts.usersCount": "{count} ユーザー",
"admin.aiCosts.byUser": "ユーザー別コスト",
"admin.aiCosts.byCompany": "会社別コスト",
"admin.aiCosts.byModel": "モデル別コスト",
"admin.aiCosts.emptyTitle": "支出の記録なし",
"admin.aiCosts.emptyBody": "この期間の課金はありません。",
"admin.aiCosts.colName": "名前",
"admin.aiCosts.colCompany": "会社",
"admin.aiCosts.colIn": "入力",
"admin.aiCosts.colOut": "出力",
"admin.aiCosts.colCost": "コスト",
"admin.aiCosts.unattributed": "未割り当て",
};
+30
View File
@@ -5751,4 +5751,34 @@ export const nl: MessageDict = {
"admin.aiCalls.loadFailed": "AI-aanroepen konden niet worden geladen.",
"admin.aiCalls.copyFailed": "Kopieren naar klembord mislukt.",
"common.copy": "Kopieren",
"products.edit.noCategoryFromFeed": "Geen categorie in de feed",
"products.edit.attrsChangedCount": "{count} gewijzigd",
"products.edit.attrKey": "Attribuut",
"products.edit.noAttributes": "Dit product heeft geen attributen.",
"admin.nav.aiCosts": "AI-kosten",
"admin.aiCosts.title": "AI-kosten",
"admin.aiCosts.description": "Wat elke tenant en gebruiker kost. {days} dagen bewaard.",
"admin.aiCosts.loadFailed": "AI-kosten konden niet worden geladen.",
"admin.aiCosts.totalTitle": "Totale uitgaven",
"admin.aiCosts.rangeLabel": "{from} tot {to}",
"admin.aiCosts.range": "Periode",
"admin.aiCosts.lastNDays": "Laatste {count} dagen",
"admin.aiCosts.grandTotal": "Eindtotaal",
"admin.aiCosts.calls": "Aanroepen",
"admin.aiCosts.failedCalls": "{count} mislukt",
"admin.aiCosts.tokens": "Tokens",
"admin.aiCosts.cachedTokens": "{count} uit cache",
"admin.aiCosts.tenants": "Tenants",
"admin.aiCosts.usersCount": "{count} gebruikers",
"admin.aiCosts.byUser": "Kosten per gebruiker",
"admin.aiCosts.byCompany": "Kosten per bedrijf",
"admin.aiCosts.byModel": "Kosten per model",
"admin.aiCosts.emptyTitle": "Geen uitgaven vastgelegd",
"admin.aiCosts.emptyBody": "Er is niets in rekening gebracht in deze periode.",
"admin.aiCosts.colName": "Naam",
"admin.aiCosts.colCompany": "Bedrijf",
"admin.aiCosts.colIn": "Invoer",
"admin.aiCosts.colOut": "Uitvoer",
"admin.aiCosts.colCost": "Kosten",
"admin.aiCosts.unattributed": "Niet toegewezen",
};
+30
View File
@@ -5751,4 +5751,34 @@ export const pl: MessageDict = {
"admin.aiCalls.loadFailed": "Nie udalo sie wczytac wywolan AI.",
"admin.aiCalls.copyFailed": "Nie udalo sie skopiowac do schowka.",
"common.copy": "Kopiuj",
"products.edit.noCategoryFromFeed": "Brak kategorii w zrodle",
"products.edit.attrsChangedCount": "Zmienionych: {count}",
"products.edit.attrKey": "Atrybut",
"products.edit.noAttributes": "Ten produkt nie ma atrybutow.",
"admin.nav.aiCosts": "Koszty AI",
"admin.aiCosts.title": "Koszty AI",
"admin.aiCosts.description": "Ile kosztuje kazdy najemca i uzytkownik. Przechowywane {days} dni.",
"admin.aiCosts.loadFailed": "Nie udalo sie wczytac kosztow AI.",
"admin.aiCosts.totalTitle": "Laczne wydatki",
"admin.aiCosts.rangeLabel": "Od {from} do {to}",
"admin.aiCosts.range": "Zakres",
"admin.aiCosts.lastNDays": "Ostatnie {count} dni",
"admin.aiCosts.grandTotal": "Suma calkowita",
"admin.aiCosts.calls": "Wywolania",
"admin.aiCosts.failedCalls": "Nieudanych: {count}",
"admin.aiCosts.tokens": "Tokeny",
"admin.aiCosts.cachedTokens": "W pamieci podrecznej: {count}",
"admin.aiCosts.tenants": "Najemcy",
"admin.aiCosts.usersCount": "Uzytkownikow: {count}",
"admin.aiCosts.byUser": "Koszt wedlug uzytkownika",
"admin.aiCosts.byCompany": "Koszt wedlug firmy",
"admin.aiCosts.byModel": "Koszt wedlug modelu",
"admin.aiCosts.emptyTitle": "Brak zarejestrowanych wydatkow",
"admin.aiCosts.emptyBody": "W tym okresie nic nie naliczono.",
"admin.aiCosts.colName": "Nazwa",
"admin.aiCosts.colCompany": "Firma",
"admin.aiCosts.colIn": "Wejscie",
"admin.aiCosts.colOut": "Wyjscie",
"admin.aiCosts.colCost": "Koszt",
"admin.aiCosts.unattributed": "Nieprzypisane",
};
+30
View File
@@ -5751,4 +5751,34 @@ export const pt: MessageDict = {
"admin.aiCalls.loadFailed": "Nao foi possivel carregar as chamadas de IA.",
"admin.aiCalls.copyFailed": "Nao foi possivel copiar.",
"common.copy": "Copiar",
"products.edit.noCategoryFromFeed": "Sem categoria no feed",
"products.edit.attrsChangedCount": "{count} alterados",
"products.edit.attrKey": "Atributo",
"products.edit.noAttributes": "Este produto nao tem atributos.",
"admin.nav.aiCosts": "Custos de IA",
"admin.aiCosts.title": "Custos de IA",
"admin.aiCosts.description": "Quanto custa cada inquilino e utilizador. Guardado {days} dias.",
"admin.aiCosts.loadFailed": "Nao foi possivel carregar os custos de IA.",
"admin.aiCosts.totalTitle": "Despesa total",
"admin.aiCosts.rangeLabel": "{from} a {to}",
"admin.aiCosts.range": "Periodo",
"admin.aiCosts.lastNDays": "Ultimos {count} dias",
"admin.aiCosts.grandTotal": "Total geral",
"admin.aiCosts.calls": "Chamadas",
"admin.aiCosts.failedCalls": "{count} falhadas",
"admin.aiCosts.tokens": "Tokens",
"admin.aiCosts.cachedTokens": "{count} em cache",
"admin.aiCosts.tenants": "Inquilinos",
"admin.aiCosts.usersCount": "{count} utilizadores",
"admin.aiCosts.byUser": "Custo por utilizador",
"admin.aiCosts.byCompany": "Custo por empresa",
"admin.aiCosts.byModel": "Custo por modelo",
"admin.aiCosts.emptyTitle": "Sem despesa registada",
"admin.aiCosts.emptyBody": "Nada foi cobrado neste periodo.",
"admin.aiCosts.colName": "Nome",
"admin.aiCosts.colCompany": "Empresa",
"admin.aiCosts.colIn": "Entrada",
"admin.aiCosts.colOut": "Saida",
"admin.aiCosts.colCost": "Custo",
"admin.aiCosts.unattributed": "Nao atribuido",
};
+30
View File
@@ -62,4 +62,34 @@ export const sl: MessageDict = {
"admin.aiCalls.loadFailed": "Klicev AI ni bilo mogoce naloziti.",
"admin.aiCalls.copyFailed": "Kopiranje v odlozisce ni uspelo.",
"common.copy": "Kopiraj",
"products.edit.noCategoryFromFeed": "V viru ni kategorije",
"products.edit.attrsChangedCount": "Spremenjenih: {count}",
"products.edit.attrKey": "Atribut",
"products.edit.noAttributes": "Ta izdelek nima atributov.",
"admin.nav.aiCosts": "Stroski AI",
"admin.aiCosts.title": "Stroski AI",
"admin.aiCosts.description": "Koliko stane vsak najemnik in uporabnik. Hranjeno {days} dni.",
"admin.aiCosts.loadFailed": "Stroskov AI ni bilo mogoce naloziti.",
"admin.aiCosts.totalTitle": "Skupna poraba",
"admin.aiCosts.rangeLabel": "Od {from} do {to}",
"admin.aiCosts.range": "Obdobje",
"admin.aiCosts.lastNDays": "Zadnjih {count} dni",
"admin.aiCosts.grandTotal": "Skupaj",
"admin.aiCosts.calls": "Klici",
"admin.aiCosts.failedCalls": "Neuspesnih: {count}",
"admin.aiCosts.tokens": "Zetoni",
"admin.aiCosts.cachedTokens": "V predpomnilniku: {count}",
"admin.aiCosts.tenants": "Najemniki",
"admin.aiCosts.usersCount": "Uporabnikov: {count}",
"admin.aiCosts.byUser": "Stroski po uporabniku",
"admin.aiCosts.byCompany": "Stroski po podjetju",
"admin.aiCosts.byModel": "Stroski po modelu",
"admin.aiCosts.emptyTitle": "Ni zabelezene porabe",
"admin.aiCosts.emptyBody": "V tem obdobju ni bilo obracunano nic.",
"admin.aiCosts.colName": "Ime",
"admin.aiCosts.colCompany": "Podjetje",
"admin.aiCosts.colIn": "Vhod",
"admin.aiCosts.colOut": "Izhod",
"admin.aiCosts.colCost": "Strosek",
"admin.aiCosts.unattributed": "Nedodeljeno",
};
@@ -168,8 +168,9 @@
return;
}
try {
const res = await api<{ items?: Company[] }>("/api/admin/companies?limit=200");
companies = res.items ?? [];
// /api/admin/companies returns { companies: [...] }, not { items }.
const res = await api<{ companies?: Company[] }>("/api/admin/companies?limit=200");
companies = res.companies ?? [];
} catch {
// Company list is a convenience filter; the page still works without it.
}
@@ -0,0 +1,246 @@
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { api, failureMessage } from "$lib/api";
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 EmptyState from "$lib/components/EmptyState.svelte";
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableShell
} from "$lib/components/ui";
import { RefreshCw } from "@lucide/svelte";
type CostRow = {
id?: string;
label?: string;
company_id?: string;
company_name?: string;
calls?: number;
failed_calls?: number;
prompt_tokens?: number;
cached_prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
cost_micros?: number;
cost_usd?: number;
companies?: number;
users?: number;
};
type CostReport = {
from?: string;
to?: string;
currency?: string;
totals?: CostRow;
by_company?: CostRow[];
by_user?: CostRow[];
by_model?: CostRow[];
usage_retention_days?: number;
};
let loading = $state(true);
let accessDenied = $state(false);
let refreshing = $state(false);
let error = $state("");
let days = $state("30");
let report = $state<CostReport | null>(null);
const RANGES = ["7", "30", "90", "365", "1095"];
const totals = $derived(report?.totals ?? {});
const retentionDays = $derived(report?.usage_retention_days ?? 1096);
/**
* Cost is stored as integer micro-USD; cost_usd is a display convenience.
* Format from micros so the page never re-sums floats.
*/
function usd(row: CostRow | undefined): string {
const micros = Number(row?.cost_micros ?? 0);
const value = micros / 1_000_000;
// Sub-cent spend is normal per user per day — show enough precision to see it.
const digits = value !== 0 && Math.abs(value) < 0.01 ? 4 : 2;
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: report?.currency || "USD",
minimumFractionDigits: digits,
maximumFractionDigits: digits
}).format(value);
}
function num(n: number | undefined): string {
return new Intl.NumberFormat().format(Number(n ?? 0));
}
async function load() {
error = "";
refreshing = true;
try {
report = await api<CostReport>(`/api/admin/ai-costs?days=${encodeURIComponent(days)}`);
} catch (err) {
error = failureMessage(err, i18n.t("admin.aiCosts.loadFailed"));
} finally {
refreshing = false;
loading = false;
}
}
onMount(async () => {
if (!(await requirePlatformAdmin())) {
accessDenied = true;
loading = false;
return;
}
await load();
});
</script>
<PageShell
title={i18n.t("admin.aiCosts.title")}
description={i18n.t("admin.aiCosts.description", { days: retentionDays })}
>
{#if accessDenied}
<ForbiddenEmptyState />
{:else if loading}
<Spinner />
{:else}
{#if error}
<Alert tone="error" message={error} />
{/if}
<Card>
<CardHeader>
<CardTitle>{i18n.t("admin.aiCosts.totalTitle")}</CardTitle>
<CardDescription>
{i18n.t("admin.aiCosts.rangeLabel", { from: report?.from ?? "—", to: report?.to ?? "—" })}
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex flex-wrap items-center gap-2">
<label class="text-sm">
<span class="mr-2 text-muted-foreground">{i18n.t("admin.aiCosts.range")}</span>
<select
class="rounded-md border bg-background p-2 text-sm"
bind:value={days}
onchange={() => load()}
>
{#each RANGES as r (r)}
<option value={r}>{i18n.t("admin.aiCosts.lastNDays", { count: r })}</option>
{/each}
</select>
</label>
<Button onclick={() => load()} disabled={refreshing}>
<RefreshCw class="mr-2 size-4" />
{i18n.t("common.refresh")}
</Button>
</div>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-lg border bg-muted/30 p-4">
<p class="text-xs uppercase tracking-wide text-muted-foreground">
{i18n.t("admin.aiCosts.grandTotal")}
</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">{usd(totals)}</p>
</div>
<div class="rounded-lg border bg-muted/30 p-4">
<p class="text-xs uppercase tracking-wide text-muted-foreground">
{i18n.t("admin.aiCosts.calls")}
</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">{num(totals.calls)}</p>
{#if Number(totals.failed_calls ?? 0) > 0}
<p class="text-xs text-destructive">
{i18n.t("admin.aiCosts.failedCalls", { count: num(totals.failed_calls) })}
</p>
{/if}
</div>
<div class="rounded-lg border bg-muted/30 p-4">
<p class="text-xs uppercase tracking-wide text-muted-foreground">
{i18n.t("admin.aiCosts.tokens")}
</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">{num(totals.total_tokens)}</p>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.aiCosts.cachedTokens", { count: num(totals.cached_prompt_tokens) })}
</p>
</div>
<div class="rounded-lg border bg-muted/30 p-4">
<p class="text-xs uppercase tracking-wide text-muted-foreground">
{i18n.t("admin.aiCosts.tenants")}
</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">{num(totals.companies)}</p>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.aiCosts.usersCount", { count: num(totals.users) })}
</p>
</div>
</div>
</CardContent>
</Card>
{#each [{ key: "by_user", titleKey: "admin.aiCosts.byUser", rows: report?.by_user ?? [], showCompany: true }, { key: "by_company", titleKey: "admin.aiCosts.byCompany", rows: report?.by_company ?? [], showCompany: false }, { key: "by_model", titleKey: "admin.aiCosts.byModel", rows: report?.by_model ?? [], showCompany: false }] as section (section.key)}
<Card class="mt-4">
<CardHeader>
<CardTitle>{i18n.t(section.titleKey)}</CardTitle>
</CardHeader>
<CardContent>
{#if section.rows.length === 0}
<EmptyState
title={i18n.t("admin.aiCosts.emptyTitle")}
message={i18n.t("admin.aiCosts.emptyBody")}
/>
{:else}
<TableShell>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("admin.aiCosts.colName")}</TableHead>
{#if section.showCompany}
<TableHead>{i18n.t("admin.aiCosts.colCompany")}</TableHead>
{/if}
<TableHead class="text-right">{i18n.t("admin.aiCosts.calls")}</TableHead>
<TableHead class="text-right">{i18n.t("admin.aiCosts.colIn")}</TableHead>
<TableHead class="text-right">{i18n.t("admin.aiCosts.colOut")}</TableHead>
<TableHead class="text-right">{i18n.t("admin.aiCosts.colCost")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each section.rows as row (row.id)}
<TableRow>
<TableCell class="text-xs">
{row.label || i18n.t("admin.aiCosts.unattributed")}
</TableCell>
{#if section.showCompany}
<TableCell class="text-xs text-muted-foreground">
{row.company_name || "—"}
</TableCell>
{/if}
<TableCell class="text-right text-xs tabular-nums">{num(row.calls)}</TableCell>
<TableCell class="text-right text-xs tabular-nums">
{num(row.prompt_tokens)}
</TableCell>
<TableCell class="text-right text-xs tabular-nums">
{num(row.completion_tokens)}
</TableCell>
<TableCell class="text-right text-xs font-medium tabular-nums">
{usd(row)}
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableShell>
{/if}
</CardContent>
</Card>
{/each}
{/if}
</PageShell>