@carlonicora/nextjs-jsonapi 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/dist/{BlockNoteEditor-PNAVMGHB.js → BlockNoteEditor-TIMGYKAA.js} +19 -19
  2. package/dist/{BlockNoteEditor-PNAVMGHB.js.map → BlockNoteEditor-TIMGYKAA.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-3PS3HYVI.mjs → BlockNoteEditor-YFJMXJEU.mjs} +4 -4
  4. package/dist/billing/index.d.mts +40 -1
  5. package/dist/billing/index.d.ts +40 -1
  6. package/dist/billing/index.js +519 -462
  7. package/dist/billing/index.js.map +1 -1
  8. package/dist/billing/index.mjs +298 -241
  9. package/dist/billing/index.mjs.map +1 -1
  10. package/dist/{chunk-QYUYQO2M.js → chunk-7HKJNUW7.js} +1144 -953
  11. package/dist/chunk-7HKJNUW7.js.map +1 -0
  12. package/dist/{chunk-MN2BUSEU.mjs → chunk-DJWNNV52.mjs} +2 -2
  13. package/dist/{chunk-EQEW64CH.mjs → chunk-FS4RVV3K.mjs} +242 -1
  14. package/dist/chunk-FS4RVV3K.mjs.map +1 -0
  15. package/dist/{chunk-FSMXD3N7.mjs → chunk-G4YS52SO.mjs} +2547 -2356
  16. package/dist/chunk-G4YS52SO.mjs.map +1 -0
  17. package/dist/{chunk-OHJQYVSI.js → chunk-OTEDZ6YW.js} +7 -7
  18. package/dist/{chunk-OHJQYVSI.js.map → chunk-OTEDZ6YW.js.map} +1 -1
  19. package/dist/{chunk-X5TPKMBS.js → chunk-RTOC53EZ.js} +247 -6
  20. package/dist/chunk-RTOC53EZ.js.map +1 -0
  21. package/dist/client/index.d.mts +1 -1
  22. package/dist/client/index.d.ts +1 -1
  23. package/dist/client/index.js +4 -4
  24. package/dist/client/index.mjs +3 -3
  25. package/dist/components/index.d.mts +137 -4
  26. package/dist/components/index.d.ts +137 -4
  27. package/dist/components/index.js +16 -4
  28. package/dist/components/index.js.map +1 -1
  29. package/dist/components/index.mjs +15 -3
  30. package/dist/{tokenusage-admin.module-5wJ_tZTj.d.ts → config-BIjrg5wd.d.ts} +39 -1
  31. package/dist/{tokenusage-admin.module-DQVT4O4j.d.mts → config-BRUjtCd1.d.mts} +39 -1
  32. package/dist/{content.fields-xH3TGvVk.d.mts → content.fields-1AlHDtDb.d.mts} +1 -0
  33. package/dist/{content.fields-xH3TGvVk.d.ts → content.fields-1AlHDtDb.d.ts} +1 -0
  34. package/dist/contexts/index.d.mts +22 -1
  35. package/dist/contexts/index.d.ts +22 -1
  36. package/dist/contexts/index.js +8 -4
  37. package/dist/contexts/index.js.map +1 -1
  38. package/dist/contexts/index.mjs +7 -3
  39. package/dist/core/index.d.mts +6 -3
  40. package/dist/core/index.d.ts +6 -3
  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.js +37 -37
  45. package/dist/features/help/index.mjs +3 -3
  46. package/dist/features/tokenusage/index.d.mts +413 -14
  47. package/dist/features/tokenusage/index.d.ts +413 -14
  48. package/dist/features/tokenusage/index.js +481 -120
  49. package/dist/features/tokenusage/index.js.map +1 -1
  50. package/dist/features/tokenusage/index.mjs +431 -70
  51. package/dist/features/tokenusage/index.mjs.map +1 -1
  52. package/dist/index.d.mts +3 -3
  53. package/dist/index.d.ts +3 -3
  54. package/dist/index.js +9 -3
  55. package/dist/index.js.map +1 -1
  56. package/dist/index.mjs +8 -2
  57. package/dist/server/index.js +3 -3
  58. package/dist/server/index.mjs +1 -1
  59. package/package.json +1 -1
  60. package/src/billing/index.ts +1 -0
  61. package/src/components/index.ts +1 -0
  62. package/src/contexts/index.ts +1 -0
  63. package/src/core/index.ts +10 -0
  64. package/src/core/registry/ModuleRegistry.ts +4 -0
  65. package/src/features/administration/components/AdminIndexContainer.tsx +150 -0
  66. package/src/features/administration/components/__tests__/AdminIndexContainer.spec.tsx +96 -0
  67. package/src/features/administration/components/index.ts +1 -0
  68. package/src/features/administration/contexts/AdministrationContext.tsx +47 -0
  69. package/src/features/administration/contexts/index.ts +1 -0
  70. package/src/features/administration/data/admin-section.types.ts +30 -0
  71. package/src/features/administration/i18n-keys.ts +26 -0
  72. package/src/features/administration/index.ts +3 -0
  73. package/src/features/billing/contexts/BillingContext.tsx +95 -0
  74. package/src/features/billing/contexts/index.ts +1 -0
  75. package/src/features/billing/index.ts +1 -0
  76. package/src/features/billing/stripe-product/components/containers/ProductsAdminContainer.tsx +39 -27
  77. package/src/features/billing/stripe-product/components/containers/ProductsAdminPageContainer.tsx +27 -0
  78. package/src/features/billing/stripe-product/components/containers/index.ts +1 -0
  79. package/src/features/company/components/containers/CompaniesListContainer.tsx +25 -0
  80. package/src/features/company/components/containers/index.ts +1 -0
  81. package/src/features/company/components/lists/CompaniesList.tsx +12 -1
  82. package/src/features/company/contexts/CompanyContext.tsx +13 -1
  83. package/src/features/tokenusage/components/TokenUsageAdminContainer.tsx +20 -4
  84. package/src/features/tokenusage/components/TokenUsageAdminTiles.tsx +31 -18
  85. package/src/features/tokenusage/components/TokenUsageBreakdownTable.tsx +13 -13
  86. package/src/features/tokenusage/components/TokenUsageRankedBar.tsx +24 -7
  87. package/src/features/tokenusage/components/TokenUsageReportContainer.tsx +102 -0
  88. package/src/features/tokenusage/components/TokenUsageReportFilterBar.tsx +34 -0
  89. package/src/features/tokenusage/components/TokenUsageReportTiles.tsx +161 -0
  90. package/src/features/tokenusage/components/TokenUsageTimelineChart.tsx +15 -13
  91. package/src/features/tokenusage/components/__tests__/TokenUsageAdminTiles.spec.tsx +4 -2
  92. package/src/features/tokenusage/components/__tests__/TokenUsageRankedBar.spec.tsx +3 -1
  93. package/src/features/tokenusage/contexts/TokenUsageAdminContext.tsx +6 -3
  94. package/src/features/tokenusage/contexts/TokenUsageReportContext.tsx +199 -0
  95. package/src/features/tokenusage/data/TokenUsageReportService.ts +65 -0
  96. package/src/features/tokenusage/data/index.ts +9 -0
  97. package/src/features/tokenusage/data/tokenusage-report-breakdown.interface.ts +12 -0
  98. package/src/features/tokenusage/data/tokenusage-report-breakdown.ts +94 -0
  99. package/src/features/tokenusage/data/tokenusage-report-summary.interface.ts +12 -0
  100. package/src/features/tokenusage/data/tokenusage-report-summary.ts +87 -0
  101. package/src/features/tokenusage/data/tokenusage-report-timeline.interface.ts +13 -0
  102. package/src/features/tokenusage/data/tokenusage-report-timeline.ts +94 -0
  103. package/src/features/tokenusage/data/tokenusage-report.types.ts +51 -0
  104. package/src/features/tokenusage/i18n-keys.ts +28 -0
  105. package/src/features/tokenusage/index.ts +18 -0
  106. package/src/features/tokenusage/lib/config.ts +22 -0
  107. package/src/features/tokenusage/lib/formatters.ts +100 -0
  108. package/src/features/tokenusage/lib/metrics.ts +8 -26
  109. package/src/features/tokenusage/lib/palette.ts +41 -14
  110. package/src/features/tokenusage/tokenusage-admin.module.ts +4 -0
  111. package/src/features/tokenusage/tokenusage.modules.ts +40 -0
  112. package/src/features/user/components/containers/PlatformUsersContainer.tsx +23 -0
  113. package/src/features/user/components/containers/index.ts +1 -0
  114. package/src/features/user/components/lists/PlatformUsersList.tsx +45 -0
  115. package/src/features/user/components/lists/index.ts +1 -0
  116. package/src/features/user/data/user.fields.ts +1 -0
  117. package/src/features/user/hooks/useUserTableStructure.tsx +17 -0
  118. package/src/shadcnui/ui/chart.tsx +30 -3
  119. package/dist/chunk-EQEW64CH.mjs.map +0 -1
  120. package/dist/chunk-FSMXD3N7.mjs.map +0 -1
  121. package/dist/chunk-QYUYQO2M.js.map +0 -1
  122. package/dist/chunk-X5TPKMBS.js.map +0 -1
  123. /package/dist/{BlockNoteEditor-3PS3HYVI.mjs.map → BlockNoteEditor-YFJMXJEU.mjs.map} +0 -0
  124. /package/dist/{chunk-MN2BUSEU.mjs.map → chunk-DJWNNV52.mjs.map} +0 -0
