@withpotter/pages-admin 0.13.0-next.119 → 0.13.0-next.122

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 (41) hide show
  1. package/dist/admin/_ui/admin.css +7 -0
  2. package/dist/admin/_ui/stat-card.js +11 -2
  3. package/dist/admin/_ui/stat-card.js.map +1 -1
  4. package/dist/admin/admins/access-dialog.js +2 -7
  5. package/dist/admin/admins/access-dialog.js.map +1 -1
  6. package/dist/admin/admins/invite-dialog.js +0 -6
  7. package/dist/admin/admins/invite-dialog.js.map +1 -1
  8. package/dist/admin/hooks.js +0 -7
  9. package/dist/admin/hooks.js.map +1 -1
  10. package/dist/admin/revenue/earnings/earnings-colors.js +23 -0
  11. package/dist/admin/revenue/earnings/earnings-colors.js.map +1 -0
  12. package/dist/admin/revenue/earnings/earnings-legend.js +38 -0
  13. package/dist/admin/revenue/earnings/earnings-legend.js.map +1 -0
  14. package/dist/admin/revenue/earnings/earnings-summary.js +194 -0
  15. package/dist/admin/revenue/earnings/earnings-summary.js.map +1 -0
  16. package/dist/admin/revenue/earnings/earnings-table.js +141 -0
  17. package/dist/admin/revenue/earnings/earnings-table.js.map +1 -0
  18. package/dist/admin/revenue/earnings/earnings.module.css +118 -0
  19. package/dist/admin/revenue/earnings/use-earnings.js +27 -0
  20. package/dist/admin/revenue/earnings/use-earnings.js.map +1 -0
  21. package/dist/admin/revenue/health-panel.js +5 -4
  22. package/dist/admin/revenue/health-panel.js.map +1 -1
  23. package/dist/admin/revenue/ledger-model.js +22 -9
  24. package/dist/admin/revenue/ledger-model.js.map +1 -1
  25. package/dist/admin/revenue/method-cards.js +13 -5
  26. package/dist/admin/revenue/method-cards.js.map +1 -1
  27. package/dist/admin/revenue/revenue-kpis.js +1 -1
  28. package/dist/admin/revenue/revenue-kpis.js.map +1 -1
  29. package/dist/admin/revenue/revenue-overview.js +18 -1
  30. package/dist/admin/revenue/revenue-overview.js.map +1 -1
  31. package/dist/admin/revenue/revenue-workspace.js +2 -0
  32. package/dist/admin/revenue/revenue-workspace.js.map +1 -1
  33. package/dist/admin/revenue/subscriptions-view.js +27 -2
  34. package/dist/admin/revenue/subscriptions-view.js.map +1 -1
  35. package/dist/admin/roles/page.js +4 -14
  36. package/dist/admin/roles/page.js.map +1 -1
  37. package/dist/admin/roles/role-dialog.js +1 -2
  38. package/dist/admin/roles/role-dialog.js.map +1 -1
  39. package/package.json +4 -4
  40. package/dist/admin/roles/role-requests.js +0 -80
  41. package/dist/admin/roles/role-requests.js.map +0 -1
@@ -16,6 +16,8 @@ import {
16
16
  import { Reveal } from "../_fx";
17
17
  import { Tinted } from "../audit/tinted";
18
18
  import { ChannelPanel } from "./channel-panel";
19
+ import { EarningsSummary } from "./earnings/earnings-summary";
20
+ import { EarningsTable } from "./earnings/earnings-table";
19
21
  import { MerchantHeadline } from "./merchant-headline";
20
22
  import { MethodCards } from "./method-cards";
21
23
  import { HealthPanel } from "./health-panel";
@@ -29,6 +31,8 @@ import { TopTenantsPanel } from "./top-tenants-panel";
29
31
  import styles from "./revenue.module.css";
30
32
  import ledgerStyles from "./ledger.module.css";
31
33
  const VIEWS = [
34
+ { id: "earnings", label: "Earnings" },
35
+ { id: "transactions", label: "Transactions" },
32
36
  { id: "olive", label: "Money Olive earns" },
33
37
  { id: "customers", label: "How customers pay" },
34
38
  { id: "merchants", label: "Money merchants earn" },
@@ -36,6 +40,8 @@ const VIEWS = [
36
40
  ];
37
41
  const VIEW_KEYS = VIEWS.map((v) => v.id);
38
42
  function RevenueOverview({
43
+ startDate,
44
+ endDate,
39
45
  data,
40
46
  health,
41
47
  money,
@@ -85,7 +91,7 @@ function RevenueOverview({
85
91
  decimals: 2
86
92
  }
87
93
  ),
88
- hint: "What Olive keeps from payouts and plan payments, after costs."
94
+ hint: "What Olive keeps from payouts and plan payments, after costs. All time, not the period."
89
95
  }
90
96
  ) }) }),
