@carlonicora/nextjs-jsonapi 2.4.0 → 3.0.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 (128) hide show
  1. package/dist/{BlockNoteEditor-7WGCH2KB.js → BlockNoteEditor-CDTU6DRN.js} +19 -19
  2. package/dist/{BlockNoteEditor-7WGCH2KB.js.map → BlockNoteEditor-CDTU6DRN.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-GWAV5ESO.mjs → BlockNoteEditor-VVAY73V2.mjs} +4 -4
  4. package/dist/billing/index.d.mts +127 -46
  5. package/dist/billing/index.d.ts +127 -46
  6. package/dist/billing/index.js +1243 -1084
  7. package/dist/billing/index.js.map +1 -1
  8. package/dist/billing/index.mjs +983 -824
  9. package/dist/billing/index.mjs.map +1 -1
  10. package/dist/{chunk-V66AZWPG.js → chunk-53B6NPGA.js} +278 -8
  11. package/dist/chunk-53B6NPGA.js.map +1 -0
  12. package/dist/{chunk-M2EGUO2F.mjs → chunk-J54546YC.mjs} +1608 -1419
  13. package/dist/chunk-J54546YC.mjs.map +1 -0
  14. package/dist/{chunk-TOKHHZSP.js → chunk-JKGEJGSD.js} +7 -7
  15. package/dist/{chunk-TOKHHZSP.js.map → chunk-JKGEJGSD.js.map} +1 -1
  16. package/dist/{chunk-3LVSA7IM.mjs → chunk-PHEFWL6L.mjs} +2 -2
  17. package/dist/{chunk-LCCRUWWY.mjs → chunk-TWXKAY34.mjs} +273 -3
  18. package/dist/chunk-TWXKAY34.mjs.map +1 -0
  19. package/dist/{chunk-GYGMEDVS.js → chunk-UGNLKDI6.js} +1012 -823
  20. package/dist/chunk-UGNLKDI6.js.map +1 -0
  21. package/dist/client/index.js +4 -4
  22. package/dist/client/index.mjs +3 -3
  23. package/dist/components/index.d.mts +16 -1
  24. package/dist/components/index.d.ts +16 -1
  25. package/dist/components/index.js +4 -4
  26. package/dist/components/index.mjs +3 -3
  27. package/dist/{tokenusage-admin.module-5wJ_tZTj.d.ts → config-BIjrg5wd.d.ts} +39 -1
  28. package/dist/{tokenusage-admin.module-DQVT4O4j.d.mts → config-BRUjtCd1.d.mts} +39 -1
  29. package/dist/contexts/index.js +4 -4
  30. package/dist/contexts/index.mjs +3 -3
  31. package/dist/core/index.d.mts +22 -3
  32. package/dist/core/index.d.ts +22 -3
  33. package/dist/core/index.js +12 -2
  34. package/dist/core/index.js.map +1 -1
  35. package/dist/core/index.mjs +11 -1
  36. package/dist/features/help/index.js +37 -37
  37. package/dist/features/help/index.mjs +3 -3
  38. package/dist/features/tokenusage/index.d.mts +413 -14
  39. package/dist/features/tokenusage/index.d.ts +413 -14
  40. package/dist/features/tokenusage/index.js +481 -120
  41. package/dist/features/tokenusage/index.js.map +1 -1
  42. package/dist/features/tokenusage/index.mjs +431 -70
  43. package/dist/features/tokenusage/index.mjs.map +1 -1
  44. package/dist/index.d.mts +3 -3
  45. package/dist/index.d.ts +3 -3
  46. package/dist/index.js +13 -3
  47. package/dist/index.js.map +1 -1
  48. package/dist/index.mjs +12 -2
  49. package/dist/server/index.js +3 -3
  50. package/dist/server/index.mjs +1 -1
  51. package/package.json +1 -1
  52. package/src/client/index.ts +4 -0
  53. package/src/core/index.ts +10 -0
  54. package/src/core/registry/ModuleRegistry.ts +4 -0
  55. package/src/features/administration/components/AdminIndexContainer.tsx +1 -1
  56. package/src/features/administration/components/__tests__/AdminIndexContainer.spec.tsx +1 -1
  57. package/src/features/billing/contexts/PriceContext.tsx +202 -0
  58. package/src/features/billing/contexts/ProductContext.tsx +185 -0
  59. package/src/features/billing/contexts/__tests__/PriceContext.spec.tsx +89 -0
  60. package/src/features/billing/contexts/__tests__/ProductContext.spec.tsx +81 -0
  61. package/src/features/billing/contexts/index.ts +2 -1
  62. package/src/features/billing/i18n-keys.ts +88 -0
  63. package/src/features/billing/stripe-price/components/containers/PriceContainer.tsx +21 -0
  64. package/src/features/billing/stripe-price/components/containers/index.ts +1 -0
  65. package/src/features/billing/stripe-price/components/details/PriceDetails.tsx +131 -0
  66. package/src/features/billing/stripe-price/components/details/index.ts +1 -0
  67. package/src/features/billing/stripe-price/components/forms/PriceArchiver.tsx +55 -0
  68. package/src/features/billing/stripe-price/components/forms/PriceEditor.tsx +307 -374
  69. package/src/features/billing/stripe-price/components/forms/index.ts +3 -1
  70. package/src/features/billing/stripe-price/components/index.ts +2 -0
  71. package/src/features/billing/stripe-price/components/lists/PricesList.tsx +49 -267
  72. package/src/features/billing/stripe-price/data/index.ts +1 -0
  73. package/src/features/billing/stripe-price/data/stripe-price.fields.ts +8 -0
  74. package/src/features/billing/stripe-price/hooks/useStripePriceTableStructure.tsx +119 -0
  75. package/src/features/billing/stripe-price/stripe-price.module.ts +5 -1
  76. package/src/features/billing/stripe-product/components/containers/ProductContainer.tsx +45 -0
  77. package/src/features/billing/stripe-product/components/containers/ProductsListContainer.tsx +27 -0
  78. package/src/features/billing/stripe-product/components/containers/index.ts +2 -2
  79. package/src/features/billing/stripe-product/components/details/ProductDetails.tsx +45 -0
  80. package/src/features/billing/stripe-product/components/details/index.ts +1 -0
  81. package/src/features/billing/stripe-product/components/forms/ProductArchiver.tsx +55 -0
  82. package/src/features/billing/stripe-product/components/forms/ProductEditor.tsx +92 -81
  83. package/src/features/billing/stripe-product/components/forms/index.ts +3 -1
  84. package/src/features/billing/stripe-product/components/index.ts +1 -0
  85. package/src/features/billing/stripe-product/components/lists/ProductsList.tsx +45 -187
  86. package/src/features/billing/stripe-product/data/index.ts +1 -0
  87. package/src/features/billing/stripe-product/data/stripe-product.fields.ts +6 -0
  88. package/src/features/billing/stripe-product/hooks/useStripeProductTableStructure.tsx +80 -0
  89. package/src/features/billing/stripe-product/stripe-product.module.ts +5 -1
  90. package/src/features/tokenusage/components/TokenUsageAdminContainer.tsx +20 -4
  91. package/src/features/tokenusage/components/TokenUsageAdminTiles.tsx +31 -18
  92. package/src/features/tokenusage/components/TokenUsageBreakdownTable.tsx +13 -13
  93. package/src/features/tokenusage/components/TokenUsageRankedBar.tsx +24 -7
  94. package/src/features/tokenusage/components/TokenUsageReportContainer.tsx +102 -0
  95. package/src/features/tokenusage/components/TokenUsageReportFilterBar.tsx +34 -0
  96. package/src/features/tokenusage/components/TokenUsageReportTiles.tsx +161 -0
  97. package/src/features/tokenusage/components/TokenUsageTimelineChart.tsx +15 -13
  98. package/src/features/tokenusage/components/__tests__/TokenUsageAdminTiles.spec.tsx +4 -2
  99. package/src/features/tokenusage/components/__tests__/TokenUsageRankedBar.spec.tsx +3 -1
  100. package/src/features/tokenusage/contexts/TokenUsageAdminContext.tsx +6 -3
  101. package/src/features/tokenusage/contexts/TokenUsageReportContext.tsx +199 -0
  102. package/src/features/tokenusage/data/TokenUsageReportService.ts +65 -0
  103. package/src/features/tokenusage/data/index.ts +9 -0
  104. package/src/features/tokenusage/data/tokenusage-report-breakdown.interface.ts +12 -0
  105. package/src/features/tokenusage/data/tokenusage-report-breakdown.ts +94 -0
  106. package/src/features/tokenusage/data/tokenusage-report-summary.interface.ts +12 -0
  107. package/src/features/tokenusage/data/tokenusage-report-summary.ts +87 -0
  108. package/src/features/tokenusage/data/tokenusage-report-timeline.interface.ts +13 -0
  109. package/src/features/tokenusage/data/tokenusage-report-timeline.ts +94 -0
  110. package/src/features/tokenusage/data/tokenusage-report.types.ts +51 -0
  111. package/src/features/tokenusage/i18n-keys.ts +28 -0
  112. package/src/features/tokenusage/index.ts +18 -0
  113. package/src/features/tokenusage/lib/config.ts +22 -0
  114. package/src/features/tokenusage/lib/formatters.ts +100 -0
  115. package/src/features/tokenusage/lib/metrics.ts +8 -26
  116. package/src/features/tokenusage/lib/palette.ts +41 -14
  117. package/src/features/tokenusage/tokenusage-admin.module.ts +4 -0
  118. package/src/features/tokenusage/tokenusage.modules.ts +40 -0
  119. package/src/shadcnui/ui/chart.tsx +30 -3
  120. package/dist/chunk-GYGMEDVS.js.map +0 -1
  121. package/dist/chunk-LCCRUWWY.mjs.map +0 -1
  122. package/dist/chunk-M2EGUO2F.mjs.map +0 -1
  123. package/dist/chunk-V66AZWPG.js.map +0 -1
  124. package/src/features/billing/contexts/BillingContext.tsx +0 -95
  125. package/src/features/billing/stripe-product/components/containers/ProductsAdminContainer.tsx +0 -98
  126. package/src/features/billing/stripe-product/components/containers/ProductsAdminPageContainer.tsx +0 -27
  127. /package/dist/{BlockNoteEditor-GWAV5ESO.mjs.map → BlockNoteEditor-VVAY73V2.mjs.map} +0 -0
  128. /package/dist/{chunk-3LVSA7IM.mjs.map → chunk-PHEFWL6L.mjs.map} +0 -0