@@ -0,0 +1,30 @@
1
+ import { LucideIcon } from "lucide-react";
2
+
3
+ /**
4
+ * One row of the administration index: an icon, a translated label and
5
+ * description, and the route it opens.
6
+ *
7
+ * Labels and descriptions are i18n *keys*, never resolved strings, so
8
+ * AdminIndexGrid stays the single place that calls useTranslations().
9
+ * `labelValues` / `descriptionValues` carry ICU arguments — `{ count: 2 }` for
10
+ * the `entities.*` plural keys, or `{ type: "..." }` for an app reusing its own
11
+ * parameterised heading key.
12
+ */
13
+ export type AdminSection = {
14
+ /** Stable React key, and the `admin-index-<key>` test id on the rendered link. */
15
+ key: string;
16
+ icon: LucideIcon;
17
+ labelKey: string;
18
+ labelValues?: Record<string, string | number>;
19
+ descriptionKey: string;
20
+ descriptionValues?: Record<string, string | number>;
21
+ href: string;
22
+ };
23
+
24
+ /** A titled block of sections. The built-in "platform" group always renders first. */
25
+ export type AdminGroup = {
26
+ key: string;
27
+ labelKey: string;
28
+ labelValues?: Record<string, string | number>;
29
+ sections: AdminSection[];
30
+ };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The fixed namespace of i18n keys the administration index reads via
3
+ * useTranslations. Consuming apps must define each entry in their
4
+ * messages/<locale>.json — this list is the contract between the package and
5
+ * the app, and the names are frozen: an app that wants different wording
6
+ * changes its own translation, never the key.
7
+ *
8
+ * Section LABELS are deliberately absent: they reuse keys every NJA app already
9
+ * defines — `entities.companies`, `entities.users`, `entities.rbac` (all with
10
+ * `{ count: 2 }`), `token_usage.admin.title`, and
11
+ * `billing.admin.products.title` — so section names stay in each app's own
12
+ * vocabulary.
13
+ *
14
+ * Mirrors the contract shipped by the token-usage feature
15
+ * (features/tokenusage/i18n-keys.ts).
16
+ */
17
+ export const ADMINISTRATION_I18N_KEYS = [
18
+ "administration.title",
19
+ "administration.subtitle",
20
+ "administration.group.platform",
21
+ "administration.companies.description",
22
+ "administration.users.description",
23
+ "administration.token_usage.description",
24
+ "administration.rbac.description",
25
+ "administration.billing.description",
26
+ ] as const;
@@ -0,0 +1,3 @@
1
+ export * from "./components";
2
+ export * from "./data/admin-section.types";
3
+ export { ADMINISTRATION_I18N_KEYS } from "./i18n-keys";
@@ -0,0 +1,95 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { createContext, ReactNode, useCallback, useContext, useState } from "react";
5
+ import { SharedProvider } from "../../../contexts/SharedContext";
6
+ import { BreadcrumbItemData } from "../../../interfaces";
7
+ import { getRoleId } from "../../../roles";
8
+ import { Button } from "../../../shadcnui";
9
+ import { useCurrentUserContext } from "../../user/contexts";
10
+ import { ProductEditor } from "../stripe-product/components/forms/ProductEditor";
11
+
12
+ interface BillingContextType {
13
+ /**
14
+ * Bumped every time the provider's own actions change the product catalogue.
15
+ * ProductsAdminContainer reloads on it — the create action lives up here in
16
+ * the page title bar, so it cannot call the container's loader directly.
17
+ */
18
+ refreshToken: number;
19
+ refresh: () => void;
20
+ }
21
+
22
+ const BillingContext = createContext<BillingContextType | undefined>(undefined);
23
+
24
+ type BillingProviderProps = {
25
+ children: ReactNode;
26
+ };
27
+
28
+ /**
29
+ * Page chrome for the administrative billing surface (Stripe products, prices
30
+ * and their credit allowances).
31
+ *
32
+ * Billing gets its own provider rather than borrowing AdministrationProvider so
33
+ * the page carries its own name — and, like CompanyProvider with CompanyEditor,
34
+ * it owns the page's ACTIONS: the create-product control is published through
35
+ * `title.functions`, which RoundPageContainerTitle renders in the header bar.
36
+ * That is where every other page in this codebase puts its primary action.
37
+ */
38
+ export const BillingProvider = ({ children }: BillingProviderProps) => {
39
+ const t = useTranslations();
40
+ const { hasRole } = useCurrentUserContext();
41
+ const [showCreateProduct, setShowCreateProduct] = useState<boolean>(false);
42
+ const [refreshToken, setRefreshToken] = useState<number>(0);
43
+
44
+ const refresh = useCallback(() => setRefreshToken((value) => value + 1), []);
45
+
46
+ // No href: this crumb IS the current page. Linking it would also point at the
47
+ // StripeProduct module's `pageUrl` (/stripe-products), which no consuming app
48
+ // routes — the page lives under the administration tree.
49
+ const breadcrumbs: BreadcrumbItemData[] = [{ name: t(`billing.admin.products.title`) }];
50
+
51
+ const title = () => {
52
+ const response: any = { type: t(`billing.admin.products.title`) };
53
+
54
+ if (hasRole(getRoleId().Administrator)) {
55
+ response.functions = [
56
+ <Button key="productEditorTrigger" onClick={() => setShowCreateProduct(true)}>
57
+ {t(`billing.admin.products.create`)}
58
+ </Button>,
59
+ showCreateProduct ? (
60
+ <ProductEditor
61
+ key="productEditor"
62
+ open={showCreateProduct}
63
+ onOpenChange={setShowCreateProduct}
64
+ onSuccess={() => {
65
+ setShowCreateProduct(false);
66
+ refresh();
67
+ }}
68
+ />
69
+ ) : null,
70
+ ];
71
+ }
72
+
73
+ return response;
74
+ };
75
+
76
+ return (
77
+ <SharedProvider value={{ breadcrumbs, title: title() }}>
78
+ <BillingContext.Provider value={{ refreshToken, refresh }}>{children}</BillingContext.Provider>
79
+ </SharedProvider>
80
+ );
81
+ };
82
+
83
+ export const useBillingContext = (): BillingContextType => {
84
+ const context = useContext(BillingContext);
85
+ if (context === undefined) {
86
+ throw new Error("useBillingContext must be used within a BillingProvider");
87
+ }
88
+ return context;
89
+ };
90
+
91
+ /**
92
+ * Non-throwing variant. ProductsAdminContainer predates this provider and is
93
+ * still mounted bare by other consumers, so it must keep working without one.
94
+ */
95
+ export const useOptionalBillingContext = (): BillingContextType | undefined => useContext(BillingContext);
@@ -0,0 +1 @@
1
+ export * from "./BillingContext";
@@ -1,3 +1,4 @@
1
1
  export * from "./components";