91
97
  recurringTracked && /* @__PURE__ */ jsxs("div", { className: "adm-grid", children: [
@@ -164,6 +170,17 @@ function RevenueOverview({
164
170
  const planSubscriptions = recurringTracked ? /* @__PURE__ */ jsx("div", { className: "adm-grid", children: /* @__PURE__ */ jsx("div", { className: "adm-span-12", children: /* @__PURE__ */ jsx(SubscriptionsPanel, { recurring: data.recurring, span }) }) }) : /* @__PURE__ */ jsx("p", { className: styles.empty, children: "Plan income is not set up on this box yet." });
165
171
  return /* @__PURE__ */ jsxs(Fragment, { children: [
166
172
  /* @__PURE__ */ jsx(PageTabs, { tabs: VIEWS, active: view, onChange: setView }),
173
+ view === "earnings" && /* @__PURE__ */ jsx(
174
+ EarningsSummary,
175
+ {
176
+ startDate,
177
+ endDate,
178
+ currency,
179
+ recurring: data.recurring,
180
+ span
181
+ }
182
+ ),
183
+ view === "transactions" && /* @__PURE__ */ jsx(EarningsTable, { startDate, endDate }),
167
184
  view === "olive" && olive,
168
185
  view === "customers" && customers,
169
186
  view === "merchants" && merchants,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/admin/revenue/revenue-overview.tsx"],"sourcesContent":["'use client';\n\nimport { useMemo } from 'react';\nimport { TimeSeriesChart } from '@withpotter/components/charts';\nimport type {\n GatewayChannelTotal,\n MerchantPaymentsPage,\n PlatformCockpit,\n PlatformRevenue,\n} from '@withpotter/lib/admin-api';\nimport type { PlanList } from '@withpotter/lib/pricing-plans-types';\nimport {\n MoneyLines,\n PICK_CURRENCY,\n PageTabs,\n Panel,\n StatCard,\n moneyIn,\n rowFor,\n toTrend,\n useTabParam,\n} from '../_ui';\nimport { Reveal } from '../_fx';\nimport { Tinted } from '../audit/tinted';\nimport { ChannelPanel } from './channel-panel';\nimport { MerchantHeadline } from './merchant-headline';\nimport { MethodCards } from './method-cards';\nimport { HealthPanel } from './health-panel';\nimport { PlanMixPanel } from './plan-mix-panel';\nimport { planLines } from './plan-lines';\nimport { PlansPanel } from './plans-panel';\nimport { platformFeeRows } from './platform-fees';\nimport { RevenueKpis } from './revenue-kpis';\nimport { SubscriptionsPanel } from './subscriptions-panel';\nimport { TopTenantsPanel } from './top-tenants-panel';\nimport styles from './revenue.module.css';\nimport ledgerStyles from './ledger.module.css';\n\n/** The Overview's own tabs, in the order the money is read: Olive's, how customers pay, merchants', then plans. */\nconst VIEWS = [\n { id: 'olive', label: 'Money Olive earns' },\n { id: 'customers', label: 'How customers pay' },\n { id: 'merchants', label: 'Money merchants earn' },\n { id: 'plans', label: 'Plan subscriptions' },\n] as const;\n\nconst VIEW_KEYS = VIEWS.map((v) => v.id);\n\n/**\n * Overview tab, one view at a time: what Olive earns (headline figures, fees, revenue health and plan mix), how\n * customers pay, what merchants earn (the GMV trend and the leaderboard), and the plan subscriptions. Merchant GMV is\n * per currency: the chart and the leaderboard follow one currency (`focus`), never a mix.\n */\nexport function RevenueOverview({\n data,\n health,\n money,\n money2,\n currency,\n focus,\n period,\n span,\n recurringCaption,\n channels,\n ledger,\n plans,\n}: {\n data: PlatformRevenue;\n health: PlatformCockpit['health'] | undefined;\n /** Formats recurring revenue, in the currency the API names for it. */\n money: (value: number) => string;\n money2: (value: number) => string;\n /** The currency filter's choice; empty shows a line per currency. */\n currency: string;\n /** The one currency the chart and leaderboard show, or null. */\n focus: string | null;\n period: string;\n span: string;\n recurringCaption: string;\n /** How customers paid, per channel and currency. Absent when the box cannot say. */\n channels?: readonly GatewayChannelTotal[];\n /** The merchant payments view's totals, with the period before; absent on a box without it. */\n ledger?: MerchantPaymentsPage;\n /** Every plan created, from Pricing. Absent while it loads or when it cannot be read. */\n plans?: PlanList;\n}) {\n const [view, setView] = useTabParam(VIEW_KEYS, 'view');\n const gmv = rowFor(data.gmvByCurrency, focus);\n const gmvSeries = useMemo(() => toTrend(gmv?.series, 'gmv'), [gmv]);\n const gmvCaption = focus ? `${period} · ${focus}` : period;\n const recurringTracked = data.recurring.tracked !== false;\n // Hidden until the api sends the field; never an invented zero.\n const platformFees = platformFeeRows(data.platformFeesEarnedByCurrency, currency);\n\n const olive = (\n <>\n <p className={ledgerStyles.sectionNote}>\n What merchants pay Olive: their plan payments, and the fees Olive keeps.\n {recurringTracked ? '' : ' Plan income is not set up on this box yet.'}\n </p>\n <RevenueKpis\n part=\"olive\"\n data={data}\n money={money}\n money2={money2}\n currency={currency}\n focus={focus}\n gmvSpark={[]}\n />\n\n {platformFees && (\n <section aria-label=\"Platform fees\" className=\"adm-grid\">\n <Reveal index={0} className=\"adm-span-3\">\n <StatCard\n label=\"Fees Olive keeps\"\n value={\n <MoneyLines\n rows={platformFees}\n currency=\"\"\n amount={(r) => r.amount}\n decimals={2}\n />\n }\n hint=\"What Olive keeps from payouts and plan payments, after costs.\"\n />\n </Reveal>\n </section>\n )}\n\n {recurringTracked && (\n <div className=\"adm-grid\">\n <div className={plans ? 'adm-span-12' : 'adm-span-7'}>\n <HealthPanel health={health} money={money} caption={recurringCaption} />\n </div>\n {/* The Plans table has five columns, so it gets a full row; the older plan mix keeps its narrow slot. */}\n <div className={plans ? 'adm-span-12' : 'adm-span-5'}>\n {plans ? (\n <PlansPanel\n lines={planLines(plans.plans, plans.currencies, data.recurring.planBreakdown)}\n money={money}\n caption={recurringCaption}\n />\n ) : (\n <PlanMixPanel\n planBreakdown={data.recurring.planBreakdown}\n money={money}\n caption={recurringCaption}\n />\n )}\n </div>\n </div>\n )}\n </>\n );\n\n const customers = (\n <>\n <p className={ledgerStyles.sectionNote}>\n The ways of paying on the checkout, and how much came in through each.\n </p>\n {ledger && focus ? (\n <MethodCards\n now={ledger.byChannel.current}\n before={ledger.byChannel.previous}\n currency={focus}\n />\n ) : channels && channels.length > 0 ? (\n <div className=\"adm-grid\">\n <div className=\"adm-span-12\">\n <ChannelPanel totals={channels} focus={focus} caption={gmvCaption} />\n </div>\n </div>\n ) : (\n <p className={styles.empty}>{focus ? 'Nothing to show for this period.' : PICK_CURRENCY}</p>\n )}\n </>\n );\n\n const merchants = (\n <>\n <p className={ledgerStyles.sectionNote}>\n What customers paid to merchants. This is the merchants' money, not Olive's own income.\n </p>\n <RevenueKpis\n part=\"merchant\"\n data={data}\n money={money}\n money2={money2}\n currency={currency}\n focus={focus}\n gmvSpark={gmvSeries.map((p) => p.value)}\n extra={ledger && focus ? <MerchantHeadline ledger={ledger} focus={focus} /> : null}\n />\n\n <div className=\"adm-grid\">\n <div className=\"adm-span-12\">\n <Panel\n title=\"Merchant sales over time\"\n caption={gmvCaption}\n height=\"calc(var(--adm-panel-h) + 32px)\"\n >\n {focus ? (\n <div className={styles.flatChart}>\n <Tinted tone=\"money\">\n <TimeSeriesChart\n data={gmvSeries}\n compare={null}\n compareLabel={`Previous ${span}`}\n formatValue={moneyIn(focus)}\n emptyMessage={`No paid ${focus} orders in this window yet`}\n height={240}\n />\n </Tinted>\n </div>\n ) : (\n <p className={styles.empty}>{PICK_CURRENCY}</p>\n )}\n </Panel>\n </div>\n </div>\n\n <div className=\"adm-grid\">\n <div className=\"adm-span-12\">\n <TopTenantsPanel\n tenants={gmv?.topTenants ?? []}\n lifetime={gmv?.lifetime ?? 0}\n focus={focus}\n />\n </div>\n </div>\n </>\n );\n\n const planSubscriptions = recurringTracked ? (\n <div className=\"adm-grid\">\n <div className=\"adm-span-12\">\n <SubscriptionsPanel recurring={data.recurring} span={span} />\n </div>\n </div>\n ) : (\n <p className={styles.empty}>Plan income is not set up on this box yet.</p>\n );\n\n return (\n <>\n <PageTabs tabs={VIEWS} active={view} onChange={setView} />\n {view === 'olive' && olive}\n {view === 'customers' && customers}\n {view === 'merchants' && merchants}\n {view === 'plans' && planSubscriptions}\n </>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/admin/revenue/revenue-overview.tsx"],"sourcesContent":["'use client';\n\nimport { useMemo } from 'react';\nimport { TimeSeriesChart } from '@withpotter/components/charts';\nimport type {\n GatewayChannelTotal,\n MerchantPaymentsPage,\n PlatformCockpit,\n PlatformRevenue,\n} from '@withpotter/lib/admin-api';\nimport type { PlanList } from '@withpotter/lib/pricing-plans-types';\nimport {\n MoneyLines,\n PICK_CURRENCY,\n PageTabs,\n Panel,\n StatCard,\n moneyIn,\n rowFor,\n toTrend,\n useTabParam,\n} from '../_ui';\nimport { Reveal } from '../_fx';\nimport { Tinted } from '../audit/tinted';\nimport { ChannelPanel } from './channel-panel';\nimport { EarningsSummary } from './earnings/earnings-summary';\nimport { EarningsTable } from './earnings/earnings-table';\nimport { MerchantHeadline } from './merchant-headline';\nimport { MethodCards } from './method-cards';\nimport { HealthPanel } from './health-panel';\nimport { PlanMixPanel } from './plan-mix-panel';\nimport { planLines } from './plan-lines';\nimport { PlansPanel } from './plans-panel';\nimport { platformFeeRows } from './platform-fees';\nimport { RevenueKpis } from './revenue-kpis';\nimport { SubscriptionsPanel } from './subscriptions-panel';\nimport { TopTenantsPanel } from './top-tenants-panel';\nimport styles from './revenue.module.css';\nimport ledgerStyles from './ledger.module.css';\n\n/**\n * The Overview's own tabs. Earnings and Transactions are first: what Olive makes from everything, and the fees behind\n * it. The rest are as they were: Money Olive earns (plan figures and fees), how customers pay, the merchants' money and\n * the plan subscriptions.\n */\nconst VIEWS = [\n { id: 'earnings', label: 'Earnings' },\n { id: 'transactions', label: 'Transactions' },\n { id: 'olive', label: 'Money Olive earns' },\n { id: 'customers', label: 'How customers pay' },\n { id: 'merchants', label: 'Money merchants earn' },\n { id: 'plans', label: 'Plan subscriptions' },\n] as const;\n\nconst VIEW_KEYS = VIEWS.map((v) => v.id);\n\n/**\n * Overview tab, one view at a time. Earnings is what Olive makes, whatever it comes from, with the fee transactions\n * behind it beside it; the other views are as they were. Merchant GMV is per currency: the chart and the leaderboard\n * follow one currency (`focus`), never a mix.\n */\nexport function RevenueOverview({\n startDate,\n endDate,\n data,\n health,\n money,\n money2,\n currency,\n focus,\n period,\n span,\n recurringCaption,\n channels,\n ledger,\n plans,\n}: {\n /** The window, for the earnings views, which read their own data. */\n startDate?: string;\n endDate?: string;\n data: PlatformRevenue;\n health: PlatformCockpit['health'] | undefined;\n /** Formats recurring revenue, in the currency the API names for it. */\n money: (value: number) => string;\n money2: (value: number) => string;\n /** The currency filter's choice; empty shows a line per currency. */\n currency: string;\n /** The one currency the chart and leaderboard show, or null. */\n focus: string | null;\n period: string;\n span: string;\n recurringCaption: string;\n /** How customers paid, per channel and currency. Absent when the box cannot say. */\n channels?: readonly GatewayChannelTotal[];\n /** The merchant payments view's totals, with the period before; absent on a box without it. */\n ledger?: MerchantPaymentsPage;\n /** Every plan created, from Pricing. Absent while it loads or when it cannot be read. */\n plans?: PlanList;\n}) {\n const [view, setView] = useTabParam(VIEW_KEYS, 'view');\n const gmv = rowFor(data.gmvByCurrency, focus);\n const gmvSeries = useMemo(() => toTrend(gmv?.series, 'gmv'), [gmv]);\n const gmvCaption = focus ? `${period} · ${focus}` : period;\n const recurringTracked = data.recurring.tracked !== false;\n // Hidden until the api sends the field; never an invented zero.\n const platformFees = platformFeeRows(data.platformFeesEarnedByCurrency, currency);\n\n const olive = (\n <>\n <p className={ledgerStyles.sectionNote}>\n What merchants pay Olive: their plan payments, and the fees Olive keeps.\n {recurringTracked ? '' : ' Plan income is not set up on this box yet.'}\n </p>\n <RevenueKpis\n part=\"olive\"\n data={data}\n money={money}\n money2={money2}\n currency={currency}\n focus={focus}\n gmvSpark={[]}\n />\n\n {platformFees && (\n <section aria-label=\"Platform fees\" className=\"adm-grid\">\n <Reveal index={0} className=\"adm-span-3\">\n <StatCard\n label=\"Fees Olive keeps\"\n value={\n <MoneyLines\n rows={platformFees}\n currency=\"\"\n amount={(r) => r.amount}\n decimals={2}\n />\n }\n hint=\"What Olive keeps from payouts and plan payments, after costs. All time, not the period.\"\n />\n </Reveal>\n </section>\n )}\n\n {recurringTracked && (\n <div className=\"adm-grid\">\n <div className={plans ? 'adm-span-12' : 'adm-span-7'}>\n <HealthPanel health={health} money={money} caption={recurringCaption} />\n </div>\n {/* The Plans table has five columns, so it gets a full row; the older plan mix keeps its narrow slot. */}\n <div className={plans ? 'adm-span-12' : 'adm-span-5'}>\n {plans ? (\n <PlansPanel\n lines={planLines(plans.plans, plans.currencies, data.recurring.planBreakdown)}\n money={money}\n caption={recurringCaption}\n />\n ) : (\n <PlanMixPanel\n planBreakdown={data.recurring.planBreakdown}\n money={money}\n caption={recurringCaption}\n />\n )}\n </div>\n </div>\n )}\n </>\n );\n\n const customers = (\n <>\n <p className={ledgerStyles.sectionNote}>\n The ways of paying on the checkout, and how much came in through each.\n </p>\n {ledger && focus ? (\n <MethodCards\n now={ledger.byChannel.current}\n before={ledger.byChannel.previous}\n currency={focus}\n />\n ) : channels && channels.length > 0 ? (\n <div className=\"adm-grid\">\n <div className=\"adm-span-12\">\n <ChannelPanel totals={channels} focus={focus} caption={gmvCaption} />\n </div>\n </div>\n ) : (\n <p className={styles.empty}>{focus ? 'Nothing to show for this period.' : PICK_CURRENCY}</p>\n )}\n </>\n );\n\n const merchants = (\n <>\n <p className={ledgerStyles.sectionNote}>\n What customers paid to merchants. This is the merchants' money, not Olive's own income.\n </p>\n <RevenueKpis\n part=\"merchant\"\n data={data}\n money={money}\n money2={money2}\n currency={currency}\n focus={focus}\n gmvSpark={gmvSeries.map((p) => p.value)}\n extra={ledger && focus ? <MerchantHeadline ledger={ledger} focus={focus} /> : null}\n />\n\n <div className=\"adm-grid\">\n <div className=\"adm-span-12\">\n <Panel\n title=\"Merchant sales over time\"\n caption={gmvCaption}\n height=\"calc(var(--adm-panel-h) + 32px)\"\n >\n {focus ? (\n <div className={styles.flatChart}>\n <Tinted tone=\"money\">\n <TimeSeriesChart\n data={gmvSeries}\n compare={null}\n compareLabel={`Previous ${span}`}\n formatValue={moneyIn(focus)}\n emptyMessage={`No paid ${focus} orders in this window yet`}\n height={240}\n />\n </Tinted>\n </div>\n ) : (\n <p className={styles.empty}>{PICK_CURRENCY}</p>\n )}\n </Panel>\n </div>\n </div>\n\n <div className=\"adm-grid\">\n <div className=\"adm-span-12\">\n <TopTenantsPanel\n tenants={gmv?.topTenants ?? []}\n lifetime={gmv?.lifetime ?? 0}\n focus={focus}\n />\n </div>\n </div>\n </>\n );\n\n const planSubscriptions = recurringTracked ? (\n <div className=\"adm-grid\">\n <div className=\"adm-span-12\">\n <SubscriptionsPanel recurring={data.recurring} span={span} />\n </div>\n </div>\n ) : (\n <p className={styles.empty}>Plan income is not set up on this box yet.</p>\n );\n\n return (\n <>\n <PageTabs tabs={VIEWS} active={view} onChange={setView} />\n {view === 'earnings' && (\n <EarningsSummary\n startDate={startDate}\n endDate={endDate}\n currency={currency}\n recurring={data.recurring}\n span={span}\n />\n )}\n {view === 'transactions' && <EarningsTable startDate={startDate} endDate={endDate} />}\n {view === 'olive' && olive}\n {view === 'customers' && customers}\n {view === 'merchants' && merchants}\n {view === 'plans' && planSubscriptions}\n </>\n );\n}\n"],"mappings":";AA4GI,mBAKE,KAJA,YADF;AA1GJ,SAAS,eAAe;AACxB,SAAS,uBAAuB;AAQhC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,cAAc;AACvB,SAAS,cAAc;AACvB,SAAS,oBAAoB;AAC7B,SAAS,uBAAuB;AAChC,SAAS,qBAAqB;AAC9B,SAAS,wBAAwB;AACjC,SAAS,mBAAmB;AAC5B,SAAS,mBAAmB;AAC5B,SAAS,oBAAoB;AAC7B,SAAS,iBAAiB;AAC1B,SAAS,kBAAkB;AAC3B,SAAS,uBAAuB;AAChC,SAAS,mBAAmB;AAC5B,SAAS,0BAA0B;AACnC,SAAS,uBAAuB;AAChC,OAAO,YAAY;AACnB,OAAO,kBAAkB;AAOzB,MAAM,QAAQ;AAAA,EACZ,EAAE,IAAI,YAAY,OAAO,WAAW;AAAA,EACpC,EAAE,IAAI,gBAAgB,OAAO,eAAe;AAAA,EAC5C,EAAE,IAAI,SAAS,OAAO,oBAAoB;AAAA,EAC1C,EAAE,IAAI,aAAa,OAAO,oBAAoB;AAAA,EAC9C,EAAE,IAAI,aAAa,OAAO,uBAAuB;AAAA,EACjD,EAAE,IAAI,SAAS,OAAO,qBAAqB;AAC7C;AAEA,MAAM,YAAY,MAAM,IAAI,CAAC,MAAM,EAAE,EAAE;AAOhC,SAAS,gBAAgB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAsBG;AACD,QAAM,CAAC,MAAM,OAAO,IAAI,YAAY,WAAW,MAAM;AACrD,QAAM,MAAM,OAAO,KAAK,eAAe,KAAK;AAC5C,QAAM,YAAY,QAAQ,MAAM,QAAQ,KAAK,QAAQ,KAAK,GAAG,CAAC,GAAG,CAAC;AAClE,QAAM,aAAa,QAAQ,GAAG,MAAM,SAAM,KAAK,KAAK;AACpD,QAAM,mBAAmB,KAAK,UAAU,YAAY;AAEpD,QAAM,eAAe,gBAAgB,KAAK,8BAA8B,QAAQ;AAEhF,QAAM,QACJ,iCACE;AAAA,yBAAC,OAAE,WAAW,aAAa,aAAa;AAAA;AAAA,MAErC,mBAAmB,KAAK;AAAA,OAC3B;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA,UAAU,CAAC;AAAA;AAAA,IACb;AAAA,IAEC,gBACC,oBAAC,aAAQ,cAAW,iBAAgB,WAAU,YAC5C,8BAAC,UAAO,OAAO,GAAG,WAAU,cAC1B;AAAA,MAAC;AAAA;AAAA,QACC,OAAM;AAAA,QACN,OACE;AAAA,UAAC;AAAA;AAAA,YACC,MAAM;AAAA,YACN,UAAS;AAAA,YACT,QAAQ,CAAC,MAAM,EAAE;AAAA,YACjB,UAAU;AAAA;AAAA,QACZ;AAAA,QAEF,MAAK;AAAA;AAAA,IACP,GACF,GACF;AAAA,IAGD,oBACC,qBAAC,SAAI,WAAU,YACb;AAAA,0BAAC,SAAI,WAAW,QAAQ,gBAAgB,cACtC,8BAAC,eAAY,QAAgB,OAAc,SAAS,kBAAkB,GACxE;AAAA,MAEA,oBAAC,SAAI,WAAW,QAAQ,gBAAgB,cACrC,kBACC;AAAA,QAAC;AAAA;AAAA,UACC,OAAO,UAAU,MAAM,OAAO,MAAM,YAAY,KAAK,UAAU,aAAa;AAAA,UAC5E;AAAA,UACA,SAAS;AAAA;AAAA,MACX,IAEA;AAAA,QAAC;AAAA;AAAA,UACC,eAAe,KAAK,UAAU;AAAA,UAC9B;AAAA,UACA,SAAS;AAAA;AAAA,MACX,GAEJ;AAAA,OACF;AAAA,KAEJ;AAGF,QAAM,YACJ,iCACE;AAAA,wBAAC,OAAE,WAAW,aAAa,aAAa,oFAExC;AAAA,IACC,UAAU,QACT;AAAA,MAAC;AAAA;AAAA,QACC,KAAK,OAAO,UAAU;AAAA,QACtB,QAAQ,OAAO,UAAU;AAAA,QACzB,UAAU;AAAA;AAAA,IACZ,IACE,YAAY,SAAS,SAAS,IAChC,oBAAC,SAAI,WAAU,YACb,8BAAC,SAAI,WAAU,eACb,8BAAC,gBAAa,QAAQ,UAAU,OAAc,SAAS,YAAY,GACrE,GACF,IAEA,oBAAC,OAAE,WAAW,OAAO,OAAQ,kBAAQ,qCAAqC,eAAc;AAAA,KAE5F;AAGF,QAAM,YACJ,iCACE;AAAA,wBAAC,OAAE,WAAW,aAAa,aAAa,qGAExC;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA,UAAU,UAAU,IAAI,CAAC,MAAM,EAAE,KAAK;AAAA,QACtC,OAAO,UAAU,QAAQ,oBAAC,oBAAiB,QAAgB,OAAc,IAAK;AAAA;AAAA,IAChF;AAAA,IAEA,oBAAC,SAAI,WAAU,YACb,8BAAC,SAAI,WAAU,eACb;AAAA,MAAC;AAAA;AAAA,QACC,OAAM;AAAA,QACN,SAAS;AAAA,QACT,QAAO;AAAA,QAEN,kBACC,oBAAC,SAAI,WAAW,OAAO,WACrB,8BAAC,UAAO,MAAK,SACX;AAAA,UAAC;AAAA;AAAA,YACC,MAAM;AAAA,YACN,SAAS;AAAA,YACT,cAAc,YAAY,IAAI;AAAA,YAC9B,aAAa,QAAQ,KAAK;AAAA,YAC1B,cAAc,WAAW,KAAK;AAAA,YAC9B,QAAQ;AAAA;AAAA,QACV,GACF,GACF,IAEA,oBAAC,OAAE,WAAW,OAAO,OAAQ,yBAAc;AAAA;AAAA,IAE/C,GACF,GACF;AAAA,IAEA,oBAAC,SAAI,WAAU,YACb,8BAAC,SAAI,WAAU,eACb;AAAA,MAAC;AAAA;AAAA,QACC,SAAS,KAAK,cAAc,CAAC;AAAA,QAC7B,UAAU,KAAK,YAAY;AAAA,QAC3B;AAAA;AAAA,IACF,GACF,GACF;AAAA,KACF;AAGF,QAAM,oBAAoB,mBACxB,oBAAC,SAAI,WAAU,YACb,8BAAC,SAAI,WAAU,eACb,8BAAC,sBAAmB,WAAW,KAAK,WAAW,MAAY,GAC7D,GACF,IAEA,oBAAC,OAAE,WAAW,OAAO,OAAO,wDAA0C;AAGxE,SACE,iCACE;AAAA,wBAAC,YAAS,MAAM,OAAO,QAAQ,MAAM,UAAU,SAAS;AAAA,IACvD,SAAS,cACR;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACA;AAAA,QACA;AAAA,QACA,WAAW,KAAK;AAAA,QAChB;AAAA;AAAA,IACF;AAAA,IAED,SAAS,kBAAkB,oBAAC,iBAAc,WAAsB,SAAkB;AAAA,IAClF,SAAS,WAAW;AAAA,IACpB,SAAS,eAAe;AAAA,IACxB,SAAS,eAAe;AAAA,IACxB,SAAS,WAAW;AAAA,KACvB;AAEJ;","names":[]}
@@ -122,6 +122,8 @@ function RevenueWorkspace() {
122
122
  body = /* @__PURE__ */ jsx(
123
123
  RevenueOverview,
124
124
  {
125
+ startDate: range.startDate,
126
+ endDate: range.endDate,
125
127
  data,
126
128
  health: cockpit.data?.health,
127
129
  money,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/admin/revenue/revenue-workspace.tsx"],"sourcesContent":["'use client';\n\nimport { useMemo } from 'react';\nimport { ADMIN_NAME } from '../_shell/admin-brand';\nimport {\n useGatewayChannels,\n useMerchantPayments,\n usePlatformCockpit,\n usePlatformRevenue,\n} from '../hooks';\nimport { LoadFailed } from '../audit/load-failed';\nimport {\n AdmLoading,\n CurrencyFilter,\n PageHeader,\n PageTabs,\n PeriodSelector,\n customRange,\n focusCurrency,\n moneyIn,\n periodPhrase,\n rangeFromKey,\n useCurrencyParam,\n usePersistedDateRange,\n useTabParam,\n} from '../_ui';\nimport { MerchantPaymentsView } from './merchant-payments-view';\nimport { usePlanList } from '../pricing/use-plans';\nimport { RevenueOverview } from './revenue-overview';\nimport { RevenueRetention } from './revenue-retention';\nimport { SubscriptionsView } from './subscriptions-view';\nimport { SummaryDownload } from './summary-download';\n\nconst TABS = [\n { id: 'overview', label: 'Overview' },\n { id: 'merchants', label: 'Merchant payments' },\n { id: 'subscriptions', label: 'Subscriptions' },\n { id: 'retention', label: 'Retention' },\n] as const;\n\nconst KEYS = TABS.map((t) => t.id);\n\n/**\n * Revenue: the whole picture on Overview, what customers paid merchants on\n * Merchant payments, what merchants pay Olive on Subscriptions, and revenue\n * quality and cohorts on Retention.\n */\nexport function RevenueWorkspace() {\n const [tab, setTab] = useTabParam(KEYS);\n const {\n rangeKey,\n setRangeKey,\n customStart,\n customEnd,\n onCustomChange,\n } = usePersistedDateRange();\n // Merchant GMV comes back once per currency; `?currency=` narrows it.\n const [currency, setCurrency] = useCurrencyParam();\n\n const range = useMemo(\n () =>\n rangeKey === 'custom'\n ? customRange(customStart, customEnd)\n : rangeFromKey(rangeKey),\n [rangeKey, customStart, customEnd],\n );\n\n const { data, isLoading, isError, refetch } = usePlatformRevenue({\n startDate: range.startDate,\n endDate: range.endDate,\n });\n // Revenue health (MRR movement, logo churn, concentration) is computed as\n // part of the cockpit, not the revenue endpoint — fetched here too rather\n // than duplicating that aggregation.\n const plans = usePlanList();\n const cockpit = usePlatformCockpit({\n startDate: range.startDate,\n endDate: range.endDate,\n });\n\n const channels = useGatewayChannels({\n startDate: range.startDate,\n endDate: range.endDate,\n });\n\n // The same totals the Merchant payments tab reads, one row only, for the method cards.\n const ledger = useMerchantPayments({\n startDate: range.startDate,\n endDate: range.endDate,\n limit: 1,\n });\n\n const currencies = useMemo(() => data?.currencies ?? [], [data]);\n const focus = focusCurrency(currencies, currency);\n // Recurring revenue is the platform's own billing, in the currency the API\n // names for it (`currency`), shown as it comes, never converted.\n const recurringCurrency = data?.currency || 'NGN';\n const money = useMemo(() => moneyIn(recurringCurrency), [recurringCurrency]);\n const money2 = useMemo(\n () => moneyIn(recurringCurrency, 2),\n [recurringCurrency],\n );\n\n const span = periodPhrase(range);\n const period = `${span[0].toUpperCase()}${span.slice(1)}`;\n\n let body;\n if (isError) {\n body = (\n <LoadFailed\n message=\"Revenue for this period could not be loaded.\"\n onRetry={() => void refetch()}\n />\n );\n } else if (isLoading || !data) {\n body = <AdmLoading label=\"Loading revenue metrics…\" />;\n } else if (tab === 'merchants') {\n body = (\n <MerchantPaymentsView\n startDate={range.startDate}\n endDate={range.endDate}\n currency={currency}\n />\n );\n } else if (tab === 'subscriptions') {\n body = (\n <SubscriptionsView\n startDate={range.startDate}\n endDate={range.endDate}\n revenue={data}\n />\n );\n } else if (tab === 'retention') {\n body = (\n <RevenueRetention\n quality={data.quality}\n money={money}\n currency={currency}\n caption={currency ? `${period} · ${currency}` : period}\n />\n );\n } else {\n body = (\n <RevenueOverview\n data={data}\n health={cockpit.data?.health}\n money={money}\n money2={money2}\n currency={currency}\n focus={focus}\n period={period}\n span={span}\n recurringCaption={`${period} · ${recurringCurrency}`}\n channels={channels.data?.byChannel}\n ledger={ledger.data}\n plans={plans.data}\n />\n );\n }\n\n return (\n <div>\n <PageHeader\n title=\"Revenue\"\n description={`All the money moving through ${ADMIN_NAME}: what customers pay merchants, and what merchants pay ${ADMIN_NAME} for their plan.`}\n actions={\n <div className=\"adm-toolbar\">\n {tab === 'overview' ? (\n <SummaryDownload\n ledger={ledger.data}\n revenue={data}\n currency={focus ?? 'NGN'}\n startDate={range.startDate}\n endDate={range.endDate}\n period={period}\n />\n ) : null}\n <CurrencyFilter\n value={currency}\n onChange={setCurrency}\n currencies={currencies}\n />\n <PeriodSelector\n value={rangeKey}\n onChange={setRangeKey}\n customStart={customStart}\n customEnd={customEnd}\n onCustomChange={onCustomChange}\n />\n </div>\n }\n />\n <PageTabs tabs={TABS} active={tab} onChange={setTab} />\n {body}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/admin/revenue/revenue-workspace.tsx"],"sourcesContent":["'use client';\n\nimport { useMemo } from 'react';\nimport { ADMIN_NAME } from '../_shell/admin-brand';\nimport {\n useGatewayChannels,\n useMerchantPayments,\n usePlatformCockpit,\n usePlatformRevenue,\n} from '../hooks';\nimport { LoadFailed } from '../audit/load-failed';\nimport {\n AdmLoading,\n CurrencyFilter,\n PageHeader,\n PageTabs,\n PeriodSelector,\n customRange,\n focusCurrency,\n moneyIn,\n periodPhrase,\n rangeFromKey,\n useCurrencyParam,\n usePersistedDateRange,\n useTabParam,\n} from '../_ui';\nimport { MerchantPaymentsView } from './merchant-payments-view';\nimport { usePlanList } from '../pricing/use-plans';\nimport { RevenueOverview } from './revenue-overview';\nimport { RevenueRetention } from './revenue-retention';\nimport { SubscriptionsView } from './subscriptions-view';\nimport { SummaryDownload } from './summary-download';\n\nconst TABS = [\n { id: 'overview', label: 'Overview' },\n { id: 'merchants', label: 'Merchant payments' },\n { id: 'subscriptions', label: 'Subscriptions' },\n { id: 'retention', label: 'Retention' },\n] as const;\n\nconst KEYS = TABS.map((t) => t.id);\n\n/**\n * Revenue: the whole picture on Overview, what customers paid merchants on\n * Merchant payments, what merchants pay Olive on Subscriptions, and revenue\n * quality and cohorts on Retention.\n */\nexport function RevenueWorkspace() {\n const [tab, setTab] = useTabParam(KEYS);\n const {\n rangeKey,\n setRangeKey,\n customStart,\n customEnd,\n onCustomChange,\n } = usePersistedDateRange();\n // Merchant GMV comes back once per currency; `?currency=` narrows it.\n const [currency, setCurrency] = useCurrencyParam();\n\n const range = useMemo(\n () =>\n rangeKey === 'custom'\n ? customRange(customStart, customEnd)\n : rangeFromKey(rangeKey),\n [rangeKey, customStart, customEnd],\n );\n\n const { data, isLoading, isError, refetch } = usePlatformRevenue({\n startDate: range.startDate,\n endDate: range.endDate,\n });\n // Revenue health (MRR movement, logo churn, concentration) is computed as\n // part of the cockpit, not the revenue endpoint — fetched here too rather\n // than duplicating that aggregation.\n const plans = usePlanList();\n const cockpit = usePlatformCockpit({\n startDate: range.startDate,\n endDate: range.endDate,\n });\n\n const channels = useGatewayChannels({\n startDate: range.startDate,\n endDate: range.endDate,\n });\n\n // The same totals the Merchant payments tab reads, one row only, for the method cards.\n const ledger = useMerchantPayments({\n startDate: range.startDate,\n endDate: range.endDate,\n limit: 1,\n });\n\n const currencies = useMemo(() => data?.currencies ?? [], [data]);\n const focus = focusCurrency(currencies, currency);\n // Recurring revenue is the platform's own billing, in the currency the API\n // names for it (`currency`), shown as it comes, never converted.\n const recurringCurrency = data?.currency || 'NGN';\n const money = useMemo(() => moneyIn(recurringCurrency), [recurringCurrency]);\n const money2 = useMemo(\n () => moneyIn(recurringCurrency, 2),\n [recurringCurrency],\n );\n\n const span = periodPhrase(range);\n const period = `${span[0].toUpperCase()}${span.slice(1)}`;\n\n let body;\n if (isError) {\n body = (\n <LoadFailed\n message=\"Revenue for this period could not be loaded.\"\n onRetry={() => void refetch()}\n />\n );\n } else if (isLoading || !data) {\n body = <AdmLoading label=\"Loading revenue metrics…\" />;\n } else if (tab === 'merchants') {\n body = (\n <MerchantPaymentsView\n startDate={range.startDate}\n endDate={range.endDate}\n currency={currency}\n />\n );\n } else if (tab === 'subscriptions') {\n body = (\n <SubscriptionsView\n startDate={range.startDate}\n endDate={range.endDate}\n revenue={data}\n />\n );\n } else if (tab === 'retention') {\n body = (\n <RevenueRetention\n quality={data.quality}\n money={money}\n currency={currency}\n caption={currency ? `${period} · ${currency}` : period}\n />\n );\n } else {\n body = (\n <RevenueOverview\n startDate={range.startDate}\n endDate={range.endDate}\n data={data}\n health={cockpit.data?.health}\n money={money}\n money2={money2}\n currency={currency}\n focus={focus}\n period={period}\n span={span}\n recurringCaption={`${period} · ${recurringCurrency}`}\n channels={channels.data?.byChannel}\n ledger={ledger.data}\n plans={plans.data}\n />\n );\n }\n\n return (\n <div>\n <PageHeader\n title=\"Revenue\"\n description={`All the money moving through ${ADMIN_NAME}: what customers pay merchants, and what merchants pay ${ADMIN_NAME} for their plan.`}\n actions={\n <div className=\"adm-toolbar\">\n {tab === 'overview' ? (\n <SummaryDownload\n ledger={ledger.data}\n revenue={data}\n currency={focus ?? 'NGN'}\n startDate={range.startDate}\n endDate={range.endDate}\n period={period}\n />\n ) : null}\n <CurrencyFilter\n value={currency}\n onChange={setCurrency}\n currencies={currencies}\n />\n <PeriodSelector\n value={rangeKey}\n onChange={setRangeKey}\n customStart={customStart}\n customEnd={customEnd}\n onCustomChange={onCustomChange}\n />\n </div>\n }\n />\n <PageTabs tabs={TABS} active={tab} onChange={setTab} />\n {body}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -13,10 +13,12 @@ import {
13
13
  PURPOSE_WORDS,
14
14
  STATUS_WORDS,
15
15
  SUBSCRIPTION_METHOD_WORDS,
16
+ planPaymentTotals,
16
17
  downloadName,
17
18
  minorToMoney,
18
19
  word
19
20
  } from "./ledger-model";
21
+ import { MethodCards } from "./method-cards";
20
22
  import { PlanMixPanel } from "./plan-mix-panel";
21
23
  import { PlanPaymentDrawer } from "./plan-payment-drawer";
22
24
  import { useUrlFilters } from "./use-url-filters";
@@ -31,10 +33,15 @@ const STATUSES = [
31
33
  { value: "failed", label: "Failed" },
32
34
  { value: "pending", label: "Waiting" }
33
35
  ];
36
+ const METHOD_HINTS = {
37
+ wallet: "Plan payments taken from the merchant's Box wallet.",
38
+ gateway: "Plan payments the merchant paid through the Olive payment gateway (card or bank).",
39
+ manual: "Plan payments an admin recorded by hand, for example a bank transfer."
40
+ };
34
41
  const METHODS = [
35
42
  { value: "", label: "Every way of paying" },
36
- { value: "gateway", label: "Card or bank" },
37
- { value: "wallet", label: "Wallet" },
43
+ { value: "gateway", label: "Olive payment gateway" },
44
+ { value: "wallet", label: "Box wallet" },
38
45
  { value: "manual", label: "Recorded by hand" }
39
46
  ];
40
47
  const tone = (status) => status === "succeeded" ? styles.statusGood : status === "failed" ? styles.statusBad : styles.statusWait;
@@ -104,6 +111,24 @@ function SubscriptionsView({
104
111
  }
105
112
  ) })
106
113
  ] }),
114
+ tracked && recurring.collectedByMethod ? /* @__PURE__ */ jsxs(Fragment, { children: [
115
+ /* @__PURE__ */ jsx("h2", { className: styles.sectionTitle, children: "How merchants pay Olive" }),
116
+ /* @__PURE__ */ jsx("p", { className: styles.sectionNote, children: "Plan payments that went through in this period, by the way the merchant paid." }),
117
+ /* @__PURE__ */ jsx(
118
+ MethodCards,
119
+ {
120
+ now: planPaymentTotals(recurring.collectedByMethod, currency),
121
+ before: null,
122
+ currency,
123
+ order: ["wallet", "gateway"],
124
+ labels: SUBSCRIPTION_METHOD_WORDS,
125
+ title: "How merchants pay Olive",
126
+ received: "of the plan income collected",
127
+ describe: (_label, key) => METHOD_HINTS[key] ?? "Plan payments paid this way.",
128
+ hide: ["manual"]
129
+ }
130
+ )
131
+ ] }) : null,
107
132
  tracked && recurring.planBreakdown.length > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
108
133
  /* @__PURE__ */ jsx("h2", { className: styles.sectionTitle, children: "Who pays what" }),
109
134
  /* @__PURE__ */ jsx("p", { className: styles.sectionNote, children: "How the monthly plan income splits across plans." }),
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/admin/revenue/subscriptions-view.tsx"],"sourcesContent":["'use client';\n\nimport { useEffect, useMemo, useState } from 'react';\nimport { EmptyNote, TableRow, TableShell, Th } from '@withpotter/ui-kit';\nimport {\n platformAdminApi,\n type PlatformRevenue,\n type SubscriptionPaymentRow,\n} from '@withpotter/lib/admin-api';\nimport { useSubscriptionPayments } from '../hooks';\nimport { LoadFailed } from '../audit/load-failed';\nimport { CountUp, Reveal } from '../_fx';\nimport { AdmLoading, FilterSearch, FilterSelect, Filters, Pagination, StatCard, moneyIn } from '../_ui';\nimport { DownloadButton } from './download-button';\nimport {\n PURPOSE_WORDS,\n STATUS_WORDS,\n SUBSCRIPTION_METHOD_WORDS,\n downloadName,\n minorToMoney,\n word,\n} from './ledger-model';\nimport { PlanMixPanel } from './plan-mix-panel';\nimport { PlanPaymentDrawer } from './plan-payment-drawer';\nimport { useUrlFilters } from './use-url-filters';\nimport styles from './ledger.module.css';\nimport table from './revenue.module.css';\n\nconst PAGE_SIZE = 25;\nconst FILTERS = { q: '', status: '', method: '' };\nconst integer = new Intl.NumberFormat('en-US');\n\nconst STATUSES = [\n { value: '', label: 'Every result' },\n { value: 'succeeded', label: 'Paid' },\n { value: 'failed', label: 'Failed' },\n { value: 'pending', label: 'Waiting' },\n];\nconst METHODS = [\n { value: '', label: 'Every way of paying' },\n { value: 'gateway', label: 'Card or bank' },\n { value: 'wallet', label: 'Wallet' },\n { value: 'manual', label: 'Recorded by hand' },\n];\n\nconst tone = (status: string) =>\n status === 'succeeded' ? styles.statusGood : status === 'failed' ? styles.statusBad : styles.statusWait;\n\n/** What merchants pay Olive for their plan: the income figures, who pays what, and every plan payment. */\nexport function SubscriptionsView({\n startDate,\n endDate,\n revenue,\n}: {\n startDate?: string;\n endDate?: string;\n revenue: PlatformRevenue;\n}) {\n const { values, page, set } = useUrlFilters('sp', FILTERS);\n const [search, setSearch] = useState(values.q);\n const [open, setOpen] = useState<SubscriptionPaymentRow | null>(null);\n\n useEffect(() => {\n const timer = setTimeout(() => {\n if (search.trim() !== values.q) set({ q: search.trim() });\n }, 300);\n\n return () => clearTimeout(timer);\n // Only a change the person typed should write to the address.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [search]);\n\n const filters = useMemo(\n () => ({ startDate, endDate, status: values.status, method: values.method, q: values.q, page, limit: PAGE_SIZE }),\n [startDate, endDate, values, page],\n );\n\n const { recurring } = revenue;\n const tracked = recurring.tracked !== false;\n const currency = recurring.currency ?? revenue.currency ?? 'NGN';\n const money = moneyIn(currency);\n const money2 = moneyIn(currency, 2);\n const notSet = 'Not set up yet';\n const why = 'The plan payments have not been connected to this page yet.';\n const payments = useSubscriptionPayments(filters);\n\n return (\n <div>\n <p className={styles.lede}>\n This is money that merchants pay Olive for their plan. It is kept apart from the money customers pay merchants.\n </p>\n\n <section aria-label=\"Plan income\" className=\"adm-grid\">\n <Reveal index={0} className=\"adm-span-3\">\n <StatCard\n label=\"Plan income each month\"\n value={tracked ? <CountUp value={recurring.mrr} format={money} /> : notSet}\n sub={tracked ? `${integer.format(recurring.payingMerchants ?? recurring.activeSubscriptions)} merchants paying` : why}\n hint=\"What merchants on a paid plan are expected to pay Olive every month. Merchants on a free plan, a trial or a pause are not counted.\"\n />\n </Reveal>\n <Reveal index={1} className=\"adm-span-3\">\n <StatCard\n label=\"Plan income over a year\"\n value={tracked ? <CountUp value={recurring.arr} format={money} /> : notSet}\n sub={tracked ? 'Each month times 12' : why}\n hint=\"What plan income adds up to over a year if nothing changes: the monthly figure times 12.\"\n />\n </Reveal>\n <Reveal index={2} className=\"adm-span-3\">\n <StatCard\n label=\"Average per paying merchant\"\n value={tracked ? <CountUp value={recurring.arpa} format={money2} /> : notSet}\n sub={tracked ? 'Each month' : why}\n hint=\"What one paying merchant pays Olive in a month, on average.\"\n />\n </Reveal>\n <Reveal index={3} className=\"adm-span-3\">\n <StatCard\n label=\"Joined and left\"\n value={tracked ? `${integer.format(recurring.newInRange)} joined` : notSet}\n sub={tracked ? `${integer.format(recurring.churnedInRange)} left or cancelled in this period` : why}\n hint=\"Merchants who started a plan in this period, and merchants whose plan was cancelled, ended or switched off.\"\n />\n </Reveal>\n </section>\n\n {tracked && recurring.planBreakdown.length > 0 ? (\n <>\n <h2 className={styles.sectionTitle}>Who pays what</h2>\n <p className={styles.sectionNote}>How the monthly plan income splits across plans.</p>\n <PlanMixPanel planBreakdown={recurring.planBreakdown} money={money} caption={`Each month · ${currency}`} />\n </>\n ) : null}\n\n <h2 className={styles.sectionTitle}>Every plan payment</h2>\n {payments.isError ? (\n <div className={styles.notice}>\n Plan payments are not available on this box yet. Once the subscription module is connected, every payment will\n be listed here.\n </div>\n ) : payments.isLoading || !payments.data ? (\n <AdmLoading label=\"Loading plan payments…\" />\n ) : (\n <>\n <div className={styles.toolbar}>\n <Filters label=\"Filter plan payments\">\n <FilterSearch\n value={search}\n onChange={setSearch}\n placeholder=\"Search a merchant's name or email, or a reference\"\n label=\"Search plan payments\"\n />\n <FilterSelect value={values.status} onChange={(status) => set({ status })} options={STATUSES} label=\"Payment result\" />\n <FilterSelect value={values.method} onChange={(method) => set({ method })} options={METHODS} label=\"Way of paying\" />\n </Filters>\n <DownloadButton\n filename={downloadName('plan-payments', startDate, endDate)}\n load={() => platformAdminApi.subscriptionPaymentsCsv({ ...filters, page: undefined, limit: undefined })}\n />\n </div>\n\n {payments.data.items.length === 0 ? (\n <EmptyNote className={table.empty}>No plan payments match. Try a wider date range or clear a filter.</EmptyNote>\n ) : (\n <TableShell\n minWidth={860}\n density=\"compact\"\n head={\n <tr>\n <Th>When</Th>\n <Th>Merchant</Th>\n <Th>Plan</Th>\n <Th>For</Th>\n <Th>How</Th>\n <Th>Result</Th>\n <Th align=\"right\">Amount</Th>\n </tr>\n }\n >\n {payments.data.items.map((row) => (\n <PlanPaymentRow key={row.id} row={row} onOpen={setOpen} />\n ))}\n </TableShell>\n )}\n <Pagination\n page={page}\n totalPages={Math.max(Math.ceil(payments.data.total / PAGE_SIZE), 1)}\n total={payments.data.total}\n noun=\"plan payments\"\n onPage={(next) => set({ page: next })}\n />\n <PlanPaymentDrawer row={open} onClose={() => setOpen(null)} />\n </>\n )}\n </div>\n );\n}\n\nfunction PlanPaymentRow({ row, onOpen }: { row: SubscriptionPaymentRow; onOpen: (row: SubscriptionPaymentRow) => void }) {\n const when = row.paidAt ?? row.createdAt;\n\n return (\n <TableRow onClick={() => onOpen(row)}>\n <td className={table.num}>\n {new Date(when).toLocaleString(undefined, { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })}\n </td>\n <td>\n <span className={styles.who}>\n <span className={styles.whoName}>{row.merchantName || 'Unknown merchant'}</span>\n {row.merchantEmail ? <span className={styles.whoSub}>{row.merchantEmail}</span> : null}\n </span>\n </td>\n <td className={table.name}>{row.plan.charAt(0).toUpperCase() + row.plan.slice(1)}</td>\n <td>{word(PURPOSE_WORDS, row.purpose)}</td>\n <td>{word(SUBSCRIPTION_METHOD_WORDS, row.method)}</td>\n <td>\n <span className={styles.who}>\n <span className={`${styles.status} ${tone(row.status)}`}>{word(STATUS_WORDS, row.status)}</span>\n {row.status === 'failed' && row.failureReason ? <span className={styles.whoSub}>{row.failureReason}</span> : null}\n </span>\n </td>\n <td className={table.numStrong}>{minorToMoney(row.amountKobo, 'NGN')}</td>\n </TableRow>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/admin/revenue/subscriptions-view.tsx"],"sourcesContent":["'use client';\n\nimport { useEffect, useMemo, useState } from 'react';\nimport { EmptyNote, TableRow, TableShell, Th } from '@withpotter/ui-kit';\nimport {\n platformAdminApi,\n type PlatformRevenue,\n type SubscriptionPaymentRow,\n} from '@withpotter/lib/admin-api';\nimport { useSubscriptionPayments } from '../hooks';\nimport { LoadFailed } from '../audit/load-failed';\nimport { CountUp, Reveal } from '../_fx';\nimport { AdmLoading, FilterSearch, FilterSelect, Filters, Pagination, StatCard, moneyIn } from '../_ui';\nimport { DownloadButton } from './download-button';\nimport {\n PURPOSE_WORDS,\n STATUS_WORDS,\n SUBSCRIPTION_METHOD_WORDS,\n planPaymentTotals,\n downloadName,\n minorToMoney,\n word,\n} from './ledger-model';\nimport { MethodCards } from './method-cards';\nimport { PlanMixPanel } from './plan-mix-panel';\nimport { PlanPaymentDrawer } from './plan-payment-drawer';\nimport { useUrlFilters } from './use-url-filters';\nimport styles from './ledger.module.css';\nimport table from './revenue.module.css';\n\nconst PAGE_SIZE = 25;\nconst FILTERS = { q: '', status: '', method: '' };\nconst integer = new Intl.NumberFormat('en-US');\n\nconst STATUSES = [\n { value: '', label: 'Every result' },\n { value: 'succeeded', label: 'Paid' },\n { value: 'failed', label: 'Failed' },\n { value: 'pending', label: 'Waiting' },\n];\nconst METHOD_HINTS: Record<string, string> = {\n wallet: \"Plan payments taken from the merchant's Box wallet.\",\n gateway: 'Plan payments the merchant paid through the Olive payment gateway (card or bank).',\n manual: 'Plan payments an admin recorded by hand, for example a bank transfer.',\n};\n\nconst METHODS = [\n { value: '', label: 'Every way of paying' },\n { value: 'gateway', label: 'Olive payment gateway' },\n { value: 'wallet', label: 'Box wallet' },\n { value: 'manual', label: 'Recorded by hand' },\n];\n\nconst tone = (status: string) =>\n status === 'succeeded' ? styles.statusGood : status === 'failed' ? styles.statusBad : styles.statusWait;\n\n/** What merchants pay Olive for their plan: the income figures, who pays what, and every plan payment. */\nexport function SubscriptionsView({\n startDate,\n endDate,\n revenue,\n}: {\n startDate?: string;\n endDate?: string;\n revenue: PlatformRevenue;\n}) {\n const { values, page, set } = useUrlFilters('sp', FILTERS);\n const [search, setSearch] = useState(values.q);\n const [open, setOpen] = useState<SubscriptionPaymentRow | null>(null);\n\n useEffect(() => {\n const timer = setTimeout(() => {\n if (search.trim() !== values.q) set({ q: search.trim() });\n }, 300);\n\n return () => clearTimeout(timer);\n // Only a change the person typed should write to the address.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [search]);\n\n const filters = useMemo(\n () => ({ startDate, endDate, status: values.status, method: values.method, q: values.q, page, limit: PAGE_SIZE }),\n [startDate, endDate, values, page],\n );\n\n const { recurring } = revenue;\n const tracked = recurring.tracked !== false;\n const currency = recurring.currency ?? revenue.currency ?? 'NGN';\n const money = moneyIn(currency);\n const money2 = moneyIn(currency, 2);\n const notSet = 'Not set up yet';\n const why = 'The plan payments have not been connected to this page yet.';\n const payments = useSubscriptionPayments(filters);\n\n return (\n <div>\n <p className={styles.lede}>\n This is money that merchants pay Olive for their plan. It is kept apart from the money customers pay merchants.\n </p>\n\n <section aria-label=\"Plan income\" className=\"adm-grid\">\n <Reveal index={0} className=\"adm-span-3\">\n <StatCard\n label=\"Plan income each month\"\n value={tracked ? <CountUp value={recurring.mrr} format={money} /> : notSet}\n sub={tracked ? `${integer.format(recurring.payingMerchants ?? recurring.activeSubscriptions)} merchants paying` : why}\n hint=\"What merchants on a paid plan are expected to pay Olive every month. Merchants on a free plan, a trial or a pause are not counted.\"\n />\n </Reveal>\n <Reveal index={1} className=\"adm-span-3\">\n <StatCard\n label=\"Plan income over a year\"\n value={tracked ? <CountUp value={recurring.arr} format={money} /> : notSet}\n sub={tracked ? 'Each month times 12' : why}\n hint=\"What plan income adds up to over a year if nothing changes: the monthly figure times 12.\"\n />\n </Reveal>\n <Reveal index={2} className=\"adm-span-3\">\n <StatCard\n label=\"Average per paying merchant\"\n value={tracked ? <CountUp value={recurring.arpa} format={money2} /> : notSet}\n sub={tracked ? 'Each month' : why}\n hint=\"What one paying merchant pays Olive in a month, on average.\"\n />\n </Reveal>\n <Reveal index={3} className=\"adm-span-3\">\n <StatCard\n label=\"Joined and left\"\n value={tracked ? `${integer.format(recurring.newInRange)} joined` : notSet}\n sub={tracked ? `${integer.format(recurring.churnedInRange)} left or cancelled in this period` : why}\n hint=\"Merchants who started a plan in this period, and merchants whose plan was cancelled, ended or switched off.\"\n />\n </Reveal>\n </section>\n\n {tracked && recurring.collectedByMethod ? (\n <>\n <h2 className={styles.sectionTitle}>How merchants pay Olive</h2>\n <p className={styles.sectionNote}>Plan payments that went through in this period, by the way the merchant paid.</p>\n <MethodCards\n now={planPaymentTotals(recurring.collectedByMethod, currency)}\n before={null}\n currency={currency}\n order={['wallet', 'gateway']}\n labels={SUBSCRIPTION_METHOD_WORDS}\n title=\"How merchants pay Olive\"\n received=\"of the plan income collected\"\n describe={(_label, key) => METHOD_HINTS[key] ?? 'Plan payments paid this way.'}\n hide={['manual']}\n />\n </>\n ) : null}\n\n {tracked && recurring.planBreakdown.length > 0 ? (\n <>\n <h2 className={styles.sectionTitle}>Who pays what</h2>\n <p className={styles.sectionNote}>How the monthly plan income splits across plans.</p>\n <PlanMixPanel planBreakdown={recurring.planBreakdown} money={money} caption={`Each month · ${currency}`} />\n </>\n ) : null}\n\n <h2 className={styles.sectionTitle}>Every plan payment</h2>\n {payments.isError ? (\n <div className={styles.notice}>\n Plan payments are not available on this box yet. Once the subscription module is connected, every payment will\n be listed here.\n </div>\n ) : payments.isLoading || !payments.data ? (\n <AdmLoading label=\"Loading plan payments…\" />\n ) : (\n <>\n <div className={styles.toolbar}>\n <Filters label=\"Filter plan payments\">\n <FilterSearch\n value={search}\n onChange={setSearch}\n placeholder=\"Search a merchant's name or email, or a reference\"\n label=\"Search plan payments\"\n />\n <FilterSelect value={values.status} onChange={(status) => set({ status })} options={STATUSES} label=\"Payment result\" />\n <FilterSelect value={values.method} onChange={(method) => set({ method })} options={METHODS} label=\"Way of paying\" />\n </Filters>\n <DownloadButton\n filename={downloadName('plan-payments', startDate, endDate)}\n load={() => platformAdminApi.subscriptionPaymentsCsv({ ...filters, page: undefined, limit: undefined })}\n />\n </div>\n\n {payments.data.items.length === 0 ? (\n <EmptyNote className={table.empty}>No plan payments match. Try a wider date range or clear a filter.</EmptyNote>\n ) : (\n <TableShell\n minWidth={860}\n density=\"compact\"\n head={\n <tr>\n <Th>When</Th>\n <Th>Merchant</Th>\n <Th>Plan</Th>\n <Th>For</Th>\n <Th>How</Th>\n <Th>Result</Th>\n <Th align=\"right\">Amount</Th>\n </tr>\n }\n >\n {payments.data.items.map((row) => (\n <PlanPaymentRow key={row.id} row={row} onOpen={setOpen} />\n ))}\n </TableShell>\n )}\n <Pagination\n page={page}\n totalPages={Math.max(Math.ceil(payments.data.total / PAGE_SIZE), 1)}\n total={payments.data.total}\n noun=\"plan payments\"\n onPage={(next) => set({ page: next })}\n />\n <PlanPaymentDrawer row={open} onClose={() => setOpen(null)} />\n </>\n )}\n </div>\n );\n}\n\nfunction PlanPaymentRow({ row, onOpen }: { row: SubscriptionPaymentRow; onOpen: (row: SubscriptionPaymentRow) => void }) {\n const when = row.paidAt ?? row.createdAt;\n\n return (\n <TableRow onClick={() => onOpen(row)}>\n <td className={table.num}>\n {new Date(when).toLocaleString(undefined, { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })}\n </td>\n <td>\n <span className={styles.who}>\n <span className={styles.whoName}>{row.merchantName || 'Unknown merchant'}</span>\n {row.merchantEmail ? <span className={styles.whoSub}>{row.merchantEmail}</span> : null}\n </span>\n </td>\n <td className={table.name}>{row.plan.charAt(0).toUpperCase() + row.plan.slice(1)}</td>\n <td>{word(PURPOSE_WORDS, row.purpose)}</td>\n <td>{word(SUBSCRIPTION_METHOD_WORDS, row.method)}</td>\n <td>\n <span className={styles.who}>\n <span className={`${styles.status} ${tone(row.status)}`}>{word(STATUS_WORDS, row.status)}</span>\n {row.status === 'failed' && row.failureReason ? <span className={styles.whoSub}>{row.failureReason}</span> : null}\n </span>\n </td>\n <td className={table.numStrong}>{minorToMoney(row.amountKobo, 'NGN')}</td>\n </TableRow>\n );\n}\n"],"mappings":";AAgGM,SAwCE,UAxCF,KAIA,YAJA;AA9FN,SAAS,WAAW,SAAS,gBAAgB;AAC7C,SAAS,WAAW,UAAU,YAAY,UAAU;AACpD;AAAA,EACE;AAAA,OAGK;AACP,SAAS,+BAA+B;AAExC,SAAS,SAAS,cAAc;AAChC,SAAS,YAAY,cAAc,cAAc,SAAS,YAAY,UAAU,eAAe;AAC/F,SAAS,sBAAsB;AAC/B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,mBAAmB;AAC5B,SAAS,oBAAoB;AAC7B,SAAS,yBAAyB;AAClC,SAAS,qBAAqB;AAC9B,OAAO,YAAY;AACnB,OAAO,WAAW;AAElB,MAAM,YAAY;AAClB,MAAM,UAAU,EAAE,GAAG,IAAI,QAAQ,IAAI,QAAQ,GAAG;AAChD,MAAM,UAAU,IAAI,KAAK,aAAa,OAAO;AAE7C,MAAM,WAAW;AAAA,EACf,EAAE,OAAO,IAAI,OAAO,eAAe;AAAA,EACnC,EAAE,OAAO,aAAa,OAAO,OAAO;AAAA,EACpC,EAAE,OAAO,UAAU,OAAO,SAAS;AAAA,EACnC,EAAE,OAAO,WAAW,OAAO,UAAU;AACvC;AACA,MAAM,eAAuC;AAAA,EAC3C,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,QAAQ;AACV;AAEA,MAAM,UAAU;AAAA,EACd,EAAE,OAAO,IAAI,OAAO,sBAAsB;AAAA,EAC1C,EAAE,OAAO,WAAW,OAAO,wBAAwB;AAAA,EACnD,EAAE,OAAO,UAAU,OAAO,aAAa;AAAA,EACvC,EAAE,OAAO,UAAU,OAAO,mBAAmB;AAC/C;AAEA,MAAM,OAAO,CAAC,WACZ,WAAW,cAAc,OAAO,aAAa,WAAW,WAAW,OAAO,YAAY,OAAO;AAGxF,SAAS,kBAAkB;AAAA,EAChC;AAAA,EACA;AAAA,EACA;AACF,GAIG;AACD,QAAM,EAAE,QAAQ,MAAM,IAAI,IAAI,cAAc,MAAM,OAAO;AACzD,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAS,OAAO,CAAC;AAC7C,QAAM,CAAC,MAAM,OAAO,IAAI,SAAwC,IAAI;AAEpE,YAAU,MAAM;AACd,UAAM,QAAQ,WAAW,MAAM;AAC7B,UAAI,OAAO,KAAK,MAAM,OAAO,EAAG,KAAI,EAAE,GAAG,OAAO,KAAK,EAAE,CAAC;AAAA,IAC1D,GAAG,GAAG;AAEN,WAAO,MAAM,aAAa,KAAK;AAAA,EAGjC,GAAG,CAAC,MAAM,CAAC;AAEX,QAAM,UAAU;AAAA,IACd,OAAO,EAAE,WAAW,SAAS,QAAQ,OAAO,QAAQ,QAAQ,OAAO,QAAQ,GAAG,OAAO,GAAG,MAAM,OAAO,UAAU;AAAA,IAC/G,CAAC,WAAW,SAAS,QAAQ,IAAI;AAAA,EACnC;AAEA,QAAM,EAAE,UAAU,IAAI;AACtB,QAAM,UAAU,UAAU,YAAY;AACtC,QAAM,WAAW,UAAU,YAAY,QAAQ,YAAY;AAC3D,QAAM,QAAQ,QAAQ,QAAQ;AAC9B,QAAM,SAAS,QAAQ,UAAU,CAAC;AAClC,QAAM,SAAS;AACf,QAAM,MAAM;AACZ,QAAM,WAAW,wBAAwB,OAAO;AAEhD,SACE,qBAAC,SACC;AAAA,wBAAC,OAAE,WAAW,OAAO,MAAM,6HAE3B;AAAA,IAEA,qBAAC,aAAQ,cAAW,eAAc,WAAU,YAC1C;AAAA,0BAAC,UAAO,OAAO,GAAG,WAAU,cAC1B;AAAA,QAAC;AAAA;AAAA,UACC,OAAM;AAAA,UACN,OAAO,UAAU,oBAAC,WAAQ,OAAO,UAAU,KAAK,QAAQ,OAAO,IAAK;AAAA,UACpE,KAAK,UAAU,GAAG,QAAQ,OAAO,UAAU,mBAAmB,UAAU,mBAAmB,CAAC,sBAAsB;AAAA,UAClH,MAAK;AAAA;AAAA,MACP,GACF;AAAA,MACA,oBAAC,UAAO,OAAO,GAAG,WAAU,cAC1B;AAAA,QAAC;AAAA;AAAA,UACC,OAAM;AAAA,UACN,OAAO,UAAU,oBAAC,WAAQ,OAAO,UAAU,KAAK,QAAQ,OAAO,IAAK;AAAA,UACpE,KAAK,UAAU,wBAAwB;AAAA,UACvC,MAAK;AAAA;AAAA,MACP,GACF;AAAA,MACA,oBAAC,UAAO,OAAO,GAAG,WAAU,cAC1B;AAAA,QAAC;AAAA;AAAA,UACC,OAAM;AAAA,UACN,OAAO,UAAU,oBAAC,WAAQ,OAAO,UAAU,MAAM,QAAQ,QAAQ,IAAK;AAAA,UACtE,KAAK,UAAU,eAAe;AAAA,UAC9B,MAAK;AAAA;AAAA,MACP,GACF;AAAA,MACA,oBAAC,UAAO,OAAO,GAAG,WAAU,cAC1B;AAAA,QAAC;AAAA;AAAA,UACC,OAAM;AAAA,UACN,OAAO,UAAU,GAAG,QAAQ,OAAO,UAAU,UAAU,CAAC,YAAY;AAAA,UACpE,KAAK,UAAU,GAAG,QAAQ,OAAO,UAAU,cAAc,CAAC,sCAAsC;AAAA,UAChG,MAAK;AAAA;AAAA,MACP,GACF;AAAA,OACF;AAAA,IAEC,WAAW,UAAU,oBACpB,iCACE;AAAA,0BAAC,QAAG,WAAW,OAAO,cAAc,qCAAuB;AAAA,MAC3D,oBAAC,OAAE,WAAW,OAAO,aAAa,2FAA6E;AAAA,MAC/G;AAAA,QAAC;AAAA;AAAA,UACC,KAAK,kBAAkB,UAAU,mBAAmB,QAAQ;AAAA,UAC5D,QAAQ;AAAA,UACR;AAAA,UACA,OAAO,CAAC,UAAU,SAAS;AAAA,UAC3B,QAAQ;AAAA,UACR,OAAM;AAAA,UACN,UAAS;AAAA,UACT,UAAU,CAAC,QAAQ,QAAQ,aAAa,GAAG,KAAK;AAAA,UAChD,MAAM,CAAC,QAAQ;AAAA;AAAA,MACjB;AAAA,OACF,IACE;AAAA,IAEH,WAAW,UAAU,cAAc,SAAS,IAC3C,iCACE;AAAA,0BAAC,QAAG,WAAW,OAAO,cAAc,2BAAa;AAAA,MACjD,oBAAC,OAAE,WAAW,OAAO,aAAa,8DAAgD;AAAA,MAClF,oBAAC,gBAAa,eAAe,UAAU,eAAe,OAAc,SAAS,mBAAgB,QAAQ,IAAI;AAAA,OAC3G,IACE;AAAA,IAEJ,oBAAC,QAAG,WAAW,OAAO,cAAc,gCAAkB;AAAA,IACrD,SAAS,UACR,oBAAC,SAAI,WAAW,OAAO,QAAQ,4IAG/B,IACE,SAAS,aAAa,CAAC,SAAS,OAClC,oBAAC,cAAW,OAAM,+BAAyB,IAE3C,iCACE;AAAA,2BAAC,SAAI,WAAW,OAAO,SACrB;AAAA,6BAAC,WAAQ,OAAM,wBACb;AAAA;AAAA,YAAC;AAAA;AAAA,cACC,OAAO;AAAA,cACP,UAAU;AAAA,cACV,aAAY;AAAA,cACZ,OAAM;AAAA;AAAA,UACR;AAAA,UACA,oBAAC,gBAAa,OAAO,OAAO,QAAQ,UAAU,CAAC,WAAW,IAAI,EAAE,OAAO,CAAC,GAAG,SAAS,UAAU,OAAM,kBAAiB;AAAA,UACrH,oBAAC,gBAAa,OAAO,OAAO,QAAQ,UAAU,CAAC,WAAW,IAAI,EAAE,OAAO,CAAC,GAAG,SAAS,SAAS,OAAM,iBAAgB;AAAA,WACrH;AAAA,QACA;AAAA,UAAC;AAAA;AAAA,YACC,UAAU,aAAa,iBAAiB,WAAW,OAAO;AAAA,YAC1D,MAAM,MAAM,iBAAiB,wBAAwB,EAAE,GAAG,SAAS,MAAM,QAAW,OAAO,OAAU,CAAC;AAAA;AAAA,QACxG;AAAA,SACF;AAAA,MAEC,SAAS,KAAK,MAAM,WAAW,IAC9B,oBAAC,aAAU,WAAW,MAAM,OAAO,+EAAiE,IAEpG;AAAA,QAAC;AAAA;AAAA,UACC,UAAU;AAAA,UACV,SAAQ;AAAA,UACR,MACE,qBAAC,QACC;AAAA,gCAAC,MAAG,kBAAI;AAAA,YACR,oBAAC,MAAG,sBAAQ;AAAA,YACZ,oBAAC,MAAG,kBAAI;AAAA,YACR,oBAAC,MAAG,iBAAG;AAAA,YACP,oBAAC,MAAG,iBAAG;AAAA,YACP,oBAAC,MAAG,oBAAM;AAAA,YACV,oBAAC,MAAG,OAAM,SAAQ,oBAAM;AAAA,aAC1B;AAAA,UAGD,mBAAS,KAAK,MAAM,IAAI,CAAC,QACxB,oBAAC,kBAA4B,KAAU,QAAQ,WAA1B,IAAI,EAA+B,CACzD;AAAA;AAAA,MACH;AAAA,MAEF;AAAA,QAAC;AAAA;AAAA,UACC;AAAA,UACA,YAAY,KAAK,IAAI,KAAK,KAAK,SAAS,KAAK,QAAQ,SAAS,GAAG,CAAC;AAAA,UAClE,OAAO,SAAS,KAAK;AAAA,UACrB,MAAK;AAAA,UACL,QAAQ,CAAC,SAAS,IAAI,EAAE,MAAM,KAAK,CAAC;AAAA;AAAA,MACtC;AAAA,MACA,oBAAC,qBAAkB,KAAK,MAAM,SAAS,MAAM,QAAQ,IAAI,GAAG;AAAA,OAC9D;AAAA,KAEJ;AAEJ;AAEA,SAAS,eAAe,EAAE,KAAK,OAAO,GAAmF;AACvH,QAAM,OAAO,IAAI,UAAU,IAAI;AAE/B,SACE,qBAAC,YAAS,SAAS,MAAM,OAAO,GAAG,GACjC;AAAA,wBAAC,QAAG,WAAW,MAAM,KAClB,cAAI,KAAK,IAAI,EAAE,eAAe,QAAW,EAAE,KAAK,WAAW,OAAO,SAAS,MAAM,WAAW,MAAM,WAAW,QAAQ,UAAU,CAAC,GACnI;AAAA,IACA,oBAAC,QACC,+BAAC,UAAK,WAAW,OAAO,KACtB;AAAA,0BAAC,UAAK,WAAW,OAAO,SAAU,cAAI,gBAAgB,oBAAmB;AAAA,MACxE,IAAI,gBAAgB,oBAAC,UAAK,WAAW,OAAO,QAAS,cAAI,eAAc,IAAU;AAAA,OACpF,GACF;AAAA,IACA,oBAAC,QAAG,WAAW,MAAM,MAAO,cAAI,KAAK,OAAO,CAAC,EAAE,YAAY,IAAI,IAAI,KAAK,MAAM,CAAC,GAAE;AAAA,IACjF,oBAAC,QAAI,eAAK,eAAe,IAAI,OAAO,GAAE;AAAA,IACtC,oBAAC,QAAI,eAAK,2BAA2B,IAAI,MAAM,GAAE;AAAA,IACjD,oBAAC,QACC,+BAAC,UAAK,WAAW,OAAO,KACtB;AAAA,0BAAC,UAAK,WAAW,GAAG,OAAO,MAAM,IAAI,KAAK,IAAI,MAAM,CAAC,IAAK,eAAK,cAAc,IAAI,MAAM,GAAE;AAAA,MACxF,IAAI,WAAW,YAAY,IAAI,gBAAgB,oBAAC,UAAK,WAAW,OAAO,QAAS,cAAI,eAAc,IAAU;AAAA,OAC/G,GACF;AAAA,IACA,oBAAC,QAAG,WAAW,MAAM,WAAY,uBAAa,IAAI,YAAY,KAAK,GAAE;AAAA,KACvE;AAEJ;","names":[]}
@@ -16,7 +16,6 @@ import { EmptyState, Panel } from "../_ui";
16
16
  import { EmptyBlock } from "../audit/empty-block";
17
17
  import { SectionHeader } from "../audit/section-header";
18
18
  import { RolesKpis } from "./roles-kpis";
19
- import { RoleRequests } from "./role-requests";
20
19
  import { RoleDialog } from "./role-dialog";
21
20
  import { RoleRow, ROLE_COLUMNS, adminCount } from "./role-row";
22
21
  import section from "../audit/page.module.css";
@@ -48,14 +47,11 @@ function RolesPage() {
48
47
  const remove = useMutation({
49
48
  mutationFn: (r) => platformAdminApi.deleteRole(r.id),
50
49
  onSuccess: (_, r) => {
51
- toast({
52
- title: "Sent for approval",
53
- description: `Another super admin has to approve deleting ${r.name}.`
54
- });
50
+ toast({ title: `${r.name} deleted` });
55
51
  void invalidate();
56
52
  },
57
53
  onError: (error) => toast({
58
- title: "The deletion was not sent",
54
+ title: "The role was not deleted",
59
55
  description: error.message,
60
56
  variant: "destructive"
61
57
  })
@@ -64,7 +60,7 @@ function RolesPage() {
64
60
  const ok = await confirm({
65
61
  title: `Delete ${r.name}?`,
66
62
  description: r.assignedCount > 0 ? `${adminCount(r.assignedCount)} hold this role and will lose the access it grants. This can\u2019t be undone.` : "No admin holds this role. This can\u2019t be undone.",
67
- confirmLabel: "Ask to delete",
63
+ confirmLabel: "Delete role",
68
64
  tone: "danger"
69
65
  });
70
66
  if (ok) remove.mutate(r);
@@ -146,19 +142,13 @@ function RolesPage() {
146
142
  children: table
147
143
  }
148
144
  ) }),
149
- /* @__PURE__ */ jsx(RoleRequests, {}),
150
145
  /* @__PURE__ */ jsx(
151
146
  RoleDialog,
152
147
  {
153
148
  mode: dialog,
154
149
  catalog: availablePermissions,
155
150
  onClose: () => setDialog(null),
156
- onSaved: () => {
157
- void invalidate();
158
- void queryClient.invalidateQueries({
159
- queryKey: ["platform-admin", "roles", "requests"]
160
- });
161
- }
151
+ onSaved: () => void invalidate()
162
152
  }
163
153
  )
164
154
  ] });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/admin/roles/page.tsx"],"sourcesContent":["'use client';\n\nimport { useMemo, useState } from 'react';\nimport { useMutation, useQueryClient } from '@tanstack/react-query';\nimport {\n Button,\n TableShell,\n TableSkeleton,\n Th,\n useConfirm,\n useToast,\n} from '@withpotter/ui-kit';\nimport { platformAdminApi, type RoleRow as Role } from '@withpotter/lib/admin-api';\nimport { useAdminRoles, useAdminPermissions } from '../hooks';\nimport { EmptyState, Panel } from '../_ui';\nimport { EmptyBlock } from '../audit/empty-block';\nimport { SectionHeader } from '../audit/section-header';\nimport { RolesKpis } from './roles-kpis';\nimport { RoleRequests } from './role-requests';\nimport { RoleDialog, type RoleDialogMode } from './role-dialog';\nimport { RoleRow, ROLE_COLUMNS, adminCount } from './role-row';\nimport section from '../audit/page.module.css';\nimport kpi from '../audit/kpi-brand.module.css';\nimport styles from './roles.module.css';\n\nconst CONTROLLED_PERMISSIONS = [\n 'money.cash.propose',\n 'money.cash.approve',\n 'money.payout_tiers.read',\n 'money.payout_tiers.manage',\n 'pricing.read',\n 'pricing.propose',\n 'pricing.approve',\n 'sales.read',\n 'sales.manage',\n] as const;\n\nexport default function RolesPage() {\n const queryClient = useQueryClient();\n const confirm = useConfirm();\n const { toast } = useToast();\n const [dialog, setDialog] = useState<RoleDialogMode | null>(null);\n const { data: roles = [], isLoading, isError, refetch } = useAdminRoles();\n const { data: catalog = [] } = useAdminPermissions();\n const availablePermissions = useMemo(\n () => Array.from(new Set([...catalog, ...CONTROLLED_PERMISSIONS])),\n [catalog],\n );\n\n const invalidate = () =>\n queryClient.invalidateQueries({ queryKey: ['platform-admin', 'roles'] });\n\n const remove = useMutation({\n mutationFn: (r: Role) => platformAdminApi.deleteRole(r.id),\n onSuccess: (_, r) => {\n toast({\n title: 'Sent for approval',\n description: `Another super admin has to approve deleting ${r.name}.`,\n });\n void invalidate();\n },\n onError: (error) =>\n toast({\n title: 'The deletion was not sent',\n description: error.message,\n variant: 'destructive',\n }),\n });\n\n const confirmDelete = async (r: Role) => {\n const ok = await confirm({\n title: `Delete ${r.name}?`,\n description:\n r.assignedCount > 0\n ? `${adminCount(r.assignedCount)} hold this role and will lose the access it grants. This can’t be undone.`\n : 'No admin holds this role. This can’t be undone.',\n confirmLabel: 'Ask to delete',\n tone: 'danger',\n });\n if (ok) remove.mutate(r);\n };\n\n const create = (\n <Button onClick={() => setDialog({ kind: 'create' })}>Create role</Button>\n );\n\n let table;\n if (isLoading) {\n table = (\n <div aria-hidden>\n <TableSkeleton bare rows={5} columns={ROLE_COLUMNS} />\n </div>\n );\n } else if (isError) {\n table = (\n <EmptyState\n tone=\"error\"\n title=\"Roles could not be loaded\"\n description=\"Try again. If this keeps happening, contact engineering.\"\n action={\n <Button size=\"sm\" variant=\"secondary\" onClick={() => void refetch()}>\n Try again\n </Button>\n }\n />\n );\n } else if (roles.length === 0) {\n table = (\n <EmptyBlock\n message=\"No roles yet. A role bundles permissions so you can grant them in one step.\"\n action={create}\n />\n );\n } else {\n table = (\n <TableShell\n bare\n fixed\n minWidth={760}\n density=\"compact\"\n head={\n <tr>\n <Th className={styles.colRole}>Role</Th>\n <Th>Permissions</Th>\n <Th className={styles.colType}>Type</Th>\n <Th className={styles.colHeld} align=\"right\">\n Held by\n </Th>\n <Th className={styles.colMenu} align=\"right\">\n <span className=\"sr-only\">Actions</span>\n </Th>\n </tr>\n }\n >\n {roles.map((r) => (\n <RoleRow\n key={r.id}\n r={r}\n onEdit={() => setDialog({ kind: 'edit', role: r })}\n onDuplicate={() => setDialog({ kind: 'duplicate', role: r })}\n onDelete={() => void confirmDelete(r)}\n />\n ))}\n </TableShell>\n );\n }\n\n return (\n <div className={styles.page}>\n <SectionHeader\n title=\"Roles\"\n description=\"Reusable permission bundles. An admin’s access is the union of every role they hold.\"\n actions={create}\n />\n\n <div className={kpi.kpis}>\n <RolesKpis\n roles={roles}\n permissions={availablePermissions.length}\n isLoading={isLoading}\n />\n </div>\n\n <div className={section.section} aria-busy={isLoading || undefined}>\n <Panel\n title=\"All roles\"\n count={isLoading ? undefined : roles.length}\n height=\"auto\"\n flush\n >\n {table}\n </Panel>\n </div>\n\n <RoleRequests />\n\n <RoleDialog\n mode={dialog}\n catalog={availablePermissions}\n onClose={() => setDialog(null)}\n onSaved={() => {\n void invalidate();\n void queryClient.invalidateQueries({\n queryKey: ['platform-admin', 'roles', 'requests'],\n });\n }}\n />\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/admin/roles/page.tsx"],"sourcesContent":["'use client';\n\nimport { useMemo, useState } from 'react';\nimport { useMutation, useQueryClient } from '@tanstack/react-query';\nimport {\n Button,\n TableShell,\n TableSkeleton,\n Th,\n useConfirm,\n useToast,\n} from '@withpotter/ui-kit';\nimport { platformAdminApi, type RoleRow as Role } from '@withpotter/lib/admin-api';\nimport { useAdminRoles, useAdminPermissions } from '../hooks';\nimport { EmptyState, Panel } from '../_ui';\nimport { EmptyBlock } from '../audit/empty-block';\nimport { SectionHeader } from '../audit/section-header';\nimport { RolesKpis } from './roles-kpis';\nimport { RoleDialog, type RoleDialogMode } from './role-dialog';\nimport { RoleRow, ROLE_COLUMNS, adminCount } from './role-row';\nimport section from '../audit/page.module.css';\nimport kpi from '../audit/kpi-brand.module.css';\nimport styles from './roles.module.css';\n\nconst CONTROLLED_PERMISSIONS = [\n 'money.cash.propose',\n 'money.cash.approve',\n 'money.payout_tiers.read',\n 'money.payout_tiers.manage',\n 'pricing.read',\n 'pricing.propose',\n 'pricing.approve',\n 'sales.read',\n 'sales.manage',\n] as const;\n\nexport default function RolesPage() {\n const queryClient = useQueryClient();\n const confirm = useConfirm();\n const { toast } = useToast();\n const [dialog, setDialog] = useState<RoleDialogMode | null>(null);\n const { data: roles = [], isLoading, isError, refetch } = useAdminRoles();\n const { data: catalog = [] } = useAdminPermissions();\n const availablePermissions = useMemo(\n () => Array.from(new Set([...catalog, ...CONTROLLED_PERMISSIONS])),\n [catalog],\n );\n\n const invalidate = () =>\n queryClient.invalidateQueries({ queryKey: ['platform-admin', 'roles'] });\n\n const remove = useMutation({\n mutationFn: (r: Role) => platformAdminApi.deleteRole(r.id),\n onSuccess: (_, r) => {\n toast({ title: `${r.name} deleted` });\n void invalidate();\n },\n onError: (error) =>\n toast({\n title: 'The role was not deleted',\n description: error.message,\n variant: 'destructive',\n }),\n });\n\n const confirmDelete = async (r: Role) => {\n const ok = await confirm({\n title: `Delete ${r.name}?`,\n description:\n r.assignedCount > 0\n ? `${adminCount(r.assignedCount)} hold this role and will lose the access it grants. This can’t be undone.`\n : 'No admin holds this role. This can’t be undone.',\n confirmLabel: 'Delete role',\n tone: 'danger',\n });\n if (ok) remove.mutate(r);\n };\n\n const create = (\n <Button onClick={() => setDialog({ kind: 'create' })}>Create role</Button>\n );\n\n let table;\n if (isLoading) {\n table = (\n <div aria-hidden>\n <TableSkeleton bare rows={5} columns={ROLE_COLUMNS} />\n </div>\n );\n } else if (isError) {\n table = (\n <EmptyState\n tone=\"error\"\n title=\"Roles could not be loaded\"\n description=\"Try again. If this keeps happening, contact engineering.\"\n action={\n <Button size=\"sm\" variant=\"secondary\" onClick={() => void refetch()}>\n Try again\n </Button>\n }\n />\n );\n } else if (roles.length === 0) {\n table = (\n <EmptyBlock\n message=\"No roles yet. A role bundles permissions so you can grant them in one step.\"\n action={create}\n />\n );\n } else {\n table = (\n <TableShell\n bare\n fixed\n minWidth={760}\n density=\"compact\"\n head={\n <tr>\n <Th className={styles.colRole}>Role</Th>\n <Th>Permissions</Th>\n <Th className={styles.colType}>Type</Th>\n <Th className={styles.colHeld} align=\"right\">\n Held by\n </Th>\n <Th className={styles.colMenu} align=\"right\">\n <span className=\"sr-only\">Actions</span>\n </Th>\n </tr>\n }\n >\n {roles.map((r) => (\n <RoleRow\n key={r.id}\n r={r}\n onEdit={() => setDialog({ kind: 'edit', role: r })}\n onDuplicate={() => setDialog({ kind: 'duplicate', role: r })}\n onDelete={() => void confirmDelete(r)}\n />\n ))}\n </TableShell>\n );\n }\n\n return (\n <div className={styles.page}>\n <SectionHeader\n title=\"Roles\"\n description=\"Reusable permission bundles. An admin’s access is the union of every role they hold.\"\n actions={create}\n />\n\n <div className={kpi.kpis}>\n <RolesKpis\n roles={roles}\n permissions={availablePermissions.length}\n isLoading={isLoading}\n />\n </div>\n\n <div className={section.section} aria-busy={isLoading || undefined}>\n <Panel\n title=\"All roles\"\n count={isLoading ? undefined : roles.length}\n height=\"auto\"\n flush\n >\n {table}\n </Panel>\n </div>\n\n <RoleDialog\n mode={dialog}\n catalog={availablePermissions}\n onClose={() => setDialog(null)}\n onSaved={() => void invalidate()}\n />\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -66,8 +66,7 @@ function RoleForm({
66
66
  ),
67
67
  onSuccess: () => {
68
68
  toast({
69
- title: "Sent for approval",
70
- description: "Another super admin has to approve this before the role changes."
69
+ title: mode.kind === "edit" ? `${name} updated` : `${name.trim()} created`
71
70
  });
72
71
  onSaved();
73
72
  onClose();
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/admin/roles/role-dialog.tsx"],"sourcesContent":["'use client';\n\nimport { useState } from 'react';\nimport { useMutation } from '@tanstack/react-query';\nimport { Button, FormDialog, Input, useToast } from '@withpotter/ui-kit';\nimport { platformAdminApi, type RoleRow as Role } from '@withpotter/lib/admin-api';\nimport { PermissionPicker, toggleIn } from './permission-picker';\nimport styles from './roles.module.css';\n\nexport type RoleDialogMode =\n | { kind: 'create' }\n | { kind: 'edit'; role: Role }\n | { kind: 'duplicate'; role: Role };\n\nconst COPY = {\n create: { title: 'Create role', action: 'Create role' },\n edit: { title: 'Edit role', action: 'Save changes' },\n duplicate: { title: 'Duplicate role', action: 'Create copy' },\n} as const;\n\nconst FORM_ID = 'role-dialog-form';\n\n/** Create, edit or duplicate a role: name, purpose and grouped permissions. */\nexport function RoleDialog({\n mode,\n catalog,\n onClose,\n onSaved,\n}: {\n mode: RoleDialogMode | null;\n catalog: readonly string[];\n onClose: () => void;\n onSaved: () => void;\n}) {\n // Keep the last mode while the dialog animates out, and count each opening\n // so the form remounts fresh — a reopened Create must not show the last\n // role's name, nor a cancelled edit come back.\n const [shown, setShown] = useState(mode);\n const [opens, setOpens] = useState(0);\n const [wasOpen, setWasOpen] = useState(false);\n if (mode && mode !== shown) setShown(mode);\n if (Boolean(mode) !== wasOpen) {\n setWasOpen(Boolean(mode));\n if (mode) setOpens((n) => n + 1);\n }\n const active = mode ?? shown;\n\n if (!active) return null;\n return (\n <RoleForm\n key={`${opens}-${active.kind}-${'role' in active ? active.role.id : ''}`}\n open={mode !== null}\n mode={active}\n catalog={catalog}\n onClose={onClose}\n onSaved={onSaved}\n />\n );\n}\n\nfunction RoleForm({\n open,\n mode,\n catalog,\n onClose,\n onSaved,\n}: {\n open: boolean;\n mode: RoleDialogMode;\n catalog: readonly string[];\n onClose: () => void;\n onSaved: () => void;\n}) {\n const { toast } = useToast();\n const source = mode.kind === 'create' ? null : mode.role;\n const [name, setName] = useState(\n !source\n ? ''\n : mode.kind === 'duplicate'\n ? `${source.name} (copy)`\n : source.name,\n );\n const [description, setDescription] = useState(source?.description ?? '');\n const [perms, setPerms] = useState<string[]>(source?.permissions ?? []);\n const lockedName = mode.kind === 'edit' && mode.role.isSystem;\n\n const save = useMutation({\n mutationFn: async (): Promise<unknown> =>\n mode.kind === 'edit'\n ? platformAdminApi.updateRole(mode.role.id, {\n ...(lockedName\n ? {}\n : { name: name.trim(), description: description.trim() }),\n permissions: perms,\n })\n : platformAdminApi.createRole(\n name.trim(),\n perms,\n description.trim() || undefined,\n ),\n onSuccess: () => {\n toast({\n title: 'Sent for approval',\n description:\n 'Another super admin has to approve this before the role changes.',\n });\n onSaved();\n onClose();\n },\n onError: (error) =>\n toast({\n title: 'The role was not saved',\n description: error.message,\n variant: 'destructive',\n }),\n });\n const canSave = name.trim().length > 0 && !save.isPending;\n\n return (\n <FormDialog\n open={open}\n size=\"lg\"\n title={COPY[mode.kind].title}\n description={\n mode.kind === 'edit'\n ? `Changes apply to every admin holding ${mode.role.name}.`\n : 'Bundle permissions you can grant in one step.'\n }\n onClose={onClose}\n footer={\n <>\n <span className={styles.footNote}>\n {perms.length} permission{perms.length === 1 ? '' : 's'} selected\n </span>\n <div className={styles.footActions}>\n <Button type=\"button\" variant=\"secondary\" onClick={onClose}>\n Cancel\n </Button>\n <Button\n type=\"submit\"\n form={FORM_ID}\n disabled={!canSave}\n loading={save.isPending}\n >\n {COPY[mode.kind].action}\n </Button>\n </div>\n </>\n }\n >\n <form\n id={FORM_ID}\n className={styles.dialogBody}\n onSubmit={(e) => {\n e.preventDefault();\n if (canSave) save.mutate();\n }}\n >\n <div className={styles.dialogFields}>\n <Input\n label=\"Role name\"\n placeholder=\"e.g. Support\"\n value={name}\n disabled={lockedName}\n onChange={(e) => setName(e.target.value)}\n autoFocus={!lockedName}\n />\n <Input\n label=\"Description (optional)\"\n placeholder=\"What this role is for\"\n value={description}\n disabled={lockedName}\n onChange={(e) => setDescription(e.target.value)}\n />\n </div>\n {lockedName && (\n <p className={styles.note}>\n Built-in roles keep their name. You can still change what they\n grant.\n </p>\n )}\n <PermissionPicker\n keys={catalog}\n value={perms}\n onToggle={(key) => setPerms(toggleIn(key))}\n onSet={setPerms}\n />\n </form>\n </FormDialog>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/admin/roles/role-dialog.tsx"],"sourcesContent":["'use client';\n\nimport { useState } from 'react';\nimport { useMutation } from '@tanstack/react-query';\nimport { Button, FormDialog, Input, useToast } from '@withpotter/ui-kit';\nimport { platformAdminApi, type RoleRow as Role } from '@withpotter/lib/admin-api';\nimport { PermissionPicker, toggleIn } from './permission-picker';\nimport styles from './roles.module.css';\n\nexport type RoleDialogMode =\n | { kind: 'create' }\n | { kind: 'edit'; role: Role }\n | { kind: 'duplicate'; role: Role };\n\nconst COPY = {\n create: { title: 'Create role', action: 'Create role' },\n edit: { title: 'Edit role', action: 'Save changes' },\n duplicate: { title: 'Duplicate role', action: 'Create copy' },\n} as const;\n\nconst FORM_ID = 'role-dialog-form';\n\n/** Create, edit or duplicate a role: name, purpose and grouped permissions. */\nexport function RoleDialog({\n mode,\n catalog,\n onClose,\n onSaved,\n}: {\n mode: RoleDialogMode | null;\n catalog: readonly string[];\n onClose: () => void;\n onSaved: () => void;\n}) {\n // Keep the last mode while the dialog animates out, and count each opening\n // so the form remounts fresh — a reopened Create must not show the last\n // role's name, nor a cancelled edit come back.\n const [shown, setShown] = useState(mode);\n const [opens, setOpens] = useState(0);\n const [wasOpen, setWasOpen] = useState(false);\n if (mode && mode !== shown) setShown(mode);\n if (Boolean(mode) !== wasOpen) {\n setWasOpen(Boolean(mode));\n if (mode) setOpens((n) => n + 1);\n }\n const active = mode ?? shown;\n\n if (!active) return null;\n return (\n <RoleForm\n key={`${opens}-${active.kind}-${'role' in active ? active.role.id : ''}`}\n open={mode !== null}\n mode={active}\n catalog={catalog}\n onClose={onClose}\n onSaved={onSaved}\n />\n );\n}\n\nfunction RoleForm({\n open,\n mode,\n catalog,\n onClose,\n onSaved,\n}: {\n open: boolean;\n mode: RoleDialogMode;\n catalog: readonly string[];\n onClose: () => void;\n onSaved: () => void;\n}) {\n const { toast } = useToast();\n const source = mode.kind === 'create' ? null : mode.role;\n const [name, setName] = useState(\n !source\n ? ''\n : mode.kind === 'duplicate'\n ? `${source.name} (copy)`\n : source.name,\n );\n const [description, setDescription] = useState(source?.description ?? '');\n const [perms, setPerms] = useState<string[]>(source?.permissions ?? []);\n const lockedName = mode.kind === 'edit' && mode.role.isSystem;\n\n const save = useMutation({\n mutationFn: async (): Promise<unknown> =>\n mode.kind === 'edit'\n ? platformAdminApi.updateRole(mode.role.id, {\n ...(lockedName\n ? {}\n : { name: name.trim(), description: description.trim() }),\n permissions: perms,\n })\n : platformAdminApi.createRole(\n name.trim(),\n perms,\n description.trim() || undefined,\n ),\n onSuccess: () => {\n toast({\n title:\n mode.kind === 'edit' ? `${name} updated` : `${name.trim()} created`,\n });\n onSaved();\n onClose();\n },\n onError: (error) =>\n toast({\n title: 'The role was not saved',\n description: error.message,\n variant: 'destructive',\n }),\n });\n const canSave = name.trim().length > 0 && !save.isPending;\n\n return (\n <FormDialog\n open={open}\n size=\"lg\"\n title={COPY[mode.kind].title}\n description={\n mode.kind === 'edit'\n ? `Changes apply to every admin holding ${mode.role.name}.`\n : 'Bundle permissions you can grant in one step.'\n }\n onClose={onClose}\n footer={\n <>\n <span className={styles.footNote}>\n {perms.length} permission{perms.length === 1 ? '' : 's'} selected\n </span>\n <div className={styles.footActions}>\n <Button type=\"button\" variant=\"secondary\" onClick={onClose}>\n Cancel\n </Button>\n <Button\n type=\"submit\"\n form={FORM_ID}\n disabled={!canSave}\n loading={save.isPending}\n >\n {COPY[mode.kind].action}\n </Button>\n </div>\n </>\n }\n >\n <form\n id={FORM_ID}\n className={styles.dialogBody}\n onSubmit={(e) => {\n e.preventDefault();\n if (canSave) save.mutate();\n }}\n >\n <div className={styles.dialogFields}>\n <Input\n label=\"Role name\"\n placeholder=\"e.g. Support\"\n value={name}\n disabled={lockedName}\n onChange={(e) => setName(e.target.value)}\n autoFocus={!lockedName}\n />\n <Input\n label=\"Description (optional)\"\n placeholder=\"What this role is for\"\n value={description}\n disabled={lockedName}\n onChange={(e) => setDescription(e.target.value)}\n />\n </div>\n {lockedName && (\n <p className={styles.note}>\n Built-in roles keep their name. You can still change what they\n grant.\n </p>\n )}\n <PermissionPicker\n keys={catalog}\n value={perms}\n onToggle={(key) => setPerms(toggleIn(key))}\n onSet={setPerms}\n />\n </form>\n </FormDialog>\n );\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@withpotter/pages-admin",
3
- "version": "0.13.0-next.119",
3
+ "version": "0.13.0-next.122",
4
4
  "description": "Box console platform-admin routes: overview, revenue, accounts, tenants, admins, roles, audit.",
5
5
  "license": "UNLICENSED",
6
6
  "sideEffects": false,
@@ -31,9 +31,9 @@
31
31
  "dependencies": {
32
32
  "@radix-ui/react-popover": "^1.1.23",
33
33
  "@tanstack/react-query": "^5.90.20",
34
- "@withpotter/lib": "^0.13.0-next.119",
35
- "@withpotter/platform-admin": "^0.13.0-next.119",
36
- "@withpotter/ui-kit": "^0.13.0-next.119",
34
+ "@withpotter/lib": "^0.13.0-next.122",
35
+ "@withpotter/platform-admin": "^0.13.0-next.122",
36
+ "@withpotter/ui-kit": "^0.13.0-next.122",
37
37
  "lucide-react": "^0.562.0",
38
38
  "motion": "^12.42.2"
39
39
  },
@@ -1,80 +0,0 @@
1
- "use client";
2
- import { jsx, jsxs } from "react/jsx-runtime";
3
- import { useMutation, useQueryClient } from "@tanstack/react-query";
4
- import { Button, useToast } from "@withpotter/ui-kit";
5
- import { platformAdminApi } from "@withpotter/lib/admin-api";
6
- import { useAdminSession, useRoleChangeRequests } from "../hooks";
7
- import { Panel } from "../_ui";
8
- import section from "../audit/page.module.css";
9
- function RoleRequests() {
10
- const queryClient = useQueryClient();
11
- const { toast } = useToast();
12
- const { data: me } = useAdminSession();
13
- const { data: requests = [] } = useRoleChangeRequests();
14
- const pending = requests.filter((r) => r.status === "pending");
15
- const decided = requests.filter((r) => r.status !== "pending").slice(0, 5);
16
- const refresh = () => {
17
- void queryClient.invalidateQueries({ queryKey: ["platform-admin", "roles"] });
18
- void queryClient.invalidateQueries({ queryKey: ["platform-admin", "admins"] });
19
- };
20
- const decide = useMutation({
21
- mutationFn: ({ id, approve }) => approve ? platformAdminApi.approveRoleChange(id) : platformAdminApi.rejectRoleChange(id),
22
- onSuccess: (_, { approve }) => {
23
- toast({ title: approve ? "Approved and applied" : "Rejected" });
24
- refresh();
25
- },
26
- onError: (error) => toast({ title: "That was not saved", description: error.message, variant: "destructive" })
27
- });
28
- if (requests.length === 0) return null;
29
- return /* @__PURE__ */ jsx("div", { className: section.section, children: /* @__PURE__ */ jsxs(Panel, { title: "Waiting for approval", count: pending.length, height: "auto", flush: true, children: [
30
- /* @__PURE__ */ jsxs("ul", { children: [
31
- pending.map((r) => {
32
- const mine = me?.email === r.requestedByEmail;
33
- return /* @__PURE__ */ jsxs("li", { className: "flex items-center justify-between gap-3 px-4 py-3 text-sm", children: [
34
- /* @__PURE__ */ jsxs("span", { className: "min-w-0", children: [
35
- r.summary,
36
- /* @__PURE__ */ jsxs("span", { className: "block text-xs text-[var(--text-tertiary)]", children: [
37
- "Asked by ",
38
- mine ? "you" : r.requestedByEmail,
39
- " \xB7 ",
40
- new Date(r.createdAt).toLocaleString()
41
- ] })
42
- ] }),
43
- /* @__PURE__ */ jsxs("span", { className: "flex shrink-0 gap-2", children: [
44
- !mine && /* @__PURE__ */ jsx(
45
- Button,
46
- {
47
- size: "sm",
48
- disabled: decide.isPending,
49
- onClick: () => decide.mutate({ id: r.id, approve: true }),
50
- children: "Approve"
51
- }
52
- ),
53
- /* @__PURE__ */ jsx(
54
- Button,
55
- {
56
- size: "sm",
57
- variant: "secondary",
58
- disabled: decide.isPending,
59
- onClick: () => decide.mutate({ id: r.id, approve: false }),
60
- children: mine ? "Withdraw" : "Reject"
61
- }
62
- )
63
- ] })
64
- ] }, r.id);
65
- }),
66
- pending.length === 0 && /* @__PURE__ */ jsx("li", { className: "px-4 py-3 text-sm text-[var(--text-secondary)]", children: "Nothing is waiting." })
67
- ] }),
68
- decided.length > 0 && /* @__PURE__ */ jsx("ul", { className: "border-t border-[var(--border-subtle)]", children: decided.map((r) => /* @__PURE__ */ jsxs("li", { className: "px-4 py-2 text-xs text-[var(--text-secondary)]", children: [
69
- r.summary,
70
- " \xB7 ",
71
- r.status === "approved" ? "approved" : "rejected",
72
- " by ",
73
- r.decidedByEmail
74
- ] }, r.id)) })
75
- ] }) });
76
- }
77
- export {
78
- RoleRequests
79
- };
80
- //# sourceMappingURL=role-requests.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../../../src/admin/roles/role-requests.tsx"],"sourcesContent":["'use client';\n\nimport { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { Button, useToast } from '@withpotter/ui-kit';\nimport { platformAdminApi } from '@withpotter/lib/admin-api';\nimport { useAdminSession, useRoleChangeRequests } from '../hooks';\nimport { Panel } from '../_ui';\nimport section from '../audit/page.module.css';\n\n/**\n * Role changes wait here for a second super admin. You can approve or reject what others\n * asked for, and withdraw your own, but never approve your own.\n */\nexport function RoleRequests() {\n const queryClient = useQueryClient();\n const { toast } = useToast();\n const { data: me } = useAdminSession();\n const { data: requests = [] } = useRoleChangeRequests();\n const pending = requests.filter((r) => r.status === 'pending');\n const decided = requests.filter((r) => r.status !== 'pending').slice(0, 5);\n\n const refresh = () => {\n void queryClient.invalidateQueries({ queryKey: ['platform-admin', 'roles'] });\n void queryClient.invalidateQueries({ queryKey: ['platform-admin', 'admins'] });\n };\n const decide = useMutation({\n mutationFn: ({ id, approve }: { id: string; approve: boolean }) =>\n approve ? platformAdminApi.approveRoleChange(id) : platformAdminApi.rejectRoleChange(id),\n onSuccess: (_, { approve }) => {\n toast({ title: approve ? 'Approved and applied' : 'Rejected' });\n refresh();\n },\n onError: (error) =>\n toast({ title: 'That was not saved', description: error.message, variant: 'destructive' }),\n });\n\n if (requests.length === 0) return null;\n\n return (\n <div className={section.section}>\n <Panel title=\"Waiting for approval\" count={pending.length} height=\"auto\" flush>\n <ul>\n {pending.map((r) => {\n const mine = me?.email === r.requestedByEmail;\n return (\n <li key={r.id} className=\"flex items-center justify-between gap-3 px-4 py-3 text-sm\">\n <span className=\"min-w-0\">\n {r.summary}\n <span className=\"block text-xs text-[var(--text-tertiary)]\">\n Asked by {mine ? 'you' : r.requestedByEmail} · {new Date(r.createdAt).toLocaleString()}\n </span>\n </span>\n <span className=\"flex shrink-0 gap-2\">\n {!mine && (\n <Button\n size=\"sm\"\n disabled={decide.isPending}\n onClick={() => decide.mutate({ id: r.id, approve: true })}\n >\n Approve\n </Button>\n )}\n <Button\n size=\"sm\"\n variant=\"secondary\"\n disabled={decide.isPending}\n onClick={() => decide.mutate({ id: r.id, approve: false })}\n >\n {mine ? 'Withdraw' : 'Reject'}\n </Button>\n </span>\n </li>\n );\n })}\n {pending.length === 0 && (\n <li className=\"px-4 py-3 text-sm text-[var(--text-secondary)]\">Nothing is waiting.</li>\n )}\n </ul>\n {decided.length > 0 && (\n <ul className=\"border-t border-[var(--border-subtle)]\">\n {decided.map((r) => (\n <li key={r.id} className=\"px-4 py-2 text-xs text-[var(--text-secondary)]\">\n {r.summary} · {r.status === 'approved' ? 'approved' : 'rejected'} by {r.decidedByEmail}\n </li>\n ))}\n </ul>\n )}\n </Panel>\n </div>\n );\n}\n"],"mappings":";AAgDkB,SAME,KANF;AA9ClB,SAAS,aAAa,sBAAsB;AAC5C,SAAS,QAAQ,gBAAgB;AACjC,SAAS,wBAAwB;AACjC,SAAS,iBAAiB,6BAA6B;AACvD,SAAS,aAAa;AACtB,OAAO,aAAa;AAMb,SAAS,eAAe;AAC7B,QAAM,cAAc,eAAe;AACnC,QAAM,EAAE,MAAM,IAAI,SAAS;AAC3B,QAAM,EAAE,MAAM,GAAG,IAAI,gBAAgB;AACrC,QAAM,EAAE,MAAM,WAAW,CAAC,EAAE,IAAI,sBAAsB;AACtD,QAAM,UAAU,SAAS,OAAO,CAAC,MAAM,EAAE,WAAW,SAAS;AAC7D,QAAM,UAAU,SAAS,OAAO,CAAC,MAAM,EAAE,WAAW,SAAS,EAAE,MAAM,GAAG,CAAC;AAEzE,QAAM,UAAU,MAAM;AACpB,SAAK,YAAY,kBAAkB,EAAE,UAAU,CAAC,kBAAkB,OAAO,EAAE,CAAC;AAC5E,SAAK,YAAY,kBAAkB,EAAE,UAAU,CAAC,kBAAkB,QAAQ,EAAE,CAAC;AAAA,EAC/E;AACA,QAAM,SAAS,YAAY;AAAA,IACzB,YAAY,CAAC,EAAE,IAAI,QAAQ,MACzB,UAAU,iBAAiB,kBAAkB,EAAE,IAAI,iBAAiB,iBAAiB,EAAE;AAAA,IACzF,WAAW,CAAC,GAAG,EAAE,QAAQ,MAAM;AAC7B,YAAM,EAAE,OAAO,UAAU,yBAAyB,WAAW,CAAC;AAC9D,cAAQ;AAAA,IACV;AAAA,IACA,SAAS,CAAC,UACR,MAAM,EAAE,OAAO,sBAAsB,aAAa,MAAM,SAAS,SAAS,cAAc,CAAC;AAAA,EAC7F,CAAC;AAED,MAAI,SAAS,WAAW,EAAG,QAAO;AAElC,SACE,oBAAC,SAAI,WAAW,QAAQ,SACtB,+BAAC,SAAM,OAAM,wBAAuB,OAAO,QAAQ,QAAQ,QAAO,QAAO,OAAK,MAC5E;AAAA,yBAAC,QACE;AAAA,cAAQ,IAAI,CAAC,MAAM;AAClB,cAAM,OAAO,IAAI,UAAU,EAAE;AAC7B,eACE,qBAAC,QAAc,WAAU,6DACvB;AAAA,+BAAC,UAAK,WAAU,WACb;AAAA,cAAE;AAAA,YACH,qBAAC,UAAK,WAAU,6CAA4C;AAAA;AAAA,cAChD,OAAO,QAAQ,EAAE;AAAA,cAAiB;AAAA,cAAI,IAAI,KAAK,EAAE,SAAS,EAAE,eAAe;AAAA,eACvF;AAAA,aACF;AAAA,UACA,qBAAC,UAAK,WAAU,uBACb;AAAA,aAAC,QACA;AAAA,cAAC;AAAA;AAAA,gBACC,MAAK;AAAA,gBACL,UAAU,OAAO;AAAA,gBACjB,SAAS,MAAM,OAAO,OAAO,EAAE,IAAI,EAAE,IAAI,SAAS,KAAK,CAAC;AAAA,gBACzD;AAAA;AAAA,YAED;AAAA,YAEF;AAAA,cAAC;AAAA;AAAA,gBACC,MAAK;AAAA,gBACL,SAAQ;AAAA,gBACR,UAAU,OAAO;AAAA,gBACjB,SAAS,MAAM,OAAO,OAAO,EAAE,IAAI,EAAE,IAAI,SAAS,MAAM,CAAC;AAAA,gBAExD,iBAAO,aAAa;AAAA;AAAA,YACvB;AAAA,aACF;AAAA,aAzBO,EAAE,EA0BX;AAAA,MAEJ,CAAC;AAAA,MACA,QAAQ,WAAW,KAClB,oBAAC,QAAG,WAAU,kDAAiD,iCAAmB;AAAA,OAEtF;AAAA,IACC,QAAQ,SAAS,KAChB,oBAAC,QAAG,WAAU,0CACX,kBAAQ,IAAI,CAAC,MACZ,qBAAC,QAAc,WAAU,kDACtB;AAAA,QAAE;AAAA,MAAQ;AAAA,MAAI,EAAE,WAAW,aAAa,aAAa;AAAA,MAAW;AAAA,MAAK,EAAE;AAAA,SADjE,EAAE,EAEX,CACD,GACH;AAAA,KAEJ,GACF;AAEJ;","names":[]}