@carlonicora/nextjs-jsonapi 1.141.3 → 1.143.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/AbstractService-B0T7h8fX.d.mts +124 -0
- package/dist/AbstractService-Cew0PD0L.d.ts +124 -0
- package/dist/{AssistantMessageInterface-DH1QwtC_.d.mts → AssistantMessageInterface-Ce8FcllX.d.mts} +1 -1
- package/dist/{AssistantMessageInterface-giWvsOrX.d.ts → AssistantMessageInterface-Dh0BpbP2.d.ts} +1 -1
- package/dist/{AuthComponent-DdxCFgUZ.d.mts → AuthComponent-Cik96ElA.d.mts} +1 -1
- package/dist/{AuthComponent-Cd7lcYif.d.ts → AuthComponent-DTTKu3VK.d.ts} +1 -1
- package/dist/{BlockNoteEditor-JTKEBS2W.mjs → BlockNoteEditor-34QM4UR5.mjs} +4 -4
- package/dist/{BlockNoteEditor-4OXY6BKF.js → BlockNoteEditor-LMKWUOJG.js} +19 -19
- package/dist/{BlockNoteEditor-4OXY6BKF.js.map → BlockNoteEditor-LMKWUOJG.js.map} +1 -1
- package/dist/{auth.interface-yivTb46w.d.ts → auth.interface-COmBjh89.d.ts} +1 -1
- package/dist/{auth.interface-r3XKYOrQ.d.mts → auth.interface-CkmZqat3.d.mts} +1 -1
- package/dist/billing/index.js +357 -357
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-5GVY2M5X.mjs → chunk-BIL2WQEV.mjs} +264 -18
- package/dist/chunk-BIL2WQEV.mjs.map +1 -0
- package/dist/{chunk-AGKZCRG2.js → chunk-DS4IBFWA.js} +269 -23
- package/dist/chunk-DS4IBFWA.js.map +1 -0
- package/dist/{chunk-4TGGHNWL.js → chunk-F6PZZ4KI.js} +752 -752
- package/dist/chunk-F6PZZ4KI.js.map +1 -0
- package/dist/{chunk-KXTTYXJ6.mjs → chunk-HN5ED7OF.mjs} +2 -2
- package/dist/{chunk-K76QSQTC.mjs → chunk-UGSOBBZH.mjs} +28 -28
- package/dist/chunk-UGSOBBZH.mjs.map +1 -0
- package/dist/{chunk-SE2YSAER.js → chunk-UXJAS6C5.js} +7 -7
- package/dist/{chunk-SE2YSAER.js.map → chunk-UXJAS6C5.js.map} +1 -1
- package/dist/client/index.d.mts +5 -5
- package/dist/client/index.d.ts +5 -5
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +6 -6
- package/dist/components/index.d.ts +6 -6
- package/dist/components/index.js +4 -4
- package/dist/components/index.mjs +3 -3
- package/dist/{config-BrkU_Ynz.d.ts → config-Cd5xTEfZ.d.ts} +1 -1
- package/dist/{config-snd2ToEB.d.mts → config-YyhFHxkU.d.mts} +1 -1
- package/dist/contexts/index.d.mts +4 -4
- package/dist/contexts/index.d.ts +4 -4
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +24 -60
- package/dist/core/index.d.ts +24 -60
- 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.d.mts +2 -2
- package/dist/features/help/index.d.ts +2 -2
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.css +29 -0
- package/dist/features/tokenusage/index.css.map +1 -0
- package/dist/features/tokenusage/index.d.mts +388 -0
- package/dist/features/tokenusage/index.d.ts +388 -0
- package/dist/features/tokenusage/index.js +1012 -0
- package/dist/features/tokenusage/index.js.map +1 -0
- package/dist/features/tokenusage/index.mjs +1012 -0
- package/dist/features/tokenusage/index.mjs.map +1 -0
- package/dist/index.d.mts +11 -9
- package/dist/index.d.ts +11 -9
- package/dist/index.js +9 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +8 -2
- package/dist/{notification.interface-LKFmZndR.d.ts → notification.interface--27exGde.d.ts} +6 -6
- package/dist/{notification.interface-B3kNUTZs.d.mts → notification.interface-CG1vqoxg.d.mts} +6 -6
- package/dist/{s3.service-BrmeMjtk.d.ts → s3.service-CpwA3sl_.d.ts} +4 -125
- package/dist/{s3.service-CXKV_OT-.d.mts → s3.service-Dt6MfuHr.d.mts} +4 -125
- package/dist/server/index.d.mts +5 -4
- package/dist/server/index.d.ts +5 -4
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +1 -1
- package/dist/tokenusage-admin.module-5wJ_tZTj.d.ts +61 -0
- package/dist/tokenusage-admin.module-DQVT4O4j.d.mts +61 -0
- package/dist/{types-CQSjy7et.d.mts → types-Bq_UA8Lg.d.mts} +1 -1
- package/dist/{types-DHOxe8rc.d.ts → types-bsm8Fp55.d.ts} +1 -1
- package/dist/{usePageUrlGenerator-tjq2mlDV.d.ts → usePageUrlGenerator-J_ECv-oQ.d.ts} +1 -1
- package/dist/{usePageUrlGenerator-uOnyJ6j2.d.mts → usePageUrlGenerator-uKBjAKqC.d.mts} +1 -1
- package/dist/{useSocket-BsbaGPqE.d.ts → useSocket-DgGAQW6h.d.ts} +1 -1
- package/dist/{useSocket-BSjn1Rlb.d.mts → useSocket-M3YY0aTS.d.mts} +1 -1
- package/package.json +6 -1
- package/src/core/index.ts +5 -0
- package/src/core/registry/ModuleRegistry.ts +4 -0
- package/src/features/company/components/details/TokenStatusIndicator.tsx +9 -9
- package/src/features/company/data/company.interface.ts +6 -6
- package/src/features/company/data/company.ts +17 -17
- package/src/features/tokenusage/components/TokenUsageAdminContainer.tsx +189 -0
- package/src/features/tokenusage/components/TokenUsageAdminFilterBar.tsx +138 -0
- package/src/features/tokenusage/components/TokenUsageAdminTiles.tsx +177 -0
- package/src/features/tokenusage/components/TokenUsageBreakdownTable.tsx +160 -0
- package/src/features/tokenusage/components/TokenUsageRankedBar.tsx +99 -0
- package/src/features/tokenusage/components/TokenUsageTimelineChart.tsx +225 -0
- package/src/features/tokenusage/components/__tests__/TokenUsageAdminTiles.spec.tsx +42 -0
- package/src/features/tokenusage/components/__tests__/TokenUsageRankedBar.spec.tsx +41 -0
- package/src/features/tokenusage/components/__tests__/TokenUsageTimelineChart.spec.tsx +47 -0
- package/src/features/tokenusage/contexts/TokenUsageAdminContext.tsx +252 -0
- package/src/features/tokenusage/contexts/__tests__/TokenUsageAdminContext.spec.tsx +80 -0
- package/src/features/tokenusage/data/TokenUsageAdminService.ts +54 -0
- package/src/features/tokenusage/data/__tests__/tokenusage-admin-models.spec.ts +147 -0
- package/src/features/tokenusage/data/index.ts +8 -0
- package/src/features/tokenusage/data/tokenusage-admin-breakdown.interface.ts +18 -0
- package/src/features/tokenusage/data/tokenusage-admin-breakdown.ts +115 -0
- package/src/features/tokenusage/data/tokenusage-admin-summary.interface.ts +14 -0
- package/src/features/tokenusage/data/tokenusage-admin-summary.ts +90 -0
- package/src/features/tokenusage/data/tokenusage-admin-timeline.interface.ts +13 -0
- package/src/features/tokenusage/data/tokenusage-admin-timeline.ts +94 -0
- package/src/features/tokenusage/data/tokenusage-admin.types.ts +56 -0
- package/src/features/tokenusage/i18n-keys.ts +65 -0
- package/src/features/tokenusage/index.ts +30 -0
- package/src/features/tokenusage/lib/metrics.ts +69 -0
- package/src/features/tokenusage/lib/operation-label.ts +41 -0
- package/src/features/tokenusage/lib/palette.ts +197 -0
- package/src/features/tokenusage/tokenusage-admin.module.ts +22 -0
- package/src/features/user/contexts/CurrentUserContext.tsx +18 -18
- package/src/features/user/contexts/__tests__/CurrentUserContext.spec.tsx +21 -21
- package/dist/chunk-4TGGHNWL.js.map +0 -1
- package/dist/chunk-5GVY2M5X.mjs.map +0 -1
- package/dist/chunk-AGKZCRG2.js.map +0 -1
- package/dist/chunk-K76QSQTC.mjs.map +0 -1
- /package/dist/{BlockNoteEditor-JTKEBS2W.mjs.map → BlockNoteEditor-34QM4UR5.mjs.map} +0 -0
- /package/dist/{chunk-KXTTYXJ6.mjs.map → chunk-HN5ED7OF.mjs.map} +0 -0
|
@@ -11,9 +11,9 @@ export class Company extends AbstractApiData implements CompanyInterface {
|
|
|
11
11
|
|
|
12
12
|
private _isActiveSubscription: boolean = false;
|
|
13
13
|
|
|
14
|
-
private
|
|
15
|
-
private
|
|
16
|
-
private
|
|
14
|
+
private _monthlyCredits: number = 0;
|
|
15
|
+
private _availableMonthlyCredits: number = 0;
|
|
16
|
+
private _availableExtraCredits: number = 0;
|
|
17
17
|
|
|
18
18
|
private _features?: FeatureInterface[];
|
|
19
19
|
private _modules?: ModuleInterface[];
|
|
@@ -46,16 +46,16 @@ export class Company extends AbstractApiData implements CompanyInterface {
|
|
|
46
46
|
return this._isActiveSubscription ?? false;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
get
|
|
50
|
-
return this.
|
|
49
|
+
get monthlyCredits(): number {
|
|
50
|
+
return this._monthlyCredits ?? 0;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
get
|
|
54
|
-
return this.
|
|
53
|
+
get availableMonthlyCredits(): number {
|
|
54
|
+
return this._availableMonthlyCredits ?? 0;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
get
|
|
58
|
-
return this.
|
|
57
|
+
get availableExtraCredits(): number {
|
|
58
|
+
return this._availableExtraCredits ?? 0;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
get features(): FeatureInterface[] {
|
|
@@ -120,9 +120,9 @@ export class Company extends AbstractApiData implements CompanyInterface {
|
|
|
120
120
|
this._logo = data.jsonApi.attributes.logo;
|
|
121
121
|
this._logoUrl = data.jsonApi.attributes.logoUrl;
|
|
122
122
|
this._isActiveSubscription = data.jsonApi.attributes.isActiveSubscription ?? false;
|
|
123
|
-
this.
|
|
124
|
-
this.
|
|
125
|
-
this.
|
|
123
|
+
this._monthlyCredits = data.jsonApi.attributes.monthlyCredits ?? 0;
|
|
124
|
+
this._availableMonthlyCredits = data.jsonApi.attributes.availableMonthlyCredits ?? 0;
|
|
125
|
+
this._availableExtraCredits = data.jsonApi.attributes.availableExtraCredits ?? 0;
|
|
126
126
|
|
|
127
127
|
this._legal_address = data.jsonApi.attributes.legal_address;
|
|
128
128
|
this._street_number = data.jsonApi.attributes.street_number;
|
|
@@ -156,11 +156,11 @@ export class Company extends AbstractApiData implements CompanyInterface {
|
|
|
156
156
|
if (data.name) response.data.attributes.name = data.name;
|
|
157
157
|
if (data.configurations) response.data.attributes.configurations = JSON.stringify(data.configurations);
|
|
158
158
|
if (data.logo) response.data.attributes.logo = data.logo;
|
|
159
|
-
if (data.
|
|
160
|
-
if (data.
|
|
161
|
-
response.data.attributes.
|
|
162
|
-
if (data.
|
|
163
|
-
response.data.attributes.
|
|
159
|
+
if (data.monthlyCredits !== undefined) response.data.attributes.monthlyCredits = data.monthlyCredits;
|
|
160
|
+
if (data.availableMonthlyCredits !== undefined)
|
|
161
|
+
response.data.attributes.availableMonthlyCredits = data.availableMonthlyCredits;
|
|
162
|
+
if (data.availableExtraCredits !== undefined)
|
|
163
|
+
response.data.attributes.availableExtraCredits = data.availableExtraCredits;
|
|
164
164
|
|
|
165
165
|
if (data.legal_address !== undefined) response.data.attributes.legal_address = data.legal_address;
|
|
166
166
|
if (data.street_number !== undefined) response.data.attributes.street_number = data.street_number;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../../../lib/utils";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { useMemo } from "react";
|
|
6
|
+
import { RoundPageContainer } from "../../../components";
|
|
7
|
+
import {
|
|
8
|
+
Card,
|
|
9
|
+
CardAction,
|
|
10
|
+
CardContent,
|
|
11
|
+
CardHeader,
|
|
12
|
+
CardTitle,
|
|
13
|
+
Select,
|
|
14
|
+
SelectContent,
|
|
15
|
+
SelectItem,
|
|
16
|
+
SelectTrigger,
|
|
17
|
+
SelectValue,
|
|
18
|
+
} from "../../../shadcnui";
|
|
19
|
+
import { useTokenUsageAdmin } from "../contexts/TokenUsageAdminContext";
|
|
20
|
+
import type { StackBy } from "../data/tokenusage-admin.types";
|
|
21
|
+
import { TokenUsageAdminTiles } from "./TokenUsageAdminTiles";
|
|
22
|
+
import { TokenUsageBreakdownTable } from "./TokenUsageBreakdownTable";
|
|
23
|
+
import { TokenUsageRankedBar } from "./TokenUsageRankedBar";
|
|
24
|
+
import { TokenUsageTimelineChart } from "./TokenUsageTimelineChart";
|
|
25
|
+
|
|
26
|
+
const STACK_BY_VALUES: StackBy[] = ["scope", "type", "company"];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Page body for the administrative token-usage dashboard.
|
|
30
|
+
*
|
|
31
|
+
* Stateless by design — every value it renders comes from
|
|
32
|
+
* `useTokenUsageAdmin()`. The filter bar is deliberately NOT here: it belongs to
|
|
33
|
+
* the page title bar, which `RoundPageContainer` fills from `SharedContext`, so
|
|
34
|
+
* the provider publishes it (see TokenUsageAdminContext).
|
|
35
|
+
*/
|
|
36
|
+
export function TokenUsageAdminContainer() {
|
|
37
|
+
const t = useTranslations();
|
|
38
|
+
const {
|
|
39
|
+
summary,
|
|
40
|
+
timeline,
|
|
41
|
+
byCompany,
|
|
42
|
+
byUser,
|
|
43
|
+
byOperation,
|
|
44
|
+
byCustomerOperation,
|
|
45
|
+
filters,
|
|
46
|
+
setFilters,
|
|
47
|
+
singleCustomerMode,
|
|
48
|
+
isLoading,
|
|
49
|
+
error,
|
|
50
|
+
} = useTokenUsageAdmin();
|
|
51
|
+
|
|
52
|
+
const stackByItems = useMemo(
|
|
53
|
+
() => ({
|
|
54
|
+
scope: t("token_usage.admin.stack.scope"),
|
|
55
|
+
type: t("token_usage.admin.stack.type"),
|
|
56
|
+
company: t("token_usage.admin.stack.company"),
|
|
57
|
+
}),
|
|
58
|
+
[t],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
if (error) {
|
|
62
|
+
return (
|
|
63
|
+
<RoundPageContainer fullWidth forceHeader>
|
|
64
|
+
<div className="p-4">
|
|
65
|
+
<Card>
|
|
66
|
+
<CardContent>
|
|
67
|
+
<p className="text-destructive text-xs/relaxed">{error}</p>
|
|
68
|
+
</CardContent>
|
|
69
|
+
</Card>
|
|
70
|
+
</div>
|
|
71
|
+
</RoundPageContainer>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Loading renders nothing in the body: the title bar (with the filter bar) is
|
|
76
|
+
// already mounted, so a spinner would only make the controls jump on arrival.
|
|
77
|
+
if (isLoading) return <RoundPageContainer fullWidth forceHeader />;
|
|
78
|
+
|
|
79
|
+
const emptyLabel = t("token_usage.admin.no_data");
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<RoundPageContainer fullWidth forceHeader>
|
|
83
|
+
<div className="flex w-full flex-col gap-4 p-4">
|
|
84
|
+
<TokenUsageAdminTiles summary={summary} metric={filters.metric} singleCustomerMode={singleCustomerMode} />
|
|
85
|
+
|
|
86
|
+
<Card>
|
|
87
|
+
<CardHeader>
|
|
88
|
+
<CardTitle>{t("token_usage.admin.usage_over_time")}</CardTitle>
|
|
89
|
+
<CardAction>
|
|
90
|
+
<div className="flex items-center gap-2">
|
|
91
|
+
<span className="text-muted-foreground text-xs">{t("token_usage.admin.stack_by")}</span>
|
|
92
|
+
<Select
|
|
93
|
+
items={stackByItems}
|
|
94
|
+
value={filters.stackBy}
|
|
95
|
+
onValueChange={(value) => {
|
|
96
|
+
if (value) setFilters({ stackBy: value as StackBy });
|
|
97
|
+
}}
|
|
98
|
+
>
|
|
99
|
+
<SelectTrigger size="sm" className="w-36">
|
|
100
|
+
<SelectValue />
|
|
101
|
+
</SelectTrigger>
|
|
102
|
+
<SelectContent>
|
|
103
|
+
{STACK_BY_VALUES.map((value) => (
|
|
104
|
+
<SelectItem key={value} value={value}>
|
|
105
|
+
{stackByItems[value]}
|
|
106
|
+
</SelectItem>
|
|
107
|
+
))}
|
|
108
|
+
</SelectContent>
|
|
109
|
+
</Select>
|
|
110
|
+
</div>
|
|
111
|
+
</CardAction>
|
|
112
|
+
</CardHeader>
|
|
113
|
+
<CardContent>
|
|
114
|
+
<TokenUsageTimelineChart rows={timeline} metric={filters.metric} stackBy={filters.stackBy} />
|
|
115
|
+
</CardContent>
|
|
116
|
+
</Card>
|
|
117
|
+
|
|
118
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
119
|
+
<Card>
|
|
120
|
+
<CardHeader>
|
|
121
|
+
<CardTitle>{t("token_usage.admin.by_company")}</CardTitle>
|
|
122
|
+
</CardHeader>
|
|
123
|
+
<CardContent>
|
|
124
|
+
<TokenUsageRankedBar rows={byCompany} metric={filters.metric} emptyLabel={emptyLabel} />
|
|
125
|
+
</CardContent>
|
|
126
|
+
</Card>
|
|
127
|
+
|
|
128
|
+
<Card>
|
|
129
|
+
<CardHeader>
|
|
130
|
+
<CardTitle>{t("token_usage.admin.by_user")}</CardTitle>
|
|
131
|
+
</CardHeader>
|
|
132
|
+
<CardContent>
|
|
133
|
+
<TokenUsageRankedBar rows={byUser} metric={filters.metric} emptyLabel={emptyLabel} />
|
|
134
|
+
</CardContent>
|
|
135
|
+
</Card>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
{/* The two cost centres, broken down the same way so they can be read
|
|
139
|
+
against each other. Operation types are vocabulary, not data — unlike
|
|
140
|
+
the company and user panels above, these labels get translated.
|
|
141
|
+
|
|
142
|
+
Platform spend has no owning company, so a company filter makes that
|
|
143
|
+
half meaningless: the provider skips the request and the card is
|
|
144
|
+
hidden rather than rendered empty, leaving the customer half full
|
|
145
|
+
width. */}
|
|
146
|
+
<div className={cn("grid gap-4", !singleCustomerMode && "md:grid-cols-2")}>
|
|
147
|
+
<Card>
|
|
148
|
+
<CardHeader>
|
|
149
|
+
<CardTitle>{t("token_usage.admin.customer_by_operation")}</CardTitle>
|
|
150
|
+
</CardHeader>
|
|
151
|
+
<CardContent>
|
|
152
|
+
<TokenUsageRankedBar
|
|
153
|
+
rows={byCustomerOperation}
|
|
154
|
+
metric={filters.metric}
|
|
155
|
+
emptyLabel={emptyLabel}
|
|
156
|
+
labelsAreOperationTypes
|
|
157
|
+
/>
|
|
158
|
+
</CardContent>
|
|
159
|
+
</Card>
|
|
160
|
+
|
|
161
|
+
{!singleCustomerMode && (
|
|
162
|
+
<Card>
|
|
163
|
+
<CardHeader>
|
|
164
|
+
<CardTitle>{t("token_usage.admin.platform_by_operation")}</CardTitle>
|
|
165
|
+
</CardHeader>
|
|
166
|
+
<CardContent>
|
|
167
|
+
<TokenUsageRankedBar
|
|
168
|
+
rows={byOperation}
|
|
169
|
+
metric={filters.metric}
|
|
170
|
+
emptyLabel={emptyLabel}
|
|
171
|
+
labelsAreOperationTypes
|
|
172
|
+
/>
|
|
173
|
+
</CardContent>
|
|
174
|
+
</Card>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<Card>
|
|
179
|
+
<CardHeader>
|
|
180
|
+
<CardTitle>{t("token_usage.admin.detail")}</CardTitle>
|
|
181
|
+
</CardHeader>
|
|
182
|
+
<CardContent>
|
|
183
|
+
<TokenUsageBreakdownTable rows={byCompany} metric={filters.metric} />
|
|
184
|
+
</CardContent>
|
|
185
|
+
</Card>
|
|
186
|
+
</div>
|
|
187
|
+
</RoundPageContainer>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { DateRangeSelector } from "../../../components/forms/DateRangeSelector";
|
|
5
|
+
import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../shadcnui";
|
|
6
|
+
import { cn } from "../../../utils";
|
|
7
|
+
import type { Granularity, Metric } from "../data/tokenusage-admin.types";
|
|
8
|
+
|
|
9
|
+
/** Sentinel for "no company filter" — Base UI Select cannot hold `undefined`. */
|
|
10
|
+
const ALL_COMPANIES = "all";
|
|
11
|
+
|
|
12
|
+
type FilterState = {
|
|
13
|
+
from: string;
|
|
14
|
+
to: string;
|
|
15
|
+
granularity: Granularity;
|
|
16
|
+
companyId?: string;
|
|
17
|
+
metric: Metric;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type Props = FilterState & {
|
|
21
|
+
companies: { id: string; label: string }[];
|
|
22
|
+
/** Receives ONLY the keys that changed. */
|
|
23
|
+
onChange: (next: Partial<FilterState>) => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The single control row above the KPI tiles.
|
|
28
|
+
*
|
|
29
|
+
* It is deliberately stateless: every control reports the one key it changed and
|
|
30
|
+
* the owning context re-fetches. Keeping the whole filter state in one place is
|
|
31
|
+
* what lets the page issue a single coordinated batch of requests instead of one
|
|
32
|
+
* per control.
|
|
33
|
+
*/
|
|
34
|
+
export function TokenUsageAdminFilterBar({ granularity, companyId, metric, companies, onChange }: Props) {
|
|
35
|
+
const t = useTranslations();
|
|
36
|
+
|
|
37
|
+
const companyItems: Record<string, string> = {
|
|
38
|
+
[ALL_COMPANIES]: t("token_usage.admin.all_companies"),
|
|
39
|
+
...Object.fromEntries(companies.map((company) => [company.id, company.label])),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
44
|
+
<DateRangeSelector
|
|
45
|
+
onDateChange={(range) => {
|
|
46
|
+
if (!range?.from || !range?.to) return;
|
|
47
|
+
onChange({ from: range.from.toISOString(), to: range.to.toISOString() });
|
|
48
|
+
}}
|
|
49
|
+
/>
|
|
50
|
+
|
|
51
|
+
<Segmented
|
|
52
|
+
ariaLabel={t("token_usage.admin.granularity.label")}
|
|
53
|
+
value={granularity}
|
|
54
|
+
options={[
|
|
55
|
+
{ value: "day", label: t("token_usage.admin.granularity.day") },
|
|
56
|
+
{ value: "week", label: t("token_usage.admin.granularity.week") },
|
|
57
|
+
{ value: "month", label: t("token_usage.admin.granularity.month") },
|
|
58
|
+
]}
|
|
59
|
+
onSelect={(value) => onChange({ granularity: value })}
|
|
60
|
+
/>
|
|
61
|
+
|
|
62
|
+
<Segmented
|
|
63
|
+
ariaLabel={t("token_usage.admin.metric.label")}
|
|
64
|
+
value={metric}
|
|
65
|
+
options={[
|
|
66
|
+
{ value: "cost", label: t("token_usage.admin.metric.cost") },
|
|
67
|
+
{ value: "credits", label: t("token_usage.admin.metric.credits") },
|
|
68
|
+
{ value: "tokens", label: t("token_usage.admin.metric.tokens") },
|
|
69
|
+
]}
|
|
70
|
+
onSelect={(value) => onChange({ metric: value })}
|
|
71
|
+
/>
|
|
72
|
+
|
|
73
|
+
<Select
|
|
74
|
+
items={companyItems}
|
|
75
|
+
value={companyId ?? ALL_COMPANIES}
|
|
76
|
+
onValueChange={(value) =>
|
|
77
|
+
onChange({ companyId: !value || value === ALL_COMPANIES ? undefined : (value as string) })
|
|
78
|
+
}
|
|
79
|
+
>
|
|
80
|
+
<SelectTrigger className="w-56">
|
|
81
|
+
<SelectValue placeholder={t("token_usage.admin.all_companies")} />
|
|
82
|
+
</SelectTrigger>
|
|
83
|
+
<SelectContent>
|
|
84
|
+
<SelectItem value={ALL_COMPANIES}>{t("token_usage.admin.all_companies")}</SelectItem>
|
|
85
|
+
{companies.map((company) => (
|
|
86
|
+
<SelectItem key={company.id} value={company.id}>
|
|
87
|
+
{company.label}
|
|
88
|
+
</SelectItem>
|
|
89
|
+
))}
|
|
90
|
+
</SelectContent>
|
|
91
|
+
</Select>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A segmented control built from plain Buttons.
|
|
98
|
+
*
|
|
99
|
+
* The design system has no ToggleGroup primitive, and Base UI triggers may never
|
|
100
|
+
* wrap a Button, so the segmented look is composed from Buttons directly — the
|
|
101
|
+
* selected segment takes the solid variant, the rest stay ghosts.
|
|
102
|
+
*/
|
|
103
|
+
function Segmented<T extends string>({
|
|
104
|
+
ariaLabel,
|
|
105
|
+
value,
|
|
106
|
+
options,
|
|
107
|
+
onSelect,
|
|
108
|
+
}: {
|
|
109
|
+
ariaLabel: string;
|
|
110
|
+
value: T;
|
|
111
|
+
options: { value: T; label: string }[];
|
|
112
|
+
onSelect: (value: T) => void;
|
|
113
|
+
}) {
|
|
114
|
+
return (
|
|
115
|
+
<div
|
|
116
|
+
role="group"
|
|
117
|
+
aria-label={ariaLabel}
|
|
118
|
+
className="border-border inline-flex items-center gap-0.5 rounded-md border p-0.5"
|
|
119
|
+
>
|
|
120
|
+
{options.map((option) => {
|
|
121
|
+
const selected = option.value === value;
|
|
122
|
+
return (
|
|
123
|
+
<Button
|
|
124
|
+
key={option.value}
|
|
125
|
+
type="button"
|
|
126
|
+
size="sm"
|
|
127
|
+
variant={selected ? "default" : "ghost"}
|
|
128
|
+
aria-pressed={selected}
|
|
129
|
+
className={cn(!selected && "text-muted-foreground")}
|
|
130
|
+
onClick={() => onSelect(option.value)}
|
|
131
|
+
>
|
|
132
|
+
{option.label}
|
|
133
|
+
</Button>
|
|
134
|
+
);
|
|
135
|
+
})}
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
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 { TokenUsageAdminSummaryInterface } from "../data/tokenusage-admin-summary.interface";
|
|
8
|
+
import type { Metric } from "../data/tokenusage-admin.types";
|
|
9
|
+
import {
|
|
10
|
+
cacheHitPercentage,
|
|
11
|
+
formatDecimal,
|
|
12
|
+
formatMetricValue,
|
|
13
|
+
formatPercent,
|
|
14
|
+
metricValue,
|
|
15
|
+
percentageDelta,
|
|
16
|
+
type TokenUsageMetrics,
|
|
17
|
+
} from "../lib/metrics";
|
|
18
|
+
|
|
19
|
+
type Props = {
|
|
20
|
+
/** The six summary rows: {customer, platform, total} × {current, previous}. */
|
|
21
|
+
summary: TokenUsageAdminSummaryInterface[];
|
|
22
|
+
metric: Metric;
|
|
23
|
+
/** True when a company filter is applied — platform spend is then meaningless. */
|
|
24
|
+
singleCustomerMode: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const ZERO: TokenUsageMetrics = { cost: 0, credits: 0, tokensIn: 0, tokensOut: 0, cached: 0, calls: 0 };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The KPI header of the administrative token-usage page.
|
|
31
|
+
*
|
|
32
|
+
* Two lead tiles carry the cost centres — customer spend and platform spend —
|
|
33
|
+
* each with its delta against the equal-length preceding window. Three
|
|
34
|
+
* supporting tiles below carry the totals that give those two numbers context.
|
|
35
|
+
*
|
|
36
|
+
* The backend always returns both windows, which is why no tile has to branch on
|
|
37
|
+
* a missing row: an absent scope is simply zero-filled here.
|
|
38
|
+
*/
|
|
39
|
+
export function TokenUsageAdminTiles({ summary, metric, singleCustomerMode }: Props) {
|
|
40
|
+
const t = useTranslations();
|
|
41
|
+
|
|
42
|
+
const rowFor = (scope: string, window: string): TokenUsageMetrics =>
|
|
43
|
+
summary.find((r) => r.scope === scope && r.window === window) ?? ZERO;
|
|
44
|
+
|
|
45
|
+
const customerCurrent = rowFor("customer", "current");
|
|
46
|
+
const customerPrevious = rowFor("customer", "previous");
|
|
47
|
+
const platformCurrent = rowFor("platform", "current");
|
|
48
|
+
const platformPrevious = rowFor("platform", "previous");
|
|
49
|
+
const totalCurrent = rowFor("total", "current");
|
|
50
|
+
const totalPrevious = rowFor("total", "previous");
|
|
51
|
+
|
|
52
|
+
const averagePerCall = totalCurrent.calls ? totalCurrent.cost / totalCurrent.calls : 0;
|
|
53
|
+
const cacheHit = cacheHitPercentage(totalCurrent.cached, totalCurrent.tokensIn);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className="grid gap-3">
|
|
57
|
+
<div className={cn("grid gap-3", singleCustomerMode ? "sm:grid-cols-1" : "sm:grid-cols-2")}>
|
|
58
|
+
<LeadTile
|
|
59
|
+
testId="tile-customer"
|
|
60
|
+
label={t("token_usage.admin.customer_spend")}
|
|
61
|
+
value={formatMetricValue(metricValue(customerCurrent, metric), metric)}
|
|
62
|
+
delta={percentageDelta(metricValue(customerCurrent, metric), metricValue(customerPrevious, metric))}
|
|
63
|
+
previousLabel={t("token_usage.admin.vs_previous")}
|
|
64
|
+
/>
|
|
65
|
+
{!singleCustomerMode && (
|
|
66
|
+
<LeadTile
|
|
67
|
+
testId="tile-platform"
|
|
68
|
+
label={t("token_usage.admin.platform_spend")}
|
|
69
|
+
value={formatMetricValue(metricValue(platformCurrent, metric), metric)}
|
|
70
|
+
delta={percentageDelta(metricValue(platformCurrent, metric), metricValue(platformPrevious, metric))}
|
|
71
|
+
previousLabel={t("token_usage.admin.vs_previous")}
|
|
72
|
+
/>
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div className="grid gap-3 sm:grid-cols-3">
|
|
77
|
+
<SupportingTile
|
|
78
|
+
testId="tile-total"
|
|
79
|
+
label={t("token_usage.admin.total_cost")}
|
|
80
|
+
value={formatMetricValue(metricValue(totalCurrent, metric), metric)}
|
|
81
|
+
delta={percentageDelta(metricValue(totalCurrent, metric), metricValue(totalPrevious, metric))}
|
|
82
|
+
/>
|
|
83
|
+
<SupportingTile
|
|
84
|
+
testId="tile-avg-per-call"
|
|
85
|
+
label={t("token_usage.admin.avg_per_call")}
|
|
86
|
+
value={`€ ${formatDecimal(averagePerCall, 4)}`}
|
|
87
|
+
/>
|
|
88
|
+
<SupportingTile
|
|
89
|
+
testId="tile-cache-hit"
|
|
90
|
+
label={t("token_usage.admin.cache_hit")}
|
|
91
|
+
value={formatPercent(cacheHit)}
|
|
92
|
+
/>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function LeadTile({
|
|
99
|
+
testId,
|
|
100
|
+
label,
|
|
101
|
+
value,
|
|
102
|
+
delta,
|
|
103
|
+
previousLabel,
|
|
104
|
+
}: {
|
|
105
|
+
testId: string;
|
|
106
|
+
label: string;
|
|
107
|
+
value: string;
|
|
108
|
+
delta: number | undefined;
|
|
109
|
+
previousLabel: string;
|
|
110
|
+
}) {
|
|
111
|
+
return (
|
|
112
|
+
<Card data-testid={testId}>
|
|
113
|
+
<CardContent className="grid gap-1">
|
|
114
|
+
<span className="text-muted-foreground text-xs">{label}</span>
|
|
115
|
+
<span className="text-primary text-xl font-semibold tabular-nums">{value}</span>
|
|
116
|
+
<span className="flex items-center gap-1">
|
|
117
|
+
<Delta testId={`${testId}-delta`} delta={delta} />
|
|
118
|
+
<span className="text-muted-foreground text-xs">{previousLabel}</span>
|
|
119
|
+
</span>
|
|
120
|
+
</CardContent>
|
|
121
|
+
</Card>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function SupportingTile({
|
|
126
|
+
testId,
|
|
127
|
+
label,
|
|
128
|
+
value,
|
|
129
|
+
delta,
|
|
130
|
+
}: {
|
|
131
|
+
testId: string;
|
|
132
|
+
label: string;
|
|
133
|
+
value: string;
|
|
134
|
+
delta?: number | undefined;
|
|
135
|
+
}) {
|
|
136
|
+
return (
|
|
137
|
+
<Card data-testid={testId} size="sm">
|
|
138
|
+
<CardContent className="grid gap-1">
|
|
139
|
+
<span className="text-muted-foreground text-xs">{label}</span>
|
|
140
|
+
<span className="flex items-center gap-2">
|
|
141
|
+
<span className="text-sm font-medium tabular-nums">{value}</span>
|
|
142
|
+
{delta !== undefined && <Delta testId={`${testId}-delta`} delta={delta} />}
|
|
143
|
+
</span>
|
|
144
|
+
</CardContent>
|
|
145
|
+
</Card>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The delta slot. An undefined delta means the previous window was zero: an
|
|
151
|
+
* em dash says "not comparable" where a percentage would say "infinite growth".
|
|
152
|
+
*/
|
|
153
|
+
function Delta({ testId, delta }: { testId: string; delta: number | undefined }) {
|
|
154
|
+
if (delta === undefined) {
|
|
155
|
+
return (
|
|
156
|
+
<span data-testid={testId} className="text-muted-foreground text-xs">
|
|
157
|
+
—
|
|
158
|
+
</span>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const increased = delta >= 0;
|
|
163
|
+
const Icon = increased ? ArrowUpIcon : ArrowDownIcon;
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<span
|
|
167
|
+
data-testid={testId}
|
|
168
|
+
className={cn(
|
|
169
|
+
"inline-flex items-center gap-0.5 text-xs tabular-nums",
|
|
170
|
+
increased ? "text-success" : "text-destructive",
|
|
171
|
+
)}
|
|
172
|
+
>
|
|
173
|
+
<Icon aria-hidden className="size-3" />
|
|
174
|
+
{formatDecimal(Math.abs(delta), 0)} %
|
|
175
|
+
</span>
|
|
176
|
+
);
|
|
177
|
+
}
|