2
+ export * from "./contexts";
2
3
  export * from "./data";
3
4
  export * from "./modules";
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
5
5
  import { getRoleId } from "../../../../../roles";
6
6
  import { Button } from "../../../../../shadcnui";
7
7
  import { useCurrentUserContext } from "../../../../user/contexts";
8
+ import { useOptionalBillingContext } from "../../../contexts/BillingContext";
8
9
  import { StripeProductInterface } from "../../data/stripe-product.interface";
9
10
  import { StripeProductService } from "../../data/stripe-product.service";
10
11
  import { ProductEditor } from "../forms/ProductEditor";
@@ -12,6 +13,10 @@ import { ProductsList } from "../lists/ProductsList";
12
13
 
13
14
  export function ProductsAdminContainer() {
14
15
  const { hasRole } = useCurrentUserContext();
16
+ // Optional on purpose: the create action lives in BillingProvider's title
17
+ // functions when this container is a page, but the container is still mounted
18
+ // bare elsewhere, where it keeps its own inline create button.
19
+ const billing = useOptionalBillingContext();
15
20
  const [products, setProducts] = useState<StripeProductInterface[]>([]);
16
21
  const [loading, setLoading] = useState<boolean>(true);
17
22
  const [showCreateProduct, setShowCreateProduct] = useState<boolean>(false);
@@ -37,9 +42,10 @@ export function ProductsAdminContainer() {
37
42
  }
38
43
  };
