fix
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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 "";
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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": "未割り当て",
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user