@burdenoff/microfe-subscriberbot 2026.1030.6 → 2026.1030.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/subscriberbot/SubscriberBotRoutes.d.ts.map +1 -1
- package/dist/subscriberbot/SubscriberBotRoutes.js +10 -6
- package/dist/subscriberbot/SubscriberBotRoutes.js.map +1 -1
- package/dist/subscriberbot/hooks/index.d.ts +1 -1
- package/dist/subscriberbot/hooks/index.d.ts.map +1 -1
- package/dist/subscriberbot/hooks/useSubscriberBotApi.d.ts +4 -1
- package/dist/subscriberbot/hooks/useSubscriberBotApi.d.ts.map +1 -1
- package/dist/subscriberbot/hooks/useSubscriberBotApi.js +137 -72
- package/dist/subscriberbot/hooks/useSubscriberBotApi.js.map +1 -1
- package/dist/subscriberbot/pages/enterprise/EnterpriseSpendPage.d.ts +13 -0
- package/dist/subscriberbot/pages/enterprise/EnterpriseSpendPage.d.ts.map +1 -0
- package/dist/subscriberbot/pages/enterprise/EnterpriseSpendPage.js +703 -0
- package/dist/subscriberbot/pages/enterprise/EnterpriseSpendPage.js.map +1 -0
- package/dist/subscriberbot/pages/relationships/RelationshipDetailPage.d.ts.map +1 -1
- package/dist/subscriberbot/pages/relationships/RelationshipDetailPage.js +276 -262
- package/dist/subscriberbot/pages/relationships/RelationshipDetailPage.js.map +1 -1
- package/dist/subscriberbot/pages/relationships/RelationshipNewPage.d.ts.map +1 -1
- package/dist/subscriberbot/pages/relationships/RelationshipNewPage.js +65 -45
- package/dist/subscriberbot/pages/relationships/RelationshipNewPage.js.map +1 -1
- package/dist/subscriberbot/types/index.d.ts +144 -0
- package/dist/subscriberbot/types/index.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
import { useSubscriberBot as e } from "../../context/SubscriberBotContext.js";
|
|
2
|
+
import { cn as t } from "../../utils/cn.js";
|
|
3
|
+
import { PageLayout as n } from "../../components/PageLayout.js";
|
|
4
|
+
import { MetricsCard as r } from "../../components/MetricsCard.js";
|
|
5
|
+
import { formatCurrency as i, formatDate as a } from "../../utils/formatters.js";
|
|
6
|
+
import { EmptyState as o } from "../../components/EmptyState.js";
|
|
7
|
+
import { Modal as s } from "../../components/Modal.js";
|
|
8
|
+
import { useTr as c } from "../../hooks/useTr.js";
|
|
9
|
+
import { ErrorView as l, LoadingView as u, Section as d } from "../../components/StateViews.js";
|
|
10
|
+
import { BarList as f, MiniProgress as p, Sparkline as m } from "../../components/viz.js";
|
|
11
|
+
import "../../components/index.js";
|
|
12
|
+
import { useEnterpriseSpendDashboard as h, useSetSpendBudget as g, useSpendBudgets as _ } from "../../hooks/useSubscriberBotApi.js";
|
|
13
|
+
import { useMemo as v, useState as y } from "react";
|
|
14
|
+
import { AlertTriangle as b, Building2 as x, CalendarClock as S, Copy as C, EyeOff as w, Layers as T, PiggyBank as E, ShieldAlert as D, Target as O, Users as k, Wallet as A } from "lucide-react";
|
|
15
|
+
import { jsx as j, jsxs as M } from "react/jsx-runtime";
|
|
16
|
+
import { GlassCard as N, PagePurpose as P } from "@burdenoff/fe-libs/ui";
|
|
17
|
+
//#region src/subscriberbot/pages/enterprise/EnterpriseSpendPage.tsx
|
|
18
|
+
var F = {
|
|
19
|
+
UNCATALOGUED: {
|
|
20
|
+
key: "enterprise.shadow.reasons.uncatalogued",
|
|
21
|
+
fallback: "Not in the catalogue"
|
|
22
|
+
},
|
|
23
|
+
UNVERIFIED_PROVIDER: {
|
|
24
|
+
key: "enterprise.shadow.reasons.unverified",
|
|
25
|
+
fallback: "Unverified provider"
|
|
26
|
+
},
|
|
27
|
+
DEPRECATED_PROVIDER: {
|
|
28
|
+
key: "enterprise.shadow.reasons.deprecated",
|
|
29
|
+
fallback: "Deprecated provider"
|
|
30
|
+
}
|
|
31
|
+
}, I = {
|
|
32
|
+
EXACT_DUPLICATE: {
|
|
33
|
+
key: "enterprise.duplicates.types.exact",
|
|
34
|
+
fallback: "Same vendor"
|
|
35
|
+
},
|
|
36
|
+
CAPABILITY: {
|
|
37
|
+
key: "enterprise.duplicates.types.capability",
|
|
38
|
+
fallback: "Same capability"
|
|
39
|
+
},
|
|
40
|
+
ENTITLEMENT: {
|
|
41
|
+
key: "enterprise.duplicates.types.entitlement",
|
|
42
|
+
fallback: "Covered by another plan"
|
|
43
|
+
}
|
|
44
|
+
}, L = {
|
|
45
|
+
PAST_DUE: {
|
|
46
|
+
key: "enterprise.risks.kinds.pastDue",
|
|
47
|
+
fallback: "Payment failed"
|
|
48
|
+
},
|
|
49
|
+
MISSING_INVOICE: {
|
|
50
|
+
key: "enterprise.risks.kinds.missingInvoice",
|
|
51
|
+
fallback: "Missing invoice"
|
|
52
|
+
},
|
|
53
|
+
UNTRACKED_TRIAL: {
|
|
54
|
+
key: "enterprise.risks.kinds.untrackedTrial",
|
|
55
|
+
fallback: "Untracked trial"
|
|
56
|
+
},
|
|
57
|
+
HIGH_COST_AI: {
|
|
58
|
+
key: "enterprise.risks.kinds.highCostAi",
|
|
59
|
+
fallback: "High-cost AI service"
|
|
60
|
+
}
|
|
61
|
+
}, R = {
|
|
62
|
+
HIGH: "bg-status-error-bg-subtle text-status-error-text",
|
|
63
|
+
MEDIUM: "bg-status-warning-bg-subtle text-status-warning-text",
|
|
64
|
+
LOW: "bg-bg-sunken text-text-secondary"
|
|
65
|
+
}, z = "w-full rounded-lg border border-border-subtle bg-bg-surface px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)]", B = "mb-1.5 block text-xs font-medium text-text-secondary", V = "truncate text-left text-sm font-medium text-text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)] rounded";
|
|
66
|
+
function H(e, t) {
|
|
67
|
+
return t ?? e("enterprise.departments.unassigned", "Unassigned");
|
|
68
|
+
}
|
|
69
|
+
var U = ({ status: e, fmt: n, tr: r }) => /* @__PURE__ */ M("div", {
|
|
70
|
+
className: "space-y-1.5",
|
|
71
|
+
children: [/* @__PURE__ */ j(p, {
|
|
72
|
+
value: Math.min(e.utilizationPct, 100),
|
|
73
|
+
tone: e.overBudget ? "negative" : e.utilizationPct >= 85 ? "warning" : "positive"
|
|
74
|
+
}), /* @__PURE__ */ j("p", {
|
|
75
|
+
className: t("text-xs tabular-nums", e.overBudget ? "text-status-error-text" : "text-text-secondary"),
|
|
76
|
+
children: e.overBudget ? r("enterprise.budget.over", "{{amount}} over a {{budget}} budget", {
|
|
77
|
+
amount: n(-e.variance),
|
|
78
|
+
budget: n(e.monthlyBudget)
|
|
79
|
+
}) : r("enterprise.budget.under", "{{amount}} left of {{budget}} ({{pct}}% used)", {
|
|
80
|
+
amount: n(e.variance),
|
|
81
|
+
budget: n(e.monthlyBudget),
|
|
82
|
+
pct: e.utilizationPct
|
|
83
|
+
})
|
|
84
|
+
})]
|
|
85
|
+
}), W = ({ title: e, icon: t, children: n, testId: r }) => /* @__PURE__ */ M(N, {
|
|
86
|
+
treatment: "plain",
|
|
87
|
+
className: "p-6",
|
|
88
|
+
"data-testid": r,
|
|
89
|
+
children: [/* @__PURE__ */ M("div", {
|
|
90
|
+
className: "mb-4 flex items-center gap-2",
|
|
91
|
+
children: [/* @__PURE__ */ j("span", {
|
|
92
|
+
className: "text-text-secondary",
|
|
93
|
+
children: t
|
|
94
|
+
}), /* @__PURE__ */ j("h3", {
|
|
95
|
+
className: "text-sm font-semibold text-text-primary",
|
|
96
|
+
children: e
|
|
97
|
+
})]
|
|
98
|
+
}), n]
|
|
99
|
+
}), G = ({ currency: e, departments: t, onClose: n }) => {
|
|
100
|
+
let r = c(), a = _(), o = g(), [l, d] = y(""), [f, p] = y(""), [m, h] = y(null), v = (a.data ?? []).filter((t) => t.currency === e), b = (t, n) => {
|
|
101
|
+
h(null), o.mutate({
|
|
102
|
+
department: t,
|
|
103
|
+
currency: e,
|
|
104
|
+
monthlyAmount: n
|
|
105
|
+
}, {
|
|
106
|
+
onSuccess: () => {
|
|
107
|
+
p(""), d("");
|
|
108
|
+
},
|
|
109
|
+
onError: (e) => h(e instanceof Error ? e.message : r("enterprise.budgets.error", "Could not save the budget."))
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
return /* @__PURE__ */ M(s, {
|
|
113
|
+
title: r("enterprise.budgets.title", "Monthly budgets"),
|
|
114
|
+
description: r("enterprise.budgets.description", "Budgets apply to spend billed in {{currency}}. Leave the department empty for the company-wide budget.", { currency: e }),
|
|
115
|
+
onClose: n,
|
|
116
|
+
maxWidthClassName: "max-w-xl",
|
|
117
|
+
children: [/* @__PURE__ */ M("form", {
|
|
118
|
+
onSubmit: (e) => {
|
|
119
|
+
e.preventDefault();
|
|
120
|
+
let t = Number(f);
|
|
121
|
+
if (!f.trim() || !Number.isFinite(t) || t <= 0) {
|
|
122
|
+
h(r("enterprise.budgets.amountRequired", "Enter a monthly amount above zero."));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
b(l.trim() || null, t);
|
|
126
|
+
},
|
|
127
|
+
className: "space-y-4",
|
|
128
|
+
children: [m && /* @__PURE__ */ j("div", {
|
|
129
|
+
className: "rounded-lg border border-status-error-text/30 bg-status-error-bg-subtle px-4 py-2 text-sm text-status-error-text",
|
|
130
|
+
children: m
|
|
131
|
+
}), /* @__PURE__ */ M("div", {
|
|
132
|
+
className: "grid grid-cols-1 gap-3 sm:grid-cols-[1fr_10rem_auto] sm:items-end",
|
|
133
|
+
children: [
|
|
134
|
+
/* @__PURE__ */ M("div", { children: [
|
|
135
|
+
/* @__PURE__ */ j("label", {
|
|
136
|
+
htmlFor: "budget-department",
|
|
137
|
+
className: B,
|
|
138
|
+
children: r("enterprise.budgets.department", "Department (optional)")
|
|
139
|
+
}),
|
|
140
|
+
/* @__PURE__ */ j("input", {
|
|
141
|
+
id: "budget-department",
|
|
142
|
+
list: "budget-department-options",
|
|
143
|
+
value: l,
|
|
144
|
+
maxLength: 120,
|
|
145
|
+
onChange: (e) => d(e.target.value),
|
|
146
|
+
placeholder: r("enterprise.budgets.companyWide", "Company-wide"),
|
|
147
|
+
className: z
|
|
148
|
+
}),
|
|
149
|
+
/* @__PURE__ */ j("datalist", {
|
|
150
|
+
id: "budget-department-options",
|
|
151
|
+
children: t.map((e) => /* @__PURE__ */ j("option", { value: e }, e))
|
|
152
|
+
})
|
|
153
|
+
] }),
|
|
154
|
+
/* @__PURE__ */ M("div", { children: [/* @__PURE__ */ j("label", {
|
|
155
|
+
htmlFor: "budget-amount",
|
|
156
|
+
className: B,
|
|
157
|
+
children: r("enterprise.budgets.amount", "Per month ({{currency}})", { currency: e })
|
|
158
|
+
}), /* @__PURE__ */ j("input", {
|
|
159
|
+
id: "budget-amount",
|
|
160
|
+
type: "number",
|
|
161
|
+
min: "0",
|
|
162
|
+
step: "0.01",
|
|
163
|
+
inputMode: "decimal",
|
|
164
|
+
value: f,
|
|
165
|
+
onChange: (e) => p(e.target.value),
|
|
166
|
+
className: z
|
|
167
|
+
})] }),
|
|
168
|
+
/* @__PURE__ */ j("button", {
|
|
169
|
+
type: "submit",
|
|
170
|
+
disabled: o.isPending,
|
|
171
|
+
className: "rounded-lg bg-action-primary-bg px-4 py-2 text-sm font-medium text-action-primary-text hover:opacity-90 disabled:opacity-60",
|
|
172
|
+
children: r("enterprise.budgets.save", "Save budget")
|
|
173
|
+
})
|
|
174
|
+
]
|
|
175
|
+
})]
|
|
176
|
+
}), /* @__PURE__ */ j("div", {
|
|
177
|
+
className: "mt-6",
|
|
178
|
+
children: a.isLoading ? /* @__PURE__ */ j(u, { label: r("enterprise.budgets.loading", "Loading budgets…") }) : v.length === 0 ? /* @__PURE__ */ j("p", {
|
|
179
|
+
className: "text-sm text-text-secondary",
|
|
180
|
+
children: r("enterprise.budgets.none", "No budgets set for {{currency}} yet.", { currency: e })
|
|
181
|
+
}) : /* @__PURE__ */ j("ul", {
|
|
182
|
+
className: "divide-y divide-border-subtle rounded-lg border border-border-subtle",
|
|
183
|
+
children: v.map((e) => /* @__PURE__ */ M("li", {
|
|
184
|
+
className: "flex items-center justify-between gap-3 px-4 py-3",
|
|
185
|
+
children: [/* @__PURE__ */ j("span", {
|
|
186
|
+
className: "truncate text-sm text-text-primary",
|
|
187
|
+
children: e.department ?? r("enterprise.budgets.companyWide", "Company-wide")
|
|
188
|
+
}), /* @__PURE__ */ M("span", {
|
|
189
|
+
className: "flex items-center gap-3",
|
|
190
|
+
children: [/* @__PURE__ */ j("span", {
|
|
191
|
+
className: "text-sm font-semibold tabular-nums text-text-primary",
|
|
192
|
+
children: i(e.monthlyAmount, e.currency)
|
|
193
|
+
}), /* @__PURE__ */ j("button", {
|
|
194
|
+
type: "button",
|
|
195
|
+
onClick: () => b(e.department, null),
|
|
196
|
+
disabled: o.isPending,
|
|
197
|
+
className: "text-xs font-medium text-status-error-text hover:underline disabled:opacity-60",
|
|
198
|
+
children: r("enterprise.budgets.remove", "Remove")
|
|
199
|
+
})]
|
|
200
|
+
})]
|
|
201
|
+
}, e.id))
|
|
202
|
+
})
|
|
203
|
+
})]
|
|
204
|
+
});
|
|
205
|
+
};
|
|
206
|
+
function K() {
|
|
207
|
+
let s = c(), { navigateTo: g } = e(), [_, z] = y(null), [B, K] = y(!1), q = h(_), J = q.data, Y = v(() => {
|
|
208
|
+
let e = J?.currency ?? "USD";
|
|
209
|
+
return (t) => i(t, e);
|
|
210
|
+
}, [J?.currency]), X = (e) => g(`/relationships/${e}`);
|
|
211
|
+
if (q.isLoading) return /* @__PURE__ */ j(u, { label: s("enterprise.loading", "Aggregating company-wide spend…") });
|
|
212
|
+
if (q.isError || !J) return /* @__PURE__ */ j(l, {
|
|
213
|
+
message: s("enterprise.error", "Could not load the enterprise spend dashboard."),
|
|
214
|
+
onRetry: () => void q.refetch()
|
|
215
|
+
});
|
|
216
|
+
let Z = J.renewalPipeline[0], Q = J.monthlySpendChangePct, $ = [J.currency, ...J.otherCurrencies.map((e) => e.currency)], ee = J.departments.map((e) => e.department).filter((e) => !!e), te = J.activeRelationshipCount === 0 && J.shadowItCount === 0 && J.riskFlagCount === 0 && J.departments.length === 0;
|
|
217
|
+
return /* @__PURE__ */ M(n, {
|
|
218
|
+
title: s("enterprise.title", "SaaS spend management"),
|
|
219
|
+
description: s("enterprise.description", "Company-wide recurring spend by department, with duplicate tools, shadow IT, renewals and risks."),
|
|
220
|
+
actions: /* @__PURE__ */ M("div", {
|
|
221
|
+
className: "flex items-center gap-2",
|
|
222
|
+
children: [$.length > 1 && /* @__PURE__ */ M("label", {
|
|
223
|
+
className: "flex items-center gap-2 text-xs text-text-secondary",
|
|
224
|
+
children: [s("enterprise.currency", "Currency"), /* @__PURE__ */ j("select", {
|
|
225
|
+
"aria-label": s("enterprise.currency", "Currency"),
|
|
226
|
+
value: J.currency,
|
|
227
|
+
onChange: (e) => z(e.target.value),
|
|
228
|
+
className: "rounded-lg border border-border-subtle bg-bg-surface px-2 py-1.5 text-xs text-text-primary focus:outline-none focus:ring-2 focus:ring-[var(--color-focus-ring)]",
|
|
229
|
+
children: $.map((e) => /* @__PURE__ */ j("option", {
|
|
230
|
+
value: e,
|
|
231
|
+
children: e
|
|
232
|
+
}, e))
|
|
233
|
+
})]
|
|
234
|
+
}), /* @__PURE__ */ M("button", {
|
|
235
|
+
type: "button",
|
|
236
|
+
onClick: () => K(!0),
|
|
237
|
+
className: "inline-flex items-center gap-2 rounded-lg border border-border-subtle px-3 py-2 text-sm font-medium text-text-primary hover:bg-bg-sunken",
|
|
238
|
+
children: [
|
|
239
|
+
/* @__PURE__ */ j(O, {
|
|
240
|
+
className: "h-4 w-4",
|
|
241
|
+
"aria-hidden": !0
|
|
242
|
+
}),
|
|
243
|
+
" ",
|
|
244
|
+
s("enterprise.manageBudgets", "Budgets")
|
|
245
|
+
]
|
|
246
|
+
})]
|
|
247
|
+
}),
|
|
248
|
+
children: [
|
|
249
|
+
/* @__PURE__ */ j(P, {
|
|
250
|
+
className: "mb-6",
|
|
251
|
+
children: s("enterprise.purpose", "For finance, IT and procurement. Spend is the monthly run-rate of every subscription billing right now, computed live from your portfolio in one currency at a time. Allocate subscriptions to a department on the subscription itself.")
|
|
252
|
+
}),
|
|
253
|
+
J.truncated && /* @__PURE__ */ j("p", {
|
|
254
|
+
className: "mb-4 rounded-lg bg-status-warning-bg-subtle px-4 py-2 text-sm text-status-warning-text",
|
|
255
|
+
children: s("enterprise.truncated", "This workspace has more subscriptions than one dashboard aggregates; figures cover the most recent ones.")
|
|
256
|
+
}),
|
|
257
|
+
te ? /* @__PURE__ */ j(o, {
|
|
258
|
+
icon: /* @__PURE__ */ j(x, { className: "h-8 w-8" }),
|
|
259
|
+
title: s("enterprise.emptyTitle", "No company spend to analyse yet"),
|
|
260
|
+
description: s("enterprise.emptyDescription", "Add your SaaS subscriptions and allocate them to departments to see spend, duplicates, shadow IT and renewals here."),
|
|
261
|
+
action: /* @__PURE__ */ j("button", {
|
|
262
|
+
type: "button",
|
|
263
|
+
onClick: () => g("/relationships/new"),
|
|
264
|
+
className: "rounded-lg bg-action-primary-bg px-4 py-2 text-sm font-medium text-action-primary-text hover:opacity-90",
|
|
265
|
+
children: s("enterprise.emptyAction", "Add a subscription")
|
|
266
|
+
})
|
|
267
|
+
}) : /* @__PURE__ */ M("div", {
|
|
268
|
+
className: "space-y-6",
|
|
269
|
+
children: [
|
|
270
|
+
/* @__PURE__ */ M("div", {
|
|
271
|
+
className: "grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4",
|
|
272
|
+
"data-testid": "enterprise-kpis",
|
|
273
|
+
children: [
|
|
274
|
+
/* @__PURE__ */ j(r, {
|
|
275
|
+
featured: !0,
|
|
276
|
+
icon: /* @__PURE__ */ j(A, { className: "h-5 w-5" }),
|
|
277
|
+
value: Y(J.annualSpend),
|
|
278
|
+
label: s("enterprise.kpis.annualSpend", "Total SaaS spend / year ({{monthly}} / month)", { monthly: Y(J.monthlySpend) }),
|
|
279
|
+
trend: Q == null ? void 0 : Q > 0 ? "up" : Q < 0 ? "down" : "neutral",
|
|
280
|
+
trendValue: Q == null ? void 0 : `${Q > 0 ? "+" : ""}${Q}%`
|
|
281
|
+
}),
|
|
282
|
+
/* @__PURE__ */ j(r, {
|
|
283
|
+
icon: /* @__PURE__ */ j(T, { className: "h-5 w-5" }),
|
|
284
|
+
value: J.activeRelationshipCount,
|
|
285
|
+
label: s("enterprise.kpis.activeLicenses", "Active licences")
|
|
286
|
+
}),
|
|
287
|
+
/* @__PURE__ */ j(r, {
|
|
288
|
+
icon: /* @__PURE__ */ j(k, { className: "h-5 w-5" }),
|
|
289
|
+
value: J.seatUtilizationPct == null ? "—" : `${J.seatUtilizationPct}%`,
|
|
290
|
+
label: J.seatUtilizationPct == null ? s("enterprise.kpis.seatsUntracked", "Seats utilised (no seat data yet)") : s("enterprise.kpis.seats", "Seats utilised ({{used}} of {{assigned}})", {
|
|
291
|
+
used: J.seatsUsed,
|
|
292
|
+
assigned: J.seatsAssigned
|
|
293
|
+
})
|
|
294
|
+
}),
|
|
295
|
+
/* @__PURE__ */ j(r, {
|
|
296
|
+
icon: /* @__PURE__ */ j(S, { className: "h-5 w-5" }),
|
|
297
|
+
value: Z ? Z.relationshipCount : 0,
|
|
298
|
+
label: s("enterprise.kpis.renewals", "Renewals this quarter ({{amount}})", { amount: Y(Z?.amount ?? 0) })
|
|
299
|
+
})
|
|
300
|
+
]
|
|
301
|
+
}),
|
|
302
|
+
/* @__PURE__ */ M("div", {
|
|
303
|
+
className: "grid grid-cols-1 gap-4 lg:grid-cols-3",
|
|
304
|
+
children: [
|
|
305
|
+
/* @__PURE__ */ M(W, {
|
|
306
|
+
title: s("enterprise.trend.title", "Monthly run-rate, last 150 days"),
|
|
307
|
+
icon: /* @__PURE__ */ j(A, { className: "h-4 w-4" }),
|
|
308
|
+
children: [/* @__PURE__ */ j(m, {
|
|
309
|
+
points: J.trend.map((e) => e.monthlySpend),
|
|
310
|
+
positive: (Q ?? 0) <= 0,
|
|
311
|
+
className: "w-full"
|
|
312
|
+
}), /* @__PURE__ */ j("p", {
|
|
313
|
+
className: "mt-2 text-xs text-text-secondary",
|
|
314
|
+
children: s("enterprise.trend.vs", "{{now}} now vs {{before}} 30 days ago", {
|
|
315
|
+
now: Y(J.monthlySpend),
|
|
316
|
+
before: Y(J.previousMonthlySpend)
|
|
317
|
+
})
|
|
318
|
+
})]
|
|
319
|
+
}),
|
|
320
|
+
/* @__PURE__ */ j(W, {
|
|
321
|
+
title: s("enterprise.budget.title", "Company budget"),
|
|
322
|
+
icon: /* @__PURE__ */ j(O, { className: "h-4 w-4" }),
|
|
323
|
+
testId: "enterprise-budget",
|
|
324
|
+
children: J.budget ? /* @__PURE__ */ j(U, {
|
|
325
|
+
status: J.budget,
|
|
326
|
+
fmt: Y,
|
|
327
|
+
tr: s
|
|
328
|
+
}) : /* @__PURE__ */ M("div", {
|
|
329
|
+
className: "space-y-3",
|
|
330
|
+
children: [/* @__PURE__ */ j("p", {
|
|
331
|
+
className: "text-sm text-text-secondary",
|
|
332
|
+
children: s("enterprise.budget.none", "No company-wide budget set for {{currency}}.", { currency: J.currency })
|
|
333
|
+
}), /* @__PURE__ */ j("button", {
|
|
334
|
+
type: "button",
|
|
335
|
+
onClick: () => K(!0),
|
|
336
|
+
className: "text-sm font-medium text-text-primary underline",
|
|
337
|
+
children: s("enterprise.budget.set", "Set a budget")
|
|
338
|
+
})]
|
|
339
|
+
})
|
|
340
|
+
}),
|
|
341
|
+
/* @__PURE__ */ M(W, {
|
|
342
|
+
title: s("enterprise.savings.title", "Consolidation opportunity"),
|
|
343
|
+
icon: /* @__PURE__ */ j(E, { className: "h-4 w-4" }),
|
|
344
|
+
children: [/* @__PURE__ */ M("p", {
|
|
345
|
+
className: "text-2xl font-bold tabular-nums text-text-primary",
|
|
346
|
+
children: [Y(J.duplicatePotentialMonthlySavings), /* @__PURE__ */ j("span", {
|
|
347
|
+
className: "text-sm font-normal text-text-secondary",
|
|
348
|
+
children: s("enterprise.perMonth", "/mo")
|
|
349
|
+
})]
|
|
350
|
+
}), /* @__PURE__ */ j("p", {
|
|
351
|
+
className: "mt-1 text-xs text-text-secondary",
|
|
352
|
+
children: s("enterprise.savings.detail", "Upper bound if each duplicated tool is consolidated into one subscription; shadow IT costs {{shadow}} / month.", { shadow: Y(J.shadowItMonthlySpend) })
|
|
353
|
+
})]
|
|
354
|
+
})
|
|
355
|
+
]
|
|
356
|
+
}),
|
|
357
|
+
/* @__PURE__ */ j(d, {
|
|
358
|
+
title: s("enterprise.departments.title", "Spend by department"),
|
|
359
|
+
description: s("enterprise.departments.description", "Monthly run-rate per department, with each department budget."),
|
|
360
|
+
children: /* @__PURE__ */ j(N, {
|
|
361
|
+
treatment: "plain",
|
|
362
|
+
className: "p-6",
|
|
363
|
+
"data-testid": "enterprise-departments",
|
|
364
|
+
children: J.departments.length ? /* @__PURE__ */ M("div", {
|
|
365
|
+
className: "grid grid-cols-1 gap-6 lg:grid-cols-2",
|
|
366
|
+
children: [/* @__PURE__ */ j(f, {
|
|
367
|
+
data: J.departments.map((e) => ({
|
|
368
|
+
label: H(s, e.department),
|
|
369
|
+
value: e.monthlySpend
|
|
370
|
+
})),
|
|
371
|
+
valueFormat: Y
|
|
372
|
+
}), /* @__PURE__ */ j("ul", {
|
|
373
|
+
className: "divide-y divide-border-subtle",
|
|
374
|
+
children: J.departments.map((e) => /* @__PURE__ */ M("li", {
|
|
375
|
+
className: "py-3 first:pt-0",
|
|
376
|
+
children: [/* @__PURE__ */ M("div", {
|
|
377
|
+
className: "flex items-center justify-between gap-3 text-sm",
|
|
378
|
+
children: [/* @__PURE__ */ j("span", {
|
|
379
|
+
className: "truncate font-medium text-text-primary",
|
|
380
|
+
children: H(s, e.department)
|
|
381
|
+
}), /* @__PURE__ */ j("span", {
|
|
382
|
+
className: "shrink-0 tabular-nums text-text-secondary",
|
|
383
|
+
children: s("enterprise.departments.row", "{{count}} subs · {{share}}%", {
|
|
384
|
+
count: e.relationshipCount,
|
|
385
|
+
share: e.sharePct
|
|
386
|
+
})
|
|
387
|
+
})]
|
|
388
|
+
}), e.budget && /* @__PURE__ */ j("div", {
|
|
389
|
+
className: "mt-2",
|
|
390
|
+
children: /* @__PURE__ */ j(U, {
|
|
391
|
+
status: e.budget,
|
|
392
|
+
fmt: Y,
|
|
393
|
+
tr: s
|
|
394
|
+
})
|
|
395
|
+
})]
|
|
396
|
+
}, e.department ?? "__unassigned"))
|
|
397
|
+
})]
|
|
398
|
+
}) : /* @__PURE__ */ j("p", {
|
|
399
|
+
className: "text-sm text-text-secondary",
|
|
400
|
+
children: s("enterprise.departments.none", "No spend is billing right now.")
|
|
401
|
+
})
|
|
402
|
+
})
|
|
403
|
+
}),
|
|
404
|
+
/* @__PURE__ */ M("div", {
|
|
405
|
+
className: "grid grid-cols-1 gap-4 lg:grid-cols-2",
|
|
406
|
+
children: [/* @__PURE__ */ j(W, {
|
|
407
|
+
title: s("enterprise.duplicates.title", "Duplicate tools ({{count}})", { count: J.duplicateTools.length }),
|
|
408
|
+
icon: /* @__PURE__ */ j(C, { className: "h-4 w-4" }),
|
|
409
|
+
testId: "enterprise-duplicates",
|
|
410
|
+
children: J.duplicateTools.length ? /* @__PURE__ */ j("ul", {
|
|
411
|
+
className: "space-y-3",
|
|
412
|
+
children: J.duplicateTools.map((e) => /* @__PURE__ */ M("li", {
|
|
413
|
+
className: "rounded-lg border border-status-warning-text/30 bg-status-warning-bg-subtle p-3",
|
|
414
|
+
children: [
|
|
415
|
+
/* @__PURE__ */ M("div", {
|
|
416
|
+
className: "flex items-start justify-between gap-3",
|
|
417
|
+
children: [/* @__PURE__ */ M("div", {
|
|
418
|
+
className: "min-w-0",
|
|
419
|
+
children: [e.providerId ? /* @__PURE__ */ j("button", {
|
|
420
|
+
type: "button",
|
|
421
|
+
className: V,
|
|
422
|
+
onClick: () => g(`/providers/${e.providerId}`),
|
|
423
|
+
children: e.name
|
|
424
|
+
}) : /* @__PURE__ */ j("p", {
|
|
425
|
+
className: "truncate text-sm font-medium text-text-primary",
|
|
426
|
+
children: e.name
|
|
427
|
+
}), /* @__PURE__ */ j("p", {
|
|
428
|
+
className: "text-xs text-text-secondary",
|
|
429
|
+
children: s("enterprise.duplicates.summary", "{{count}} subscriptions · {{teams}}", {
|
|
430
|
+
count: e.relationshipCount,
|
|
431
|
+
teams: e.departments.length ? e.departments.join(", ") : s("enterprise.departments.unassigned", "Unassigned")
|
|
432
|
+
})
|
|
433
|
+
})]
|
|
434
|
+
}), /* @__PURE__ */ j("span", {
|
|
435
|
+
className: "shrink-0 text-right text-xs text-status-warning-text",
|
|
436
|
+
children: s("enterprise.duplicates.save", "Save up to {{amount}}/mo", { amount: Y(e.potentialMonthlySavings) })
|
|
437
|
+
})]
|
|
438
|
+
}),
|
|
439
|
+
/* @__PURE__ */ j("ul", {
|
|
440
|
+
className: "mt-2 flex flex-wrap gap-1.5",
|
|
441
|
+
children: e.relationships.map((e) => /* @__PURE__ */ j("li", { children: /* @__PURE__ */ M("button", {
|
|
442
|
+
type: "button",
|
|
443
|
+
onClick: () => X(e.relationshipId),
|
|
444
|
+
className: t("rounded-md px-2 py-1 text-xs hover:bg-bg-sunken", e.keep ? "border border-status-success-text/40 bg-status-success-bg-subtle text-status-success-text" : "bg-bg-surface text-text-primary"),
|
|
445
|
+
children: [
|
|
446
|
+
e.keep ? `${s("enterprise.duplicates.keep", "Keep")}: ` : "",
|
|
447
|
+
e.label,
|
|
448
|
+
" · ",
|
|
449
|
+
Y(e.monthlySpend)
|
|
450
|
+
]
|
|
451
|
+
}) }, e.relationshipId))
|
|
452
|
+
}),
|
|
453
|
+
/* @__PURE__ */ M("p", {
|
|
454
|
+
className: "mt-2 text-xs text-text-secondary",
|
|
455
|
+
children: [
|
|
456
|
+
s(I[e.type].key, I[e.type].fallback),
|
|
457
|
+
" ·",
|
|
458
|
+
" ",
|
|
459
|
+
e.rationale
|
|
460
|
+
]
|
|
461
|
+
})
|
|
462
|
+
]
|
|
463
|
+
}, e.key))
|
|
464
|
+
}) : /* @__PURE__ */ j("p", {
|
|
465
|
+
className: "text-sm text-text-secondary",
|
|
466
|
+
children: s("enterprise.duplicates.none", "No duplicate or overlapping tools found.")
|
|
467
|
+
})
|
|
468
|
+
}), /* @__PURE__ */ j(W, {
|
|
469
|
+
title: s("enterprise.shadow.title", "Shadow IT ({{count}})", { count: J.shadowItCount }),
|
|
470
|
+
icon: /* @__PURE__ */ j(w, { className: "h-4 w-4" }),
|
|
471
|
+
testId: "enterprise-shadow-it",
|
|
472
|
+
children: J.shadowIt.length ? /* @__PURE__ */ j("ul", {
|
|
473
|
+
className: "divide-y divide-border-subtle",
|
|
474
|
+
children: J.shadowIt.map((e) => /* @__PURE__ */ M("li", {
|
|
475
|
+
className: "flex items-center justify-between gap-3 py-2.5",
|
|
476
|
+
children: [/* @__PURE__ */ M("div", {
|
|
477
|
+
className: "min-w-0",
|
|
478
|
+
children: [/* @__PURE__ */ j("button", {
|
|
479
|
+
type: "button",
|
|
480
|
+
className: V,
|
|
481
|
+
onClick: () => X(e.relationshipId),
|
|
482
|
+
children: e.label
|
|
483
|
+
}), /* @__PURE__ */ M("p", {
|
|
484
|
+
className: "text-xs text-text-secondary",
|
|
485
|
+
children: [
|
|
486
|
+
s(F[e.reason].key, F[e.reason].fallback),
|
|
487
|
+
" · ",
|
|
488
|
+
H(s, e.department),
|
|
489
|
+
" · ",
|
|
490
|
+
s("enterprise.shadow.found", "found {{date}}", { date: a(e.detectedAt) })
|
|
491
|
+
]
|
|
492
|
+
})]
|
|
493
|
+
}), /* @__PURE__ */ j("span", {
|
|
494
|
+
className: "shrink-0 text-sm font-semibold tabular-nums text-text-primary",
|
|
495
|
+
children: Y(e.monthlySpend)
|
|
496
|
+
})]
|
|
497
|
+
}, e.relationshipId))
|
|
498
|
+
}) : /* @__PURE__ */ j("p", {
|
|
499
|
+
className: "text-sm text-text-secondary",
|
|
500
|
+
children: s("enterprise.shadow.none", "Every discovered tool is linked to an approved provider.")
|
|
501
|
+
})
|
|
502
|
+
})]
|
|
503
|
+
}),
|
|
504
|
+
/* @__PURE__ */ j(d, {
|
|
505
|
+
title: s("enterprise.renewals.title", "Renewal pipeline"),
|
|
506
|
+
description: s("enterprise.renewals.description", "Renewal charges due by quarter, for planning renegotiations."),
|
|
507
|
+
children: /* @__PURE__ */ j("div", {
|
|
508
|
+
className: "grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4",
|
|
509
|
+
"data-testid": "enterprise-renewals",
|
|
510
|
+
children: J.renewalPipeline.map((e) => /* @__PURE__ */ M(N, {
|
|
511
|
+
treatment: "plain",
|
|
512
|
+
className: "p-5",
|
|
513
|
+
children: [
|
|
514
|
+
/* @__PURE__ */ j("p", {
|
|
515
|
+
className: "text-xs font-medium uppercase tracking-wide text-text-secondary",
|
|
516
|
+
children: e.label
|
|
517
|
+
}),
|
|
518
|
+
/* @__PURE__ */ j("p", {
|
|
519
|
+
className: "mt-1 text-xl font-bold tabular-nums text-text-primary",
|
|
520
|
+
children: Y(e.amount)
|
|
521
|
+
}),
|
|
522
|
+
/* @__PURE__ */ j("p", {
|
|
523
|
+
className: "text-xs text-text-secondary",
|
|
524
|
+
children: s("enterprise.renewals.counts", "{{renewals}} renewals · {{subs}} subscriptions", {
|
|
525
|
+
renewals: e.renewalCount,
|
|
526
|
+
subs: e.relationshipCount
|
|
527
|
+
})
|
|
528
|
+
}),
|
|
529
|
+
e.topRenewals.length > 0 && /* @__PURE__ */ j("ul", {
|
|
530
|
+
className: "mt-3 space-y-1.5",
|
|
531
|
+
children: e.topRenewals.map((e) => /* @__PURE__ */ M("li", {
|
|
532
|
+
className: "flex items-center justify-between gap-2",
|
|
533
|
+
children: [/* @__PURE__ */ j("button", {
|
|
534
|
+
type: "button",
|
|
535
|
+
className: t(V, "text-xs"),
|
|
536
|
+
onClick: () => X(e.relationshipId),
|
|
537
|
+
children: e.label
|
|
538
|
+
}), /* @__PURE__ */ M("span", {
|
|
539
|
+
className: "shrink-0 text-xs tabular-nums text-text-secondary",
|
|
540
|
+
children: [
|
|
541
|
+
a(e.renewsAt),
|
|
542
|
+
" · ",
|
|
543
|
+
Y(e.amount)
|
|
544
|
+
]
|
|
545
|
+
})]
|
|
546
|
+
}, e.relationshipId))
|
|
547
|
+
})
|
|
548
|
+
]
|
|
549
|
+
}, e.label))
|
|
550
|
+
})
|
|
551
|
+
}),
|
|
552
|
+
/* @__PURE__ */ j(d, {
|
|
553
|
+
title: s("enterprise.risks.title", "Compliance and risk flags ({{count}})", { count: J.riskFlagCount }),
|
|
554
|
+
children: /* @__PURE__ */ j(N, {
|
|
555
|
+
treatment: "plain",
|
|
556
|
+
className: "p-2",
|
|
557
|
+
"data-testid": "enterprise-risks",
|
|
558
|
+
children: J.riskFlags.length ? /* @__PURE__ */ j("ul", {
|
|
559
|
+
className: "divide-y divide-border-subtle",
|
|
560
|
+
children: J.riskFlags.map((e) => /* @__PURE__ */ M("li", {
|
|
561
|
+
className: "flex flex-col gap-2 p-4 sm:flex-row sm:items-center sm:justify-between",
|
|
562
|
+
children: [/* @__PURE__ */ M("div", {
|
|
563
|
+
className: "flex min-w-0 items-start gap-3",
|
|
564
|
+
children: [/* @__PURE__ */ j("span", {
|
|
565
|
+
className: t("shrink-0 rounded-md px-2 py-0.5 text-xs font-medium", R[e.severity]),
|
|
566
|
+
children: s(L[e.kind].key, L[e.kind].fallback)
|
|
567
|
+
}), /* @__PURE__ */ M("div", {
|
|
568
|
+
className: "min-w-0",
|
|
569
|
+
children: [/* @__PURE__ */ j("button", {
|
|
570
|
+
type: "button",
|
|
571
|
+
className: V,
|
|
572
|
+
onClick: () => X(e.relationshipId),
|
|
573
|
+
children: e.label
|
|
574
|
+
}), /* @__PURE__ */ j("p", {
|
|
575
|
+
className: "text-xs text-text-secondary",
|
|
576
|
+
children: e.detail
|
|
577
|
+
})]
|
|
578
|
+
})]
|
|
579
|
+
}), /* @__PURE__ */ M("span", {
|
|
580
|
+
className: "shrink-0 text-xs text-text-secondary",
|
|
581
|
+
children: [
|
|
582
|
+
H(s, e.department),
|
|
583
|
+
" · ",
|
|
584
|
+
Y(e.monthlySpend),
|
|
585
|
+
s("enterprise.perMonth", "/mo")
|
|
586
|
+
]
|
|
587
|
+
})]
|
|
588
|
+
}, `${e.kind}-${e.relationshipId}`))
|
|
589
|
+
}) : /* @__PURE__ */ M("p", {
|
|
590
|
+
className: "flex items-center gap-2 p-4 text-sm text-text-secondary",
|
|
591
|
+
children: [/* @__PURE__ */ j(D, {
|
|
592
|
+
className: "h-4 w-4",
|
|
593
|
+
"aria-hidden": !0
|
|
594
|
+
}), s("enterprise.risks.none", "No compliance or risk issues found.")]
|
|
595
|
+
})
|
|
596
|
+
})
|
|
597
|
+
}),
|
|
598
|
+
/* @__PURE__ */ j(d, {
|
|
599
|
+
title: s("enterprise.licenses.title", "Licences"),
|
|
600
|
+
children: /* @__PURE__ */ M(N, {
|
|
601
|
+
treatment: "plain",
|
|
602
|
+
className: "overflow-x-auto",
|
|
603
|
+
"data-testid": "enterprise-licenses",
|
|
604
|
+
children: [/* @__PURE__ */ M("table", {
|
|
605
|
+
className: "w-full min-w-[640px] text-sm",
|
|
606
|
+
children: [/* @__PURE__ */ j("thead", { children: /* @__PURE__ */ M("tr", {
|
|
607
|
+
className: "border-b border-border-subtle text-left text-xs text-text-secondary",
|
|
608
|
+
children: [
|
|
609
|
+
/* @__PURE__ */ j("th", {
|
|
610
|
+
className: "px-4 py-3 font-medium",
|
|
611
|
+
children: s("enterprise.licenses.app", "App")
|
|
612
|
+
}),
|
|
613
|
+
/* @__PURE__ */ j("th", {
|
|
614
|
+
className: "px-4 py-3 font-medium",
|
|
615
|
+
children: s("enterprise.licenses.department", "Department")
|
|
616
|
+
}),
|
|
617
|
+
/* @__PURE__ */ j("th", {
|
|
618
|
+
className: "px-4 py-3 font-medium",
|
|
619
|
+
children: s("enterprise.licenses.seats", "Seats used / assigned")
|
|
620
|
+
}),
|
|
621
|
+
/* @__PURE__ */ j("th", {
|
|
622
|
+
className: "px-4 py-3 font-medium",
|
|
623
|
+
children: s("enterprise.licenses.utilization", "Utilisation")
|
|
624
|
+
}),
|
|
625
|
+
/* @__PURE__ */ j("th", {
|
|
626
|
+
className: "px-4 py-3 text-right font-medium",
|
|
627
|
+
children: s("enterprise.licenses.annualCost", "Annual cost")
|
|
628
|
+
}),
|
|
629
|
+
/* @__PURE__ */ j("th", {
|
|
630
|
+
className: "px-4 py-3 font-medium",
|
|
631
|
+
children: s("enterprise.licenses.renews", "Renews")
|
|
632
|
+
})
|
|
633
|
+
]
|
|
634
|
+
}) }), /* @__PURE__ */ j("tbody", {
|
|
635
|
+
className: "divide-y divide-border-subtle",
|
|
636
|
+
children: J.licenses.map((e) => /* @__PURE__ */ M("tr", { children: [
|
|
637
|
+
/* @__PURE__ */ M("td", {
|
|
638
|
+
className: "max-w-[16rem] px-4 py-3",
|
|
639
|
+
children: [/* @__PURE__ */ j("button", {
|
|
640
|
+
type: "button",
|
|
641
|
+
className: V,
|
|
642
|
+
onClick: () => X(e.relationshipId),
|
|
643
|
+
children: e.label
|
|
644
|
+
}), e.providerName && /* @__PURE__ */ j("p", {
|
|
645
|
+
className: "truncate text-xs text-text-secondary",
|
|
646
|
+
children: e.providerName
|
|
647
|
+
})]
|
|
648
|
+
}),
|
|
649
|
+
/* @__PURE__ */ j("td", {
|
|
650
|
+
className: "px-4 py-3 text-text-secondary",
|
|
651
|
+
children: H(s, e.department)
|
|
652
|
+
}),
|
|
653
|
+
/* @__PURE__ */ j("td", {
|
|
654
|
+
className: "px-4 py-3 tabular-nums text-text-primary",
|
|
655
|
+
children: e.seatsAssigned == null ? "—" : `${e.seatsUsed ?? 0} / ${e.seatsAssigned}`
|
|
656
|
+
}),
|
|
657
|
+
/* @__PURE__ */ j("td", {
|
|
658
|
+
className: "w-40 px-4 py-3",
|
|
659
|
+
children: e.utilizationPct == null ? /* @__PURE__ */ j("span", {
|
|
660
|
+
className: "text-text-secondary",
|
|
661
|
+
children: "—"
|
|
662
|
+
}) : /* @__PURE__ */ j(p, {
|
|
663
|
+
value: e.utilizationPct,
|
|
664
|
+
tone: e.utilizationPct >= 70 ? "positive" : "warning"
|
|
665
|
+
})
|
|
666
|
+
}),
|
|
667
|
+
/* @__PURE__ */ j("td", {
|
|
668
|
+
className: "px-4 py-3 text-right font-semibold tabular-nums text-text-primary",
|
|
669
|
+
children: Y(e.annualCost)
|
|
670
|
+
}),
|
|
671
|
+
/* @__PURE__ */ j("td", {
|
|
672
|
+
className: "px-4 py-3 text-text-secondary",
|
|
673
|
+
children: e.renewsAt ? a(e.renewsAt) : "—"
|
|
674
|
+
})
|
|
675
|
+
] }, e.relationshipId))
|
|
676
|
+
})]
|
|
677
|
+
}), J.licenses.length === 0 && /* @__PURE__ */ j("p", {
|
|
678
|
+
className: "p-4 text-sm text-text-secondary",
|
|
679
|
+
children: s("enterprise.licenses.none", "No subscription is billing right now.")
|
|
680
|
+
})]
|
|
681
|
+
})
|
|
682
|
+
}),
|
|
683
|
+
J.otherCurrencies.length > 0 && /* @__PURE__ */ M("p", {
|
|
684
|
+
className: "flex items-start gap-2 text-xs text-text-secondary",
|
|
685
|
+
children: [/* @__PURE__ */ j(b, {
|
|
686
|
+
className: "mt-0.5 h-3.5 w-3.5 shrink-0",
|
|
687
|
+
"aria-hidden": !0
|
|
688
|
+
}), s("enterprise.otherCurrencies", "Not included above (amounts are never converted): {{list}}. Switch currency to see them.", { list: J.otherCurrencies.map((e) => `${i(e.monthlySpend, e.currency)}/mo`).join(", ") })]
|
|
689
|
+
})
|
|
690
|
+
]
|
|
691
|
+
}),
|
|
692
|
+
B && /* @__PURE__ */ j(G, {
|
|
693
|
+
currency: J.currency,
|
|
694
|
+
departments: ee,
|
|
695
|
+
onClose: () => K(!1)
|
|
696
|
+
})
|
|
697
|
+
]
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
//#endregion
|
|
701
|
+
export { K as EnterpriseSpendPage };
|
|
702
|
+
|
|
703
|
+
//# sourceMappingURL=EnterpriseSpendPage.js.map
|