@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.
Files changed (117) hide show
  1. package/dist/AbstractService-B0T7h8fX.d.mts +124 -0
  2. package/dist/AbstractService-Cew0PD0L.d.ts +124 -0
  3. package/dist/{AssistantMessageInterface-DH1QwtC_.d.mts → AssistantMessageInterface-Ce8FcllX.d.mts} +1 -1
  4. package/dist/{AssistantMessageInterface-giWvsOrX.d.ts → AssistantMessageInterface-Dh0BpbP2.d.ts} +1 -1
  5. package/dist/{AuthComponent-DdxCFgUZ.d.mts → AuthComponent-Cik96ElA.d.mts} +1 -1
  6. package/dist/{AuthComponent-Cd7lcYif.d.ts → AuthComponent-DTTKu3VK.d.ts} +1 -1
  7. package/dist/{BlockNoteEditor-JTKEBS2W.mjs → BlockNoteEditor-34QM4UR5.mjs} +4 -4
  8. package/dist/{BlockNoteEditor-4OXY6BKF.js → BlockNoteEditor-LMKWUOJG.js} +19 -19
  9. package/dist/{BlockNoteEditor-4OXY6BKF.js.map → BlockNoteEditor-LMKWUOJG.js.map} +1 -1
  10. package/dist/{auth.interface-yivTb46w.d.ts → auth.interface-COmBjh89.d.ts} +1 -1
  11. package/dist/{auth.interface-r3XKYOrQ.d.mts → auth.interface-CkmZqat3.d.mts} +1 -1
  12. package/dist/billing/index.js +357 -357
  13. package/dist/billing/index.mjs +3 -3
  14. package/dist/{chunk-5GVY2M5X.mjs → chunk-BIL2WQEV.mjs} +264 -18
  15. package/dist/chunk-BIL2WQEV.mjs.map +1 -0
  16. package/dist/{chunk-AGKZCRG2.js → chunk-DS4IBFWA.js} +269 -23
  17. package/dist/chunk-DS4IBFWA.js.map +1 -0
  18. package/dist/{chunk-4TGGHNWL.js → chunk-F6PZZ4KI.js} +752 -752
  19. package/dist/chunk-F6PZZ4KI.js.map +1 -0
  20. package/dist/{chunk-KXTTYXJ6.mjs → chunk-HN5ED7OF.mjs} +2 -2
  21. package/dist/{chunk-K76QSQTC.mjs → chunk-UGSOBBZH.mjs} +28 -28
  22. package/dist/chunk-UGSOBBZH.mjs.map +1 -0
  23. package/dist/{chunk-SE2YSAER.js → chunk-UXJAS6C5.js} +7 -7
  24. package/dist/{chunk-SE2YSAER.js.map → chunk-UXJAS6C5.js.map} +1 -1
  25. package/dist/client/index.d.mts +5 -5
  26. package/dist/client/index.d.ts +5 -5
  27. package/dist/client/index.js +4 -4
  28. package/dist/client/index.mjs +3 -3
  29. package/dist/components/index.d.mts +6 -6
  30. package/dist/components/index.d.ts +6 -6
  31. package/dist/components/index.js +4 -4
  32. package/dist/components/index.mjs +3 -3
  33. package/dist/{config-BrkU_Ynz.d.ts → config-Cd5xTEfZ.d.ts} +1 -1
  34. package/dist/{config-snd2ToEB.d.mts → config-YyhFHxkU.d.mts} +1 -1
  35. package/dist/contexts/index.d.mts +4 -4
  36. package/dist/contexts/index.d.ts +4 -4
  37. package/dist/contexts/index.js +4 -4
  38. package/dist/contexts/index.mjs +3 -3
  39. package/dist/core/index.d.mts +24 -60
  40. package/dist/core/index.d.ts +24 -60
  41. package/dist/core/index.js +8 -2
  42. package/dist/core/index.js.map +1 -1
  43. package/dist/core/index.mjs +7 -1
  44. package/dist/features/help/index.d.mts +2 -2
  45. package/dist/features/help/index.d.ts +2 -2
  46. package/dist/features/help/index.js +37 -37
  47. package/dist/features/help/index.mjs +3 -3
  48. package/dist/features/tokenusage/index.css +29 -0
  49. package/dist/features/tokenusage/index.css.map +1 -0
  50. package/dist/features/tokenusage/index.d.mts +388 -0
  51. package/dist/features/tokenusage/index.d.ts +388 -0
  52. package/dist/features/tokenusage/index.js +1012 -0
  53. package/dist/features/tokenusage/index.js.map +1 -0
  54. package/dist/features/tokenusage/index.mjs +1012 -0
  55. package/dist/features/tokenusage/index.mjs.map +1 -0
  56. package/dist/index.d.mts +11 -9
  57. package/dist/index.d.ts +11 -9
  58. package/dist/index.js +9 -3
  59. package/dist/index.js.map +1 -1
  60. package/dist/index.mjs +8 -2
  61. package/dist/{notification.interface-LKFmZndR.d.ts → notification.interface--27exGde.d.ts} +6 -6
  62. package/dist/{notification.interface-B3kNUTZs.d.mts → notification.interface-CG1vqoxg.d.mts} +6 -6
  63. package/dist/{s3.service-BrmeMjtk.d.ts → s3.service-CpwA3sl_.d.ts} +4 -125
  64. package/dist/{s3.service-CXKV_OT-.d.mts → s3.service-Dt6MfuHr.d.mts} +4 -125
  65. package/dist/server/index.d.mts +5 -4
  66. package/dist/server/index.d.ts +5 -4
  67. package/dist/server/index.js +3 -3
  68. package/dist/server/index.mjs +1 -1
  69. package/dist/tokenusage-admin.module-5wJ_tZTj.d.ts +61 -0
  70. package/dist/tokenusage-admin.module-DQVT4O4j.d.mts +61 -0
  71. package/dist/{types-CQSjy7et.d.mts → types-Bq_UA8Lg.d.mts} +1 -1
  72. package/dist/{types-DHOxe8rc.d.ts → types-bsm8Fp55.d.ts} +1 -1
  73. package/dist/{usePageUrlGenerator-tjq2mlDV.d.ts → usePageUrlGenerator-J_ECv-oQ.d.ts} +1 -1
  74. package/dist/{usePageUrlGenerator-uOnyJ6j2.d.mts → usePageUrlGenerator-uKBjAKqC.d.mts} +1 -1
  75. package/dist/{useSocket-BsbaGPqE.d.ts → useSocket-DgGAQW6h.d.ts} +1 -1
  76. package/dist/{useSocket-BSjn1Rlb.d.mts → useSocket-M3YY0aTS.d.mts} +1 -1
  77. package/package.json +6 -1
  78. package/src/core/index.ts +5 -0
  79. package/src/core/registry/ModuleRegistry.ts +4 -0
  80. package/src/features/company/components/details/TokenStatusIndicator.tsx +9 -9
  81. package/src/features/company/data/company.interface.ts +6 -6
  82. package/src/features/company/data/company.ts +17 -17
  83. package/src/features/tokenusage/components/TokenUsageAdminContainer.tsx +189 -0
  84. package/src/features/tokenusage/components/TokenUsageAdminFilterBar.tsx +138 -0
  85. package/src/features/tokenusage/components/TokenUsageAdminTiles.tsx +177 -0
  86. package/src/features/tokenusage/components/TokenUsageBreakdownTable.tsx +160 -0
  87. package/src/features/tokenusage/components/TokenUsageRankedBar.tsx +99 -0
  88. package/src/features/tokenusage/components/TokenUsageTimelineChart.tsx +225 -0
  89. package/src/features/tokenusage/components/__tests__/TokenUsageAdminTiles.spec.tsx +42 -0
  90. package/src/features/tokenusage/components/__tests__/TokenUsageRankedBar.spec.tsx +41 -0
  91. package/src/features/tokenusage/components/__tests__/TokenUsageTimelineChart.spec.tsx +47 -0
  92. package/src/features/tokenusage/contexts/TokenUsageAdminContext.tsx +252 -0
  93. package/src/features/tokenusage/contexts/__tests__/TokenUsageAdminContext.spec.tsx +80 -0
  94. package/src/features/tokenusage/data/TokenUsageAdminService.ts +54 -0
  95. package/src/features/tokenusage/data/__tests__/tokenusage-admin-models.spec.ts +147 -0
  96. package/src/features/tokenusage/data/index.ts +8 -0
  97. package/src/features/tokenusage/data/tokenusage-admin-breakdown.interface.ts +18 -0
  98. package/src/features/tokenusage/data/tokenusage-admin-breakdown.ts +115 -0
  99. package/src/features/tokenusage/data/tokenusage-admin-summary.interface.ts +14 -0
  100. package/src/features/tokenusage/data/tokenusage-admin-summary.ts +90 -0
  101. package/src/features/tokenusage/data/tokenusage-admin-timeline.interface.ts +13 -0
  102. package/src/features/tokenusage/data/tokenusage-admin-timeline.ts +94 -0
  103. package/src/features/tokenusage/data/tokenusage-admin.types.ts +56 -0
  104. package/src/features/tokenusage/i18n-keys.ts +65 -0
  105. package/src/features/tokenusage/index.ts +30 -0
  106. package/src/features/tokenusage/lib/metrics.ts +69 -0
  107. package/src/features/tokenusage/lib/operation-label.ts +41 -0
  108. package/src/features/tokenusage/lib/palette.ts +197 -0
  109. package/src/features/tokenusage/tokenusage-admin.module.ts +22 -0
  110. package/src/features/user/contexts/CurrentUserContext.tsx +18 -18
  111. package/src/features/user/contexts/__tests__/CurrentUserContext.spec.tsx +21 -21
  112. package/dist/chunk-4TGGHNWL.js.map +0 -1
  113. package/dist/chunk-5GVY2M5X.mjs.map +0 -1
  114. package/dist/chunk-AGKZCRG2.js.map +0 -1
  115. package/dist/chunk-K76QSQTC.mjs.map +0 -1
  116. /package/dist/{BlockNoteEditor-JTKEBS2W.mjs.map → BlockNoteEditor-34QM4UR5.mjs.map} +0 -0
  117. /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 _monthlyTokens: number = 0;
