@carlonicora/nextjs-jsonapi 2.3.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{BlockNoteEditor-PNAVMGHB.js → BlockNoteEditor-TIMGYKAA.js} +19 -19
- package/dist/{BlockNoteEditor-PNAVMGHB.js.map → BlockNoteEditor-TIMGYKAA.js.map} +1 -1
- package/dist/{BlockNoteEditor-3PS3HYVI.mjs → BlockNoteEditor-YFJMXJEU.mjs} +4 -4
- package/dist/billing/index.d.mts +40 -1
- package/dist/billing/index.d.ts +40 -1
- package/dist/billing/index.js +519 -462
- package/dist/billing/index.js.map +1 -1
- package/dist/billing/index.mjs +298 -241
- package/dist/billing/index.mjs.map +1 -1
- package/dist/{chunk-QYUYQO2M.js → chunk-7HKJNUW7.js} +1144 -953
- package/dist/chunk-7HKJNUW7.js.map +1 -0
- package/dist/{chunk-MN2BUSEU.mjs → chunk-DJWNNV52.mjs} +2 -2
- package/dist/{chunk-EQEW64CH.mjs → chunk-FS4RVV3K.mjs} +242 -1
- package/dist/chunk-FS4RVV3K.mjs.map +1 -0
- package/dist/{chunk-FSMXD3N7.mjs → chunk-G4YS52SO.mjs} +2547 -2356
- package/dist/chunk-G4YS52SO.mjs.map +1 -0
- package/dist/{chunk-OHJQYVSI.js → chunk-OTEDZ6YW.js} +7 -7
- package/dist/{chunk-OHJQYVSI.js.map → chunk-OTEDZ6YW.js.map} +1 -1
- package/dist/{chunk-X5TPKMBS.js → chunk-RTOC53EZ.js} +247 -6
- package/dist/chunk-RTOC53EZ.js.map +1 -0
- package/dist/client/index.d.mts +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +137 -4
- package/dist/components/index.d.ts +137 -4
- package/dist/components/index.js +16 -4
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +15 -3
- package/dist/{tokenusage-admin.module-5wJ_tZTj.d.ts → config-BIjrg5wd.d.ts} +39 -1
- package/dist/{tokenusage-admin.module-DQVT4O4j.d.mts → config-BRUjtCd1.d.mts} +39 -1
- package/dist/{content.fields-xH3TGvVk.d.mts → content.fields-1AlHDtDb.d.mts} +1 -0
- package/dist/{content.fields-xH3TGvVk.d.ts → content.fields-1AlHDtDb.d.ts} +1 -0
- package/dist/contexts/index.d.mts +22 -1
- package/dist/contexts/index.d.ts +22 -1
- package/dist/contexts/index.js +8 -4
- package/dist/contexts/index.js.map +1 -1
- package/dist/contexts/index.mjs +7 -3
- package/dist/core/index.d.mts +6 -3
- package/dist/core/index.d.ts +6 -3
- package/dist/core/index.js +8 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +7 -1
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.d.mts +413 -14
- package/dist/features/tokenusage/index.d.ts +413 -14
- package/dist/features/tokenusage/index.js +481 -120
- package/dist/features/tokenusage/index.js.map +1 -1
- package/dist/features/tokenusage/index.mjs +431 -70
- package/dist/features/tokenusage/index.mjs.map +1 -1
- package/dist/index.d.mts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +9 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +8 -2
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +1 -1
- package/package.json +1 -1
- package/src/billing/index.ts +1 -0
- package/src/components/index.ts +1 -0
- package/src/contexts/index.ts +1 -0
- package/src/core/index.ts +10 -0
- package/src/core/registry/ModuleRegistry.ts +4 -0
- package/src/features/administration/components/AdminIndexContainer.tsx +150 -0
- package/src/features/administration/components/__tests__/AdminIndexContainer.spec.tsx +96 -0
- package/src/features/administration/components/index.ts +1 -0
- package/src/features/administration/contexts/AdministrationContext.tsx +47 -0
- package/src/features/administration/contexts/index.ts +1 -0
- package/src/features/administration/data/admin-section.types.ts +30 -0
- package/src/features/administration/i18n-keys.ts +26 -0
- package/src/features/administration/index.ts +3 -0
- package/src/features/billing/contexts/BillingContext.tsx +95 -0
- package/src/features/billing/contexts/index.ts +1 -0
- package/src/features/billing/index.ts +1 -0
- package/src/features/billing/stripe-product/components/containers/ProductsAdminContainer.tsx +39 -27
- package/src/features/billing/stripe-product/components/containers/ProductsAdminPageContainer.tsx +27 -0
- package/src/features/billing/stripe-product/components/containers/index.ts +1 -0
- package/src/features/company/components/containers/CompaniesListContainer.tsx +25 -0
- package/src/features/company/components/containers/index.ts +1 -0
- package/src/features/company/components/lists/CompaniesList.tsx +12 -1
- package/src/features/company/contexts/CompanyContext.tsx +13 -1
- package/src/features/tokenusage/components/TokenUsageAdminContainer.tsx +20 -4
- package/src/features/tokenusage/components/TokenUsageAdminTiles.tsx +31 -18
- package/src/features/tokenusage/components/TokenUsageBreakdownTable.tsx +13 -13
- package/src/features/tokenusage/components/TokenUsageRankedBar.tsx +24 -7
- package/src/features/tokenusage/components/TokenUsageReportContainer.tsx +102 -0
- package/src/features/tokenusage/components/TokenUsageReportFilterBar.tsx +34 -0
- package/src/features/tokenusage/components/TokenUsageReportTiles.tsx +161 -0
- package/src/features/tokenusage/components/TokenUsageTimelineChart.tsx +15 -13
- package/src/features/tokenusage/components/__tests__/TokenUsageAdminTiles.spec.tsx +4 -2
- package/src/features/tokenusage/components/__tests__/TokenUsageRankedBar.spec.tsx +3 -1
- package/src/features/tokenusage/contexts/TokenUsageAdminContext.tsx +6 -3
- package/src/features/tokenusage/contexts/TokenUsageReportContext.tsx +199 -0
- package/src/features/tokenusage/data/TokenUsageReportService.ts +65 -0
- package/src/features/tokenusage/data/index.ts +9 -0
- package/src/features/tokenusage/data/tokenusage-report-breakdown.interface.ts +12 -0
- package/src/features/tokenusage/data/tokenusage-report-breakdown.ts +94 -0
- package/src/features/tokenusage/data/tokenusage-report-summary.interface.ts +12 -0
- package/src/features/tokenusage/data/tokenusage-report-summary.ts +87 -0
- package/src/features/tokenusage/data/tokenusage-report-timeline.interface.ts +13 -0
- package/src/features/tokenusage/data/tokenusage-report-timeline.ts +94 -0
- package/src/features/tokenusage/data/tokenusage-report.types.ts +51 -0
- package/src/features/tokenusage/i18n-keys.ts +28 -0
- package/src/features/tokenusage/index.ts +18 -0
- package/src/features/tokenusage/lib/config.ts +22 -0
- package/src/features/tokenusage/lib/formatters.ts +100 -0
- package/src/features/tokenusage/lib/metrics.ts +8 -26
- package/src/features/tokenusage/lib/palette.ts +41 -14
- package/src/features/tokenusage/tokenusage-admin.module.ts +4 -0
- package/src/features/tokenusage/tokenusage.modules.ts +40 -0
- package/src/features/user/components/containers/PlatformUsersContainer.tsx +23 -0
- package/src/features/user/components/containers/index.ts +1 -0
- package/src/features/user/components/lists/PlatformUsersList.tsx +45 -0
- package/src/features/user/components/lists/index.ts +1 -0
- package/src/features/user/data/user.fields.ts +1 -0
- package/src/features/user/hooks/useUserTableStructure.tsx +17 -0
- package/src/shadcnui/ui/chart.tsx +30 -3
- package/dist/chunk-EQEW64CH.mjs.map +0 -1
- package/dist/chunk-FSMXD3N7.mjs.map +0 -1
- package/dist/chunk-QYUYQO2M.js.map +0 -1
- package/dist/chunk-X5TPKMBS.js.map +0 -1
- /package/dist/{BlockNoteEditor-3PS3HYVI.mjs.map → BlockNoteEditor-YFJMXJEU.mjs.map} +0 -0
- /package/dist/{chunk-MN2BUSEU.mjs.map → chunk-DJWNNV52.mjs.map} +0 -0
|
@@ -1,17 +1,33 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useTranslations } from "next-intl";
|
|
4
|
-
import type { TokenUsageAdminBreakdownInterface } from "../data/tokenusage-admin-breakdown.interface";
|
|
5
4
|
import type { Metric } from "../data/tokenusage-admin.types";
|
|
6
|
-
import {
|
|
5
|
+
import { useUsageFormatters } from "../lib/formatters";
|
|
6
|
+
import { metricValue, type TokenUsageMetrics } from "../lib/metrics";
|
|
7
7
|
import { operationLabel } from "../lib/operation-label";
|
|
8
8
|
import { SEQUENTIAL_RAMP } from "../lib/palette";
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Everything this component actually reads from a row.
|
|
12
|
+
*
|
|
13
|
+
* Declared STRUCTURALLY rather than as one of the breakdown interfaces on
|
|
14
|
+
* purpose: the same list ranks administrative rows (which additionally carry
|
|
15
|
+
* activeUsers / monthlyCredits / availableMonthlyCredits) and self-service
|
|
16
|
+
* report rows (which do not). Narrowing this to the administrative interface
|
|
17
|
+
* would force every self-service caller into a cast, and duplicating the
|
|
18
|
+
* component would fork the ramp and the share arithmetic.
|
|
19
|
+
*/
|
|
20
|
+
export type TokenUsageRankedRow = TokenUsageMetrics & {
|
|
21
|
+
/** Row identity; the literal "other" marks the folded tail. */
|
|
22
|
+
id: string;
|
|
23
|
+
label: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
10
26
|
type Props = {
|
|
11
27
|
/** Ranked rows, already ordered descending by the backend, "other" last. */
|
|
12
|
-
rows:
|
|
28
|
+
rows: TokenUsageRankedRow[];
|
|
13
29
|
metric: Metric;
|
|
14
|
-
/**
|
|
30
|
+
/** Copy shown when there is nothing to rank, already translated by the caller. */
|
|
15
31
|
emptyLabel: string;
|
|
16
32
|
/**
|
|
17
33
|
* Whether `row.label` is an operation TYPE (vocabulary the consuming app
|
|
@@ -37,8 +53,9 @@ type Props = {
|
|
|
37
53
|
*/
|
|
38
54
|
export function TokenUsageRankedBar({ rows, metric, emptyLabel, labelsAreOperationTypes = false }: Props) {
|
|
39
55
|
const t = useTranslations();
|
|
56
|
+
const { metricValue: formatValue, percent } = useUsageFormatters();
|
|
40
57
|
|
|
41
|
-
const renderLabel = (row:
|
|
58
|
+
const renderLabel = (row: TokenUsageRankedRow): string => {
|
|
42
59
|
if (row.id === "other") return t("token_usage.admin.other");
|
|
43
60
|
if (!labelsAreOperationTypes) return row.label;
|
|
44
61
|
return operationLabel(
|
|
@@ -83,13 +100,13 @@ export function TokenUsageRankedBar({ rows, metric, emptyLabel, labelsAreOperati
|
|
|
83
100
|
</div>
|
|
84
101
|
</div>
|
|
85
102
|
<span data-testid={`ranked-value-${row.id}`} className="text-right text-xs tabular-nums">
|
|
86
|
-
{
|
|
103
|
+
{formatValue(value, metric)}
|
|
87
104
|
</span>
|
|
88
105
|
<span
|
|
89
106
|
data-testid={`ranked-share-${row.id}`}
|
|
90
107
|
className="text-muted-foreground w-16 text-right text-xs tabular-nums"
|
|
91
108
|
>
|
|
92
|
-
{
|
|
109
|
+
{percent(share)}
|
|
93
110
|
</span>
|
|
94
111
|
</div>
|
|
95
112
|
);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { RoundPageContainer } from "../../../components";
|
|
5
|
+
import { Card, CardContent, CardHeader, CardTitle } from "../../../shadcnui";
|
|
6
|
+
import { cn } from "../../../utils";
|
|
7
|
+
import { useTokenUsageReport } from "../contexts/TokenUsageReportContext";
|
|
8
|
+
import { TokenUsageRankedBar } from "./TokenUsageRankedBar";
|
|
9
|
+
import { TokenUsageReportTiles, type TokenUsageBalances } from "./TokenUsageReportTiles";
|
|
10
|
+
import { TokenUsageTimelineChart } from "./TokenUsageTimelineChart";
|
|
11
|
+
|
|
12
|
+
type Props = {
|
|
13
|
+
/**
|
|
14
|
+
* The caller's own credit balances. Supplied by the host app, which reads them
|
|
15
|
+
* from its CurrentUserContext — the package has no access to that context.
|
|
16
|
+
*/
|
|
17
|
+
balances?: TokenUsageBalances | null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Page body for the self-service token-usage dashboard.
|
|
22
|
+
*
|
|
23
|
+
* Stateless by design — every value comes from useTokenUsageReport(). The filter
|
|
24
|
+
* bar is deliberately NOT here: it belongs to the page title bar, which
|
|
25
|
+
* RoundPageContainer fills from SharedContext, so the provider publishes it.
|
|
26
|
+
*
|
|
27
|
+
* The timeline and the ranked bars are the PACKAGE'S EXISTING components, used
|
|
28
|
+
* verbatim. They take the same six metric getters the report interfaces were
|
|
29
|
+
* given, which is what makes that reuse possible.
|
|
30
|
+
*/
|
|
31
|
+
export function TokenUsageReportContainer({ balances = null }: Props) {
|
|
32
|
+
const t = useTranslations();
|
|
33
|
+
const { summary, timeline, byOperation, byTarget, isLoading, error, targetPanelTitleKey } = useTokenUsageReport();
|
|
34
|
+
|
|
35
|
+
if (error) {
|
|
36
|
+
return (
|
|
37
|
+
<RoundPageContainer fullWidth forceHeader>
|
|
38
|
+
<div className="p-4">
|
|
39
|
+
<Card>
|
|
40
|
+
<CardContent>
|
|
41
|
+
<p className="text-destructive text-xs/relaxed">{error}</p>
|
|
42
|
+
</CardContent>
|
|
43
|
+
</Card>
|
|
44
|
+
</div>
|
|
45
|
+
</RoundPageContainer>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Loading renders nothing in the body: the title bar (with the filter bar) is
|
|
50
|
+
// already mounted, so a spinner would only make the controls jump on arrival.
|
|
51
|
+
if (isLoading) return <RoundPageContainer fullWidth forceHeader />;
|
|
52
|
+
|
|
53
|
+
const emptyLabel = t("token_usage.report.no_data");
|
|
54
|
+
// The panel needs both a title the host app owns and rows to put under it.
|
|
55
|
+
const targetTitle = targetPanelTitleKey && byTarget.length > 0 ? t(targetPanelTitleKey) : undefined;
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<RoundPageContainer fullWidth forceHeader>
|
|
59
|
+
<div className="flex w-full flex-col gap-4 p-4">
|
|
60
|
+
<TokenUsageReportTiles summary={summary} metric="credits" balances={balances} />
|
|
61
|
+
|
|
62
|
+
<Card>
|
|
63
|
+
<CardHeader>
|
|
64
|
+
<CardTitle>{t("token_usage.report.usage_over_time")}</CardTitle>
|
|
65
|
+
</CardHeader>
|
|
66
|
+
<CardContent>
|
|
67
|
+
<TokenUsageTimelineChart rows={timeline} metric="credits" stackBy="type" />
|
|
68
|
+
</CardContent>
|
|
69
|
+
</Card>
|
|
70
|
+
|
|
71
|
+
<div className={cn("grid gap-4", targetTitle && "md:grid-cols-2")}>
|
|
72
|
+
<Card>
|
|
73
|
+
<CardHeader>
|
|
74
|
+
<CardTitle>{t("token_usage.report.by_operation")}</CardTitle>
|
|
75
|
+
</CardHeader>
|
|
76
|
+
<CardContent>
|
|
77
|
+
{/* Operation types are vocabulary the host app translates, unlike
|
|
78
|
+
the target panel's rows, which carry entity NAMES. */}
|
|
79
|
+
<TokenUsageRankedBar
|
|
80
|
+
rows={byOperation}
|
|
81
|
+
metric="credits"
|
|
82
|
+
emptyLabel={emptyLabel}
|
|
83
|
+
labelsAreOperationTypes
|
|
84
|
+
/>
|
|
85
|
+
</CardContent>
|
|
86
|
+
</Card>
|
|
87
|
+
|
|
88
|
+
{targetTitle && (
|
|
89
|
+
<Card>
|
|
90
|
+
<CardHeader>
|
|
91
|
+
<CardTitle>{targetTitle}</CardTitle>
|
|
92
|
+
</CardHeader>
|
|
93
|
+
<CardContent>
|
|
94
|
+
<TokenUsageRankedBar rows={byTarget} metric="credits" emptyLabel={emptyLabel} />
|
|
95
|
+
</CardContent>
|
|
96
|
+
</Card>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</RoundPageContainer>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { DateRangeSelector } from "../../../components/forms/DateRangeSelector";
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
/** Receives ONLY the keys that changed. */
|
|
7
|
+
onChange: (next: { from?: string; to?: string }) => void;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The single control row above the KPI tiles.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately stateless: the control reports the keys it changed and the owning
|
|
14
|
+
* context re-fetches, which is what lets the page issue a single coordinated
|
|
15
|
+
* batch of requests instead of one per control.
|
|
16
|
+
*
|
|
17
|
+
* There is NO metric selector. A game master is billed in credits, so credits
|
|
18
|
+
* are the only unit this surface speaks: cost would leak platform margin (the
|
|
19
|
+
* controller rejects metric=cost outright) and a raw token count is an
|
|
20
|
+
* implementation detail nobody is charged for. There is no company selector
|
|
21
|
+
* either — a self-service caller has exactly one company.
|
|
22
|
+
*/
|
|
23
|
+
export function TokenUsageReportFilterBar({ onChange }: Props) {
|
|
24
|
+
return (
|
|
25
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
26
|
+
<DateRangeSelector
|
|
27
|
+
onDateChange={(range) => {
|
|
28
|
+
if (!range?.from || !range?.to) return;
|
|
29
|
+
onChange({ from: range.from.toISOString(), to: range.to.toISOString() });
|
|
30
|
+
}}
|
|
31
|
+
/>
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ArrowDownIcon, ArrowUpIcon } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { Card, CardContent } from "../../../shadcnui";
|
|
6
|
+
import { cn } from "../../../utils";
|
|
7
|
+
import type { TokenUsageReportSummaryInterface } from "../data/tokenusage-report-summary.interface";
|
|
8
|
+
import type { ReportMetric } from "../data/tokenusage-report.types";
|
|
9
|
+
import { useUsageFormatters } from "../lib/formatters";
|
|
10
|
+
import { metricValue, percentageDelta, type TokenUsageMetrics } from "../lib/metrics";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The caller's own credit position, as the host app reads it from its
|
|
14
|
+
* CurrentUserContext. The package has no access to that context, so the numbers
|
|
15
|
+
* arrive as a prop.
|
|
16
|
+
*/
|
|
17
|
+
export type TokenUsageBalances = {
|
|
18
|
+
monthlyCredits: number;
|
|
19
|
+
availableMonthlyCredits: number;
|
|
20
|
+
availableExtraCredits: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type Props = {
|
|
24
|
+
/** Two rows: window "current" and "previous". */
|
|
25
|
+
summary: TokenUsageReportSummaryInterface[];
|
|
26
|
+
metric: ReportMetric;
|
|
27
|
+
/** The caller's own balances, read from CurrentUserContext by the container. */
|
|
28
|
+
balances: TokenUsageBalances | null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const ZERO: TokenUsageMetrics = { cost: 0, credits: 0, tokensIn: 0, tokensOut: 0, cached: 0, calls: 0 };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The KPI header of the self-service token-usage page.
|
|
35
|
+
*
|
|
36
|
+
* One lead tile carries the number the page exists to answer — how much was
|
|
37
|
+
* spent in the period — with its delta against the equal-length preceding
|
|
38
|
+
* window. Three supporting tiles give it context: what is left this month, what
|
|
39
|
+
* extra sits behind that, and how many calls produced the spend.
|
|
40
|
+
*
|
|
41
|
+
* Credits are fractional. Customer-facing BALANCES are floored to whole credits
|
|
42
|
+
* (Math.max(0, Math.floor(v))) so nobody is told they have 715.4 of something
|
|
43
|
+
* indivisible; the percentage arithmetic behind the colour keeps the raw floats.
|
|
44
|
+
* Spend is NOT floored — it is a measurement, not a wallet.
|
|
45
|
+
*/
|
|
46
|
+
export function TokenUsageReportTiles({ summary, metric, balances }: Props) {
|
|
47
|
+
const t = useTranslations();
|
|
48
|
+
const { decimal, metricValue: formatValue } = useUsageFormatters();
|
|
49
|
+
|
|
50
|
+
const rowFor = (window: string): TokenUsageMetrics => summary.find((row) => row.window === window) ?? ZERO;
|
|
51
|
+
|
|
52
|
+
const current = rowFor("current");
|
|
53
|
+
const previous = rowFor("previous");
|
|
54
|
+
|
|
55
|
+
const monthlyPercentage =
|
|
56
|
+
balances && balances.monthlyCredits > 0 ? (balances.availableMonthlyCredits / balances.monthlyCredits) * 100 : 0;
|
|
57
|
+
|
|
58
|
+
// Three bands, not four: the previous implementation had a dead branch where
|
|
59
|
+
// >= 25 and >= 5 both returned the same class.
|
|
60
|
+
const monthlyColor =
|
|
61
|
+
monthlyPercentage > 75 ? "text-success" : monthlyPercentage >= 5 ? "text-warning" : "text-destructive";
|
|
62
|
+
|
|
63
|
+
const whole = (value: number) => decimal(Math.max(0, Math.floor(value)), 0);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="grid gap-3">
|
|
67
|
+
<Card data-testid="tile-used">
|
|
68
|
+
<CardContent className="grid gap-1">
|
|
69
|
+
<span className="text-muted-foreground text-xs">{t("token_usage.report.used_in_period")}</span>
|
|
70
|
+
<span className="text-primary text-xl font-semibold tabular-nums">
|
|
71
|
+
{formatValue(metricValue(current, metric), metric)}
|
|
72
|
+
</span>
|
|
73
|
+
<span className="flex items-center gap-1">
|
|
74
|
+
<Delta
|
|
75
|
+
testId="tile-used-delta"
|
|
76
|
+
delta={percentageDelta(metricValue(current, metric), metricValue(previous, metric))}
|
|
77
|
+
decimal={decimal}
|
|
78
|
+
/>
|
|
79
|
+
<span className="text-muted-foreground text-xs">{t("token_usage.report.vs_previous")}</span>
|
|
80
|
+
</span>
|
|
81
|
+
</CardContent>
|
|
82
|
+
</Card>
|
|
83
|
+
|
|
84
|
+
<div className="grid gap-3 sm:grid-cols-3">
|
|
85
|
+
{balances && (
|
|
86
|
+
<Card data-testid="tile-monthly" size="sm">
|
|
87
|
+
<CardContent className="grid gap-1">
|
|
88
|
+
<span className="text-muted-foreground text-xs">{t("token_usage.report.monthly_left")}</span>
|
|
89
|
+
<span className="flex items-baseline gap-1">
|
|
90
|
+
<span data-testid="tile-monthly-value" className={cn("text-sm font-medium tabular-nums", monthlyColor)}>
|
|
91
|
+
{whole(balances.availableMonthlyCredits)}
|
|
92
|
+
</span>
|
|
93
|
+
<span className="text-muted-foreground text-xs tabular-nums">/ {whole(balances.monthlyCredits)}</span>
|
|
94
|
+
</span>
|
|
95
|
+
</CardContent>
|
|
96
|
+
</Card>
|
|
97
|
+
)}
|
|
98
|
+
|
|
99
|
+
{balances && (
|
|
100
|
+
<Card data-testid="tile-extra" size="sm">
|
|
101
|
+
<CardContent className="grid gap-1">
|
|
102
|
+
<span className="text-muted-foreground text-xs">{t("token_usage.report.extra_credits")}</span>
|
|
103
|
+
<span className="text-sm font-medium tabular-nums">{whole(balances.availableExtraCredits)}</span>
|
|
104
|
+
</CardContent>
|
|
105
|
+
</Card>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
<Card data-testid="tile-calls" size="sm">
|
|
109
|
+
<CardContent className="grid gap-1">
|
|
110
|
+
<span className="text-muted-foreground text-xs">{t("token_usage.report.calls")}</span>
|
|
111
|
+
<span className="text-sm font-medium tabular-nums">{decimal(current.calls, 0)}</span>
|
|
112
|
+
</CardContent>
|
|
113
|
+
</Card>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The delta slot. An undefined delta means the previous window was zero: an em
|
|
121
|
+
* dash says "not comparable" where a percentage would say "infinite growth".
|
|
122
|
+
*
|
|
123
|
+
* `decimal` arrives as a prop rather than from the hook because this is a
|
|
124
|
+
* module-level function, not a component — calling a hook here would break the
|
|
125
|
+
* rules of hooks.
|
|
126
|
+
*/
|
|
127
|
+
function Delta({
|
|
128
|
+
testId,
|
|
129
|
+
delta,
|
|
130
|
+
decimal,
|
|
131
|
+
}: {
|
|
132
|
+
testId: string;
|
|
133
|
+
delta: number | undefined;
|
|
134
|
+
decimal: (value: number, decimals: number) => string;
|
|
135
|
+
}) {
|
|
136
|
+
if (delta === undefined) {
|
|
137
|
+
return (
|
|
138
|
+
<span data-testid={testId} className="text-muted-foreground text-xs">
|
|
139
|
+
—
|
|
140
|
+
</span>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const increased = delta >= 0;
|
|
145
|
+
const Icon = increased ? ArrowUpIcon : ArrowDownIcon;
|
|
146
|
+
|
|
147
|
+
// On a SPEND page more is not better, so the semantics are inverted relative
|
|
148
|
+
// to the administrative tiles: rising spend reads as a warning, not a success.
|
|
149
|
+
return (
|
|
150
|
+
<span
|
|
151
|
+
data-testid={testId}
|
|
152
|
+
className={cn(
|
|
153
|
+
"inline-flex items-center gap-0.5 text-xs tabular-nums",
|
|
154
|
+
increased ? "text-warning" : "text-success",
|
|
155
|
+
)}
|
|
156
|
+
>
|
|
157
|
+
<Icon aria-hidden className="size-3" />
|
|
158
|
+
{decimal(Math.abs(delta), 0)} %
|
|
159
|
+
</span>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "../../../shadcnui";
|
|
15
15
|
import { TokenUsageAdminTimelineInterface } from "../data/tokenusage-admin-timeline.interface";
|
|
16
16
|
import { Metric, StackBy } from "../data/tokenusage-admin.types";
|
|
17
|
+
import { useUsageFormatters } from "../lib/formatters";
|
|
17
18
|
import { operationLabel } from "../lib/operation-label";
|
|
18
19
|
import { CATEGORICAL_CEILING, ChartMode, OTHER_COLOR, seriesColor } from "../lib/palette";
|
|
19
20
|
|
|
@@ -71,13 +72,6 @@ const inferGranularity = (buckets: string[]): "day" | "week" | "month" => {
|
|
|
71
72
|
return "day";
|
|
72
73
|
};
|
|
73
74
|
|
|
74
|
-
const bucketFormatter = (granularity: "day" | "week" | "month"): Intl.DateTimeFormat =>
|
|
75
|
-
granularity === "month"
|
|
76
|
-
? new Intl.DateTimeFormat("it-IT", { month: "short", year: "numeric", timeZone: "UTC" })
|
|
77
|
-
: new Intl.DateTimeFormat("it-IT", { day: "numeric", month: "short", timeZone: "UTC" });
|
|
78
|
-
|
|
79
|
-
const compactNumber = new Intl.NumberFormat("it-IT", { notation: "compact", maximumFractionDigits: 1 });
|
|
80
|
-
|
|
81
75
|
/**
|
|
82
76
|
* Usage over time as a stacked bar chart.
|
|
83
77
|
*
|
|
@@ -93,6 +87,7 @@ const compactNumber = new Intl.NumberFormat("it-IT", { notation: "compact", maxi
|
|
|
93
87
|
*/
|
|
94
88
|
export function TokenUsageTimelineChart({ rows, metric, stackBy, className }: TokenUsageTimelineChartProps) {
|
|
95
89
|
const t = useTranslations();
|
|
90
|
+
const { compact, bucketDate, metricValue: formatValue } = useUsageFormatters();
|
|
96
91
|
const { resolvedTheme } = useTheme();
|
|
97
92
|
const mode: ChartMode = resolvedTheme === "dark" ? "dark" : "light";
|
|
98
93
|
|
|
@@ -134,7 +129,7 @@ export function TokenUsageTimelineChart({ rows, metric, stackBy, className }: To
|
|
|
134
129
|
const seriesLabel = (series: string): string => {
|
|
135
130
|
if (series === OTHER_SERIES) {
|
|
136
131
|
const key = "token_usage.series.other";
|
|
137
|
-
return t.has(key) ? t(key) : "
|
|
132
|
+
return t.has(key) ? t(key) : "Other";
|
|
138
133
|
}
|
|
139
134
|
// Stacking by company makes the series a company NAME, which is data, not
|
|
140
135
|
// vocabulary — it is never looked up. Scope and type keys are vocabulary the
|
|
@@ -156,13 +151,12 @@ export function TokenUsageTimelineChart({ rows, metric, stackBy, className }: To
|
|
|
156
151
|
const key = "token_usage.timeline.empty";
|
|
157
152
|
return (
|
|
158
153
|
<p className={className ? `text-muted-foreground text-sm ${className}` : "text-muted-foreground text-sm"}>
|
|
159
|
-
{t.has(key) ? t(key) : "
|
|
154
|
+
{t.has(key) ? t(key) : "No data in the selected period"}
|
|
160
155
|
</p>
|
|
161
156
|
);
|
|
162
157
|
}
|
|
163
158
|
|
|
164
159
|
const granularity = inferGranularity(chartData.map((entry) => entry.bucket));
|
|
165
|
-
const formatBucket = bucketFormatter(granularity);
|
|
166
160
|
const seriesColorFor = (series: string, index: number) =>
|
|
167
161
|
series === OTHER_SERIES ? OTHER_COLOR : seriesColor(index, mode);
|
|
168
162
|
|
|
@@ -186,19 +180,27 @@ export function TokenUsageTimelineChart({ rows, metric, stackBy, className }: To
|
|
|
186
180
|
axisLine={false}
|
|
187
181
|
tickMargin={8}
|
|
188
182
|
minTickGap={16}
|
|
189
|
-
tickFormatter={(value: string) =>
|
|
183
|
+
tickFormatter={(value: string) => bucketDate(value, granularity)}
|
|
190
184
|
/>
|
|
191
185
|
<YAxis
|
|
192
186
|
tickLine={false}
|
|
193
187
|
axisLine={false}
|
|
194
188
|
tickMargin={8}
|
|
195
189
|
width={48}
|
|
196
|
-
tickFormatter={(value: number) =>
|
|
190
|
+
tickFormatter={(value: number) => compact(value)}
|
|
197
191
|
/>
|
|
198
192
|
<ChartTooltip
|
|
199
193
|
content={
|
|
200
194
|
<ChartTooltipContent
|
|
201
|
-
labelFormatter={(value) =>
|
|
195
|
+
labelFormatter={(value) => bucketDate(String(value), granularity)}
|
|
196
|
+
// Without this the tooltip falls back to toLocaleString() and
|
|
197
|
+
// prints the raw stored precision (credits carry 4 decimals),
|
|
198
|
+
// which disagrees with every other number on the page.
|
|
199
|
+
valueFormatter={(value) => formatValue(Number(value), metric)}
|
|
200
|
+
// Every series appears in every bucket's payload, so a day with
|
|
201
|
+
// one active operation would otherwise list the whole vocabulary
|
|
202
|
+
// as zeros and bury the one number that matters.
|
|
203
|
+
hideZeroValues
|
|
202
204
|
/>
|
|
203
205
|
}
|
|
204
206
|
/>
|
|
@@ -18,8 +18,10 @@ describe("TokenUsageAdminTiles", () => {
|
|
|
18
18
|
it("shows both cost centres with their deltas against the previous window", () => {
|
|
19
19
|
render(<TokenUsageAdminTiles summary={summary} metric="cost" singleCustomerMode={false} />);
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
// The suite mocks useLocale() as "en", and formatting now honours the
|
|
22
|
+
// locale instead of a hard-coded it-IT, so the separator is a point.
|
|
23
|
+
expect(screen.getByTestId("tile-customer")).toHaveTextContent("9.46");
|
|
24
|
+
expect(screen.getByTestId("tile-platform")).toHaveTextContent("9.47");
|
|
23
25
|
// customer 9.46 vs 8.00 => +18%
|
|
24
26
|
expect(screen.getByTestId("tile-customer-delta")).toHaveTextContent("18");
|
|
25
27
|
// platform 9.47 vs 10.00 => -5%
|
|
@@ -15,7 +15,9 @@ describe("TokenUsageRankedBar", () => {
|
|
|
15
15
|
/>,
|
|
16
16
|
);
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
// The suite mocks useLocale() as "en", and formatting now honours the
|
|
19
|
+
// locale instead of a hard-coded it-IT, so the separator is a point.
|
|
20
|
+
expect(screen.getByTestId("ranked-value-a")).toHaveTextContent("6.00");
|
|
19
21
|
expect(screen.getByTestId("ranked-share-a")).toHaveTextContent("60");
|
|
20
22
|
expect(screen.getByTestId("ranked-share-b")).toHaveTextContent("40");
|
|
21
23
|
});
|
|
@@ -13,8 +13,8 @@ import type { Granularity, Metric, StackBy } from "../data/tokenusage-admin.type
|
|
|
13
13
|
import { TokenUsageAdminService } from "../data/TokenUsageAdminService";
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* Default route the breadcrumb links back to. A host app that mounts the page
|
|
17
|
+
* elsewhere overrides it with the `pageUrl` prop — the constant stays here
|
|
18
18
|
* because the breadcrumb is built by this provider, not by the app.
|
|
19
19
|
*/
|
|
20
20
|
const TOKEN_USAGE_ADMIN_PAGE_URL = "/administration/token-usage";
|
|
@@ -72,6 +72,8 @@ type TokenUsageAdminProviderProps = {
|
|
|
72
72
|
initialTo?: string;
|
|
73
73
|
/** Rows per ranked panel. Defaults to 10. */
|
|
74
74
|
topN?: number;
|
|
75
|
+
/** Route the breadcrumb links back to. */
|
|
76
|
+
pageUrl?: string;
|
|
75
77
|
};
|
|
76
78
|
|
|
77
79
|
/**
|
|
@@ -89,6 +91,7 @@ export const TokenUsageAdminProvider = ({
|
|
|
89
91
|
initialFrom,
|
|
90
92
|
initialTo,
|
|
91
93
|
topN = DEFAULT_TOP_N,
|
|
94
|
+
pageUrl = TOKEN_USAGE_ADMIN_PAGE_URL,
|
|
92
95
|
}: TokenUsageAdminProviderProps) => {
|
|
93
96
|
const t = useTranslations();
|
|
94
97
|
const generateUrl = usePageUrlGenerator();
|
|
@@ -185,7 +188,7 @@ export const TokenUsageAdminProvider = ({
|
|
|
185
188
|
const breadcrumb = (): BreadcrumbItemData[] => [
|
|
186
189
|
{
|
|
187
190
|
name: t("token_usage.admin.title"),
|
|
188
|
-
href: generateUrl({ page:
|
|
191
|
+
href: generateUrl({ page: pageUrl }),
|
|
189
192
|
},
|
|
190
193
|
];
|
|
191
194
|
|