39
44
 
45
+ // Reloads on mount and whenever the provider's create action succeeds.
40
46
  useEffect(() => {
41
47
  loadProducts();
42
- }, []);
48
+ }, [billing?.refreshToken]);
43
49
 
44
50
  if (loading) {
45
51
  return (
@@ -50,37 +56,43 @@ export function ProductsAdminContainer() {
50
56
  }
51
57
 
52
58
  return (
53
- <div className="flex w-full flex-col gap-y-6">
54
- {/* Header */}
55
- <div className="flex items-center justify-between">
56
- <div className="flex items-center gap-x-3">
57
- <Package className="h-8 w-8" />
58
- <h1 className="text-primary text-3xl font-semibold">Product & Price Management</h1>
59
+ <div className="flex w-full flex-col">
60
+ {/* No header here. The page name and the create action belong to
61
+ BillingProvider, which publishes them through `title.functions` for
62
+ RoundPageContainerTitle to render — the same way CompanyProvider
63
+ publishes CompanyEditor. A header drawn inside the content would
64
+ duplicate the title bar and strand the button above the list. */}
65
+ {/* Fallback for consumers mounting this container without BillingProvider:
66
+ they have no title bar to host the action, so it stays inline. */}
67
+ {!billing && (
68
+ <div className="flex w-full items-center justify-end px-4 pt-4">
69
+ <Button onClick={() => setShowCreateProduct(true)}>Create Product</Button>
59
70
  </div>
60
- <Button onClick={() => setShowCreateProduct(true)}>Create Product</Button>
61
- </div>
71
+ )}
62
72
 
63
- {/* Empty State */}
64
- {products.length === 0 && (
65
- <div className="bg-muted/50 flex flex-col items-center justify-center gap-y-4 rounded-lg border-2 border-dashed p-12">
66
- <Package className="text-muted-foreground h-16 w-16" />
67
- <div className="text-center">
68
- <h3 className="mb-2 text-sm font-medium">No products yet</h3>
69
- <p className="text-muted-foreground mb-4">
70
- Create your first product to start offering subscriptions to your customers.
71
- </p>
72
- <Button onClick={() => setShowCreateProduct(true)}>Create Your First Product</Button>
73
+ <div className="flex w-full flex-col gap-y-6 px-4 pb-4">
74
+ {/* Empty State */}
75
+ {products.length === 0 && (
76
+ <div className="bg-muted/50 flex flex-col items-center justify-center gap-y-4 rounded-lg border-2 border-dashed p-12">
77
+ <Package className="text-muted-foreground h-16 w-16" />
78
+ <div className="text-center">
79
+ <h3 className="mb-2 text-sm font-medium">No products yet</h3>
80
+ <p className="text-muted-foreground mb-4">
81
+ Create your first product to start offering subscriptions to your customers.
82
+ </p>
83
+ <Button onClick={() => setShowCreateProduct(true)}>Create Your First Product</Button>
84
+ </div>
73
85
  </div>
74
- </div>
75
- )}
86
+ )}
76
87
 
77
- {/* Products List */}
78
- {products.length > 0 && <ProductsList products={products} onProductsChange={loadProducts} />}
88
+ {/* Products List */}
89
+ {products.length > 0 && <ProductsList products={products} onProductsChange={loadProducts} />}
79
90
 
80
- {/* Create Product Modal */}
81
- {showCreateProduct && (
82
- <ProductEditor open={showCreateProduct} onOpenChange={setShowCreateProduct} onSuccess={loadProducts} />
83
- )}
91
+ {/* Create Product Modal */}
92
+ {showCreateProduct && (
93
+ <ProductEditor open={showCreateProduct} onOpenChange={setShowCreateProduct} onSuccess={loadProducts} />
94
+ )}
95
+ </div>
84
96
  </div>
85
97
  );
86
98
  }
@@ -0,0 +1,27 @@
1
+ "use client";
2
+
3
+ import { RoundPageContainer } from "../../../../../components";
4
+ import { Modules } from "../../../../../core";
5
+ import { BillingProvider } from "../../../contexts/BillingContext";
6
+ import { ProductsAdminContainer } from "./ProductsAdminContainer";
7
+
8
+ /**
9
+ * Page shell around the Stripe product/price administration surface.
10
+ *
11
+ * Client component by necessity: `module={Modules.StripeProduct}` is a registry
12
+ * entry with an icon component and methods, which a Server Component cannot pass
13
+ * across the boundary.
14
+ */
15
+ export function ProductsAdminPageContainer() {
16
+ return (
17
+ <BillingProvider>
18
+ {/* forceHeader, unlike the sibling list pages: those get their header row
19
+ (title + actions) from ContentListTable, and this page has no table.
20
+ The strip is what renders BillingProvider's title and its create-product
21
+ action — the same arrangement TokenUsageAdminContainer uses. */}
22
+ <RoundPageContainer module={Modules.StripeProduct} fullWidth forceHeader>
23
+ <ProductsAdminContainer />
24
+ </RoundPageContainer>
25
+ </BillingProvider>
26
+ );
27
+ }
@@ -1 +1,2 @@
1
1
  export * from "./ProductsAdminContainer";
2
+ export * from "./ProductsAdminPageContainer";
@@ -0,0 +1,25 @@
1
+ "use client";
2
+
3
+ import { RoundPageContainer } from "../../../../components";
4
+ import { Modules } from "../../../../core";
5
+ import { CompanyProvider } from "../../contexts/CompanyContext";
6
+ import { CompaniesList } from "../lists/CompaniesList";
7
+
8
+ /**
9
+ * Page container for the administrative company list.
10
+ *
11
+ * This has to be a client component: `module={Modules.Company}` is a registry
12
+ * entry carrying an icon component and methods, and a Server Component cannot
13
+ * pass it across the boundary ("Only plain objects can be passed to Client
14
+ * Components"). Routes mount this container instead — the same shape as every
15
+ * other list page.
16
+ */
17
+ export function CompaniesListContainer() {
18
+ return (
19
+ <CompanyProvider>
20
+ <RoundPageContainer module={Modules.Company} fullWidth>
21
+ <CompaniesList fullWidth />
22
+ </RoundPageContainer>
23
+ </CompanyProvider>
24
+ );
25
+ }
@@ -1,2 +1,3 @@
1
1
  export * from "./AdminCompanyContainer";
2
+ export * from "./CompaniesListContainer";
2
3
  export * from "./CompanyContainer";
@@ -9,7 +9,17 @@ import { CompanyFields, CompanyInterface } from "../../data";
9
9
  import { CompanyService } from "../../data/company.service";
10
10
  import { CompanyEditor } from "../forms/CompanyEditor";
11
11
 
12
- export function CompaniesList() {
12
+ type CompaniesListProps = {
13
+ /**
14
+ * Pass this whenever the list is the page's own content inside a
15
+ * `RoundPageContainer fullWidth`. Without it ContentListTable wraps itself in
16
+ * `rounded-md border`, drawing a second bordered card inside the page's
17
+ * rounded shell — two borders fighting each other at every corner.
18
+ */
19
+ fullWidth?: boolean;
20
+ };
21
+
22
+ export function CompaniesList({ fullWidth }: CompaniesListProps = {}) {
13
23
  const t = useTranslations();
14
24
 
15
25
  const data: DataListRetriever<CompanyInterface> = useDataListRetriever({
@@ -26,6 +36,7 @@ export function CompaniesList() {
26
36
  fields={[CompanyFields.name]}
27
37
  tableGeneratorType={Modules.Company}
28
38
  functions={functions}
39
+ fullWidth={fullWidth}
29
40
  title={t(`entities.companies`, { count: 2 })}
30
41
  />
31
42
  );
@@ -43,10 +43,22 @@ export const CompanyProvider = ({ children, dehydratedCompany, configurationEdit
43
43
  const breadcrumb = () => {
44
44
  const response: BreadcrumbItemData[] = [];
45
45
 
46
+ // The list root always comes first, exactly as UserProvider does it —
47
+ // without it the company list rendered a breadcrumb of just "Home", while
48
+ // every sibling administration page showed its own name.
49
+ //
50
+ // Linked ONLY when it is not the current page: the Company module's
51
+ // `pageUrl` is /companies, which the administration tree does not route, so
52
+ // a link from the list itself would land on a 404.
53
+ response.push({
54
+ name: t(`entities.companies`, { count: 2 }),
55
+ ...(company ? { href: generateUrl({ page: Modules.Company }) } : {}),
56
+ });
57
+
46
58
  if (company)
47
59
  response.push({
48
60
  name: company.name,
49
- href: generateUrl({ page: Modules.Company }),
61
+ href: generateUrl({ page: Modules.Company, id: company.id }),
50
62
  });
51
63
 
52
64
  return response;
@@ -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
  })}