15
- private _availableMonthlyTokens: number = 0;
16
- private _availableExtraTokens: number = 0;
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 monthlyTokens(): number {
50
- return this._monthlyTokens ?? 0;
49
+ get monthlyCredits(): number {
50
+ return this._monthlyCredits ?? 0;
51
51
  }
52
52
 
53
- get availableMonthlyTokens(): number {
54
- return this._availableMonthlyTokens ?? 0;
53
+ get availableMonthlyCredits(): number {
54
+ return this._availableMonthlyCredits ?? 0;
55
55
  }
56
56
 
57
- get availableExtraTokens(): number {
58
- return this._availableExtraTokens ?? 0;
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._monthlyTokens = data.jsonApi.attributes.monthlyTokens ?? 0;
124
- this._availableMonthlyTokens = data.jsonApi.attributes.availableMonthlyTokens ?? 0;
125
- this._availableExtraTokens = data.jsonApi.attributes.availableExtraTokens ?? 0;
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.monthlyTokens !== undefined) response.data.attributes.monthlyTokens = data.monthlyTokens;
160
- if (data.availableMonthlyTokens !== undefined)
161
- response.data.attributes.availableMonthlyTokens = data.availableMonthlyTokens;
162
- if (data.availableExtraTokens !== undefined)
163
- response.data.attributes.availableExtraTokens = data.availableExtraTokens;
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
+ }