@@ -49,6 +49,21 @@ export function TokenUsageAdminContainer() {
49
49
  error,
50
50
  } = useTokenUsageAdmin();
51
51
 
52
+ // narr8-shaped deployments record essentially no platform spend (usage with no
53
+ // owning company), which would leave the platform tile and the
54
+ // platform-by-operation panel as permanent zeros. Treat "nothing to show" the
55
+ // same way a company filter is treated — the customer half then takes full
56
+ // width. a360ai is unaffected: it has real platform spend.
57
+ const platformIsEmpty = useMemo(
58
+ () =>
59
+ summary
60
+ .filter((row) => row.scope === "platform")
61
+ .every((row) => row.cost === 0 && row.credits === 0 && row.tokensIn === 0 && row.tokensOut === 0),
62
+ [summary],
63
+ );
64
+
65
+ const hidePlatform = singleCustomerMode || platformIsEmpty;
66
+
52
67
  const stackByItems = useMemo(
53
68
  () => ({
54
69
  scope: t("token_usage.admin.stack.scope"),
@@ -81,7 +96,7 @@ export function TokenUsageAdminContainer() {
81
96
  return (
82
97
  <RoundPageContainer fullWidth forceHeader>
83
98
  <div className="flex w-full flex-col gap-4 p-4">
84
- <TokenUsageAdminTiles summary={summary} metric={filters.metric} singleCustomerMode={singleCustomerMode} />
99
+ <TokenUsageAdminTiles summary={summary} metric={filters.metric} singleCustomerMode={hidePlatform} />
85
100
 
86
101
  <Card>
87
102
  <CardHeader>
@@ -142,8 +157,9 @@ export function TokenUsageAdminContainer() {
142
157
  Platform spend has no owning company, so a company filter makes that
143
158
  half meaningless: the provider skips the request and the card is
144
159
  hidden rather than rendered empty, leaving the customer half full
145
- width. */}
146
- <div className={cn("grid gap-4", !singleCustomerMode && "md:grid-cols-2")}>
160
+ width. The same holds when the window records no platform spend at
161
+ all see `hidePlatform` above. */}
162
+ <div className={cn("grid gap-4", !hidePlatform && "md:grid-cols-2")}>
147
163
  <Card>
148
164
  <CardHeader>
149
165
  <CardTitle>{t("token_usage.admin.customer_by_operation")}</CardTitle>
@@ -158,7 +174,7 @@ export function TokenUsageAdminContainer() {
158
174
  </CardContent>
159
175
  </Card>
160
176
 
161
- {!singleCustomerMode && (
177
+ {!hidePlatform && (
162
178
  <Card>
163
179
  <CardHeader>
164
180
  <CardTitle>{t("token_usage.admin.platform_by_operation")}</CardTitle>
@@ -6,15 +6,8 @@ import { Card, CardContent } from "../../../shadcnui";
6
6
  import { cn } from "../../../utils";
7
7
  import type { TokenUsageAdminSummaryInterface } from "../data/tokenusage-admin-summary.interface";
8
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";
9
+ import { useUsageFormatters } from "../lib/formatters";
10
+ import { cacheHitPercentage, metricValue, percentageDelta, type TokenUsageMetrics } from "../lib/metrics";
18
11
 
19
12
  type Props = {
20
13
  /** The six summary rows: {customer, platform, total} × {current, previous}. */
@@ -38,6 +31,7 @@ const ZERO: TokenUsageMetrics = { cost: 0, credits: 0, tokensIn: 0, tokensOut: 0
38
31
  */
39
32
  export function TokenUsageAdminTiles({ summary, metric, singleCustomerMode }: Props) {
40
33
  const t = useTranslations();
34
+ const { decimal, metricValue: formatValue, currency, percent } = useUsageFormatters();
41
35
 
42
36
  const rowFor = (scope: string, window: string): TokenUsageMetrics =>
43
37
  summary.find((r) => r.scope === scope && r.window === window) ?? ZERO;
@@ -58,17 +52,19 @@ export function TokenUsageAdminTiles({ summary, metric, singleCustomerMode }: Pr
58
52
  <LeadTile
59
53
  testId="tile-customer"
60
54
  label={t("token_usage.admin.customer_spend")}
61
- value={formatMetricValue(metricValue(customerCurrent, metric), metric)}
55
+ value={formatValue(metricValue(customerCurrent, metric), metric)}
62
56
  delta={percentageDelta(metricValue(customerCurrent, metric), metricValue(customerPrevious, metric))}
63
57
  previousLabel={t("token_usage.admin.vs_previous")}
58
+ decimal={decimal}
64
59
  />
65
60
  {!singleCustomerMode && (
66
61
  <LeadTile
67
62
  testId="tile-platform"
68
63
  label={t("token_usage.admin.platform_spend")}
69
- value={formatMetricValue(metricValue(platformCurrent, metric), metric)}
64
+ value={formatValue(metricValue(platformCurrent, metric), metric)}
70
65
  delta={percentageDelta(metricValue(platformCurrent, metric), metricValue(platformPrevious, metric))}
71
66
  previousLabel={t("token_usage.admin.vs_previous")}
67
+ decimal={decimal}
72
68
  />
73
69
  )}
74
70
  </div>
@@ -77,18 +73,21 @@ export function TokenUsageAdminTiles({ summary, metric, singleCustomerMode }: Pr
77
73
  <SupportingTile
78
74
  testId="tile-total"
79
75
  label={t("token_usage.admin.total_cost")}
80
- value={formatMetricValue(metricValue(totalCurrent, metric), metric)}
76
+ value={formatValue(metricValue(totalCurrent, metric), metric)}
81
77
  delta={percentageDelta(metricValue(totalCurrent, metric), metricValue(totalPrevious, metric))}
78
+ decimal={decimal}
82
79
  />
83
80
  <SupportingTile
84
81
  testId="tile-avg-per-call"
85
82
  label={t("token_usage.admin.avg_per_call")}
86
- value={`€ ${formatDecimal(averagePerCall, 4)}`}
83
+ value={currency(averagePerCall, 4)}
84
+ decimal={decimal}
87
85
  />
88
86
  <SupportingTile
89
87
  testId="tile-cache-hit"
90
88
  label={t("token_usage.admin.cache_hit")}
91
- value={formatPercent(cacheHit)}
89
+ value={percent(cacheHit)}
90
+ decimal={decimal}
92
91
  />
93
92
  </div>
94
93
  </div>
@@ -101,12 +100,15 @@ function LeadTile({
101
100
  value,
102
101
  delta,
103
102
  previousLabel,
103
+ decimal,
104
104
  }: {
105
105
  testId: string;
106
106
  label: string;
107
107
  value: string;
108
108
  delta: number | undefined;
109
109
  previousLabel: string;
110
+ /** Passed down because `Delta` is a plain function, not a component: it cannot call the hook itself. */
111
+ decimal: (value: number, decimals: number) => string;
110
112
  }) {
111
113
  return (
112
114
  <Card data-testid={testId}>
@@ -114,7 +116,7 @@ function LeadTile({
114
116
  <span className="text-muted-foreground text-xs">{label}</span>
115
117
  <span className="text-primary text-xl font-semibold tabular-nums">{value}</span>
116
118
  <span className="flex items-center gap-1">
117
- <Delta testId={`${testId}-delta`} delta={delta} />
119
+ <Delta testId={`${testId}-delta`} delta={delta} decimal={decimal} />
118
120
  <span className="text-muted-foreground text-xs">{previousLabel}</span>
119
121
  </span>
120
122
  </CardContent>
@@ -127,11 +129,14 @@ function SupportingTile({
127
129
  label,
128
130
  value,
129
131
  delta,
132
+ decimal,
130
133
  }: {
131
134
  testId: string;
132
135
  label: string;
133
136
  value: string;
134
137
  delta?: number | undefined;
138
+ /** Passed down because `Delta` is a plain function, not a component: it cannot call the hook itself. */
139
+ decimal: (value: number, decimals: number) => string;
135
140
  }) {
136
141
  return (
137
142
  <Card data-testid={testId} size="sm">
@@ -139,7 +144,7 @@ function SupportingTile({
139
144
  <span className="text-muted-foreground text-xs">{label}</span>
140
145
  <span className="flex items-center gap-2">
141
146
  <span className="text-sm font-medium tabular-nums">{value}</span>
142
- {delta !== undefined && <Delta testId={`${testId}-delta`} delta={delta} />}
147
+ {delta !== undefined && <Delta testId={`${testId}-delta`} delta={delta} decimal={decimal} />}
143
148
  </span>
144
149
  </CardContent>
145
150
  </Card>
@@ -150,7 +155,15 @@ function SupportingTile({
150
155
  * The delta slot. An undefined delta means the previous window was zero: an
151
156
  * em dash says "not comparable" where a percentage would say "infinite growth".
152
157
  */
153
- function Delta({ testId, delta }: { testId: string; delta: number | undefined }) {
158
+ function Delta({
159
+ testId,
160
+ delta,
161
+ decimal,
162
+ }: {
163
+ testId: string;
164
+ delta: number | undefined;
165
+ decimal: (value: number, decimals: number) => string;
166
+ }) {
154
167
  if (delta === undefined) {
155
168
  return (
156
169
  <span data-testid={testId} className="text-muted-foreground text-xs">
@@ -171,7 +184,7 @@ function Delta({ testId, delta }: { testId: string; delta: number | undefined })
171
184
  )}
172
185
  >
173
186
  <Icon aria-hidden className="size-3" />
174
- {formatDecimal(Math.abs(delta), 0)} %
187
+ {decimal(Math.abs(delta), 0)} %
175
188
  </span>
176
189
  );
177
190
  }
@@ -7,7 +7,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ".
7
7
  import { cn } from "../../../utils";
8
8
  import type { TokenUsageAdminBreakdownInterface } from "../data/tokenusage-admin-breakdown.interface";
9
9
  import type { Metric } from "../data/tokenusage-admin.types";
10
- import { cacheHitPercentage, formatDecimal, formatMetricValue, formatPercent, metricValue } from "../lib/metrics";
10
+ import { useUsageFormatters } from "../lib/formatters";
11
+ import { cacheHitPercentage, metricValue } from "../lib/metrics";
11
12
 
12
13
  type Props = {
13
14
  rows: TokenUsageAdminBreakdownInterface[];
@@ -28,6 +29,7 @@ type SortState = { key: SortKey; direction: "asc" | "desc" };
28
29
  */
29
30
  export function TokenUsageBreakdownTable({ rows, metric }: Props) {
30
31
  const t = useTranslations();
32
+ const { decimal, metricValue: formatValue, percent, compare } = useUsageFormatters();
31
33
  const [sort, setSort] = useState<SortState | undefined>(undefined);
32
34
 
33
35
  // The company dimension is the only one that reports active users. Rather than
@@ -61,11 +63,11 @@ export function TokenUsageBreakdownTable({ rows, metric }: Props) {
61
63
  const left = sortValue(a, sort.key);
62
64
  const right = sortValue(b, sort.key);
63
65
  if (typeof left === "string" || typeof right === "string") {
64
- return String(left).localeCompare(String(right), "it-IT") * factor;
66
+ return compare(String(left), String(right)) * factor;
65
67
  }
66
68
  return (left - right) * factor;
67
69
  });
68
- }, [rows, sort, metric]);
70
+ }, [rows, sort, metric, compare]);
69
71
 
70
72
  // First click on a column sorts descending: on a spend table the interesting
71
73
  // end of every numeric column is the top one.
@@ -136,20 +138,18 @@ export function TokenUsageBreakdownTable({ rows, metric }: Props) {
136
138
  <TableCell className="text-muted-foreground text-xs">{row.sublabel ?? ""}</TableCell>
137
139
  {showActiveUsers && (
138
140
  <TableCell className="text-right text-xs tabular-nums">
139
- {row.activeUsers === undefined ? "" : formatDecimal(row.activeUsers, 0)}
141
+ {row.activeUsers === undefined ? "" : decimal(row.activeUsers, 0)}
140
142
  </TableCell>
141
143
  )}
142
- <TableCell className="text-right text-xs tabular-nums">{formatDecimal(row.calls, 0)}</TableCell>
143
- <TableCell className="text-right text-xs tabular-nums">{formatDecimal(row.tokensIn, 0)}</TableCell>
144
- <TableCell className="text-right text-xs tabular-nums">{formatDecimal(row.tokensOut, 0)}</TableCell>
144
+ <TableCell className="text-right text-xs tabular-nums">{decimal(row.calls, 0)}</TableCell>
145
+ <TableCell className="text-right text-xs tabular-nums">{decimal(row.tokensIn, 0)}</TableCell>
146
+ <TableCell className="text-right text-xs tabular-nums">{decimal(row.tokensOut, 0)}</TableCell>
145
147
  <TableCell className="text-right text-xs tabular-nums">
146
- {formatPercent(cacheHitPercentage(row.cached, row.tokensIn))}
148
+ {percent(cacheHitPercentage(row.cached, row.tokensIn))}
147
149
  </TableCell>
148
- <TableCell className="text-right text-xs tabular-nums">{formatMetricValue(row.cost, "cost")}</TableCell>
149
- <TableCell className="text-right text-xs tabular-nums">
150
- {formatMetricValue(row.credits, "credits")}
151
- </TableCell>
152
- <TableCell className="text-right text-xs tabular-nums">{formatPercent(share)}</TableCell>
150
+ <TableCell className="text-right text-xs tabular-nums">{formatValue(row.cost, "cost")}</TableCell>
151
+ <TableCell className="text-right text-xs tabular-nums">{formatValue(row.credits, "credits")}</TableCell>
152
+ <TableCell className="text-right text-xs tabular-nums">{percent(share)}</TableCell>
153
153
  </TableRow>
154
154
  );
155
155
  })}
@@ -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 { formatMetricValue, formatPercent, metricValue } from "../lib/metrics";
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: TokenUsageAdminBreakdownInterface[];
28
+ rows: TokenUsageRankedRow[];
13
29
  metric: Metric;
14
- /** Italian copy shown when there is nothing to rank. */
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: TokenUsageAdminBreakdownInterface): string => {
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
- {formatMetricValue(value, metric)}
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
- {formatPercent(share)}
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
+ }