@withpotter/pages-admin 0.13.0-next.120 → 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.
- package/dist/admin/_ui/admin.css +7 -0
- package/dist/admin/_ui/stat-card.js +11 -2
- package/dist/admin/_ui/stat-card.js.map +1 -1
- package/dist/admin/admins/access-dialog.js +2 -7
- package/dist/admin/admins/access-dialog.js.map +1 -1
- package/dist/admin/admins/invite-dialog.js +0 -6
- package/dist/admin/admins/invite-dialog.js.map +1 -1
- package/dist/admin/hooks.js +0 -7
- package/dist/admin/hooks.js.map +1 -1
- package/dist/admin/revenue/earnings/earnings-colors.js +23 -0
- package/dist/admin/revenue/earnings/earnings-colors.js.map +1 -0
- package/dist/admin/revenue/earnings/earnings-legend.js +38 -0
- package/dist/admin/revenue/earnings/earnings-legend.js.map +1 -0
- package/dist/admin/revenue/earnings/earnings-summary.js +194 -0
- package/dist/admin/revenue/earnings/earnings-summary.js.map +1 -0
- package/dist/admin/revenue/earnings/earnings-table.js +141 -0
- package/dist/admin/revenue/earnings/earnings-table.js.map +1 -0
- package/dist/admin/revenue/earnings/earnings.module.css +118 -0
- package/dist/admin/revenue/earnings/use-earnings.js +27 -0
- package/dist/admin/revenue/earnings/use-earnings.js.map +1 -0
- package/dist/admin/revenue/health-panel.js +5 -4
- package/dist/admin/revenue/health-panel.js.map +1 -1
- package/dist/admin/revenue/revenue-kpis.js +1 -1
- package/dist/admin/revenue/revenue-kpis.js.map +1 -1
- package/dist/admin/revenue/revenue-overview.js +18 -1
- package/dist/admin/revenue/revenue-overview.js.map +1 -1
- package/dist/admin/revenue/revenue-workspace.js +2 -0
- package/dist/admin/revenue/revenue-workspace.js.map +1 -1
- package/dist/admin/roles/page.js +4 -14
- package/dist/admin/roles/page.js.map +1 -1
- package/dist/admin/roles/role-dialog.js +1 -2
- package/dist/admin/roles/role-dialog.js.map +1 -1
- package/package.json +4 -4
- package/dist/admin/roles/role-requests.js +0 -80
- package/dist/admin/roles/role-requests.js.map +0 -1
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import { EmptyNote, TableRow, TableShell, Th } from "@withpotter/ui-kit";
|
|
6
|
+
import { adminEarningsApi } from "@withpotter/lib/admin-earnings-api";
|
|
7
|
+
import { formatMinor } from "@withpotter/lib/money-minor";
|
|
8
|
+
import { useMerchantOptions } from "../../hooks";
|
|
9
|
+
import { LoadFailed } from "../../audit/load-failed";
|
|
10
|
+
import { AdmLoading, FilterSearch, FilterSelect, Filters, Pagination } from "../../_ui";
|
|
11
|
+
import { DownloadButton } from "../download-button";
|
|
12
|
+
import { downloadName } from "../ledger-model";
|
|
13
|
+
import { useUrlFilters } from "../use-url-filters";
|
|
14
|
+
import { useSourceColors } from "./earnings-colors";
|
|
15
|
+
import { useEarningsTransactions } from "./use-earnings";
|
|
16
|
+
import styles from "../ledger.module.css";
|
|
17
|
+
import table from "../revenue.module.css";
|
|
18
|
+
const PAGE_SIZE = 25;
|
|
19
|
+
const FILTERS = { q: "", source: "", merchant: "" };
|
|
20
|
+
const SOURCES = [
|
|
21
|
+
{ value: "", label: "Every source" },
|
|
22
|
+
{ value: "payments", label: "Customer payments" },
|
|
23
|
+
{ value: "payouts", label: "Payouts (money sent out)" },
|
|
24
|
+
{ value: "settlements", label: "Settlements to bank" },
|
|
25
|
+
{ value: "cash", label: "Cash received" },
|
|
26
|
+
{ value: "plans", label: "Plan subscriptions (from wallet)" },
|
|
27
|
+
{ value: "transfers", label: "Wallet transfers" },
|
|
28
|
+
{ value: "fx", label: "Currency conversion" },
|
|
29
|
+
{ value: "lending", label: "Lending" },
|
|
30
|
+
{ value: "other", label: "Other fees" }
|
|
31
|
+
];
|
|
32
|
+
function EarningsTable({ startDate, endDate }) {
|
|
33
|
+
const colors = useSourceColors();
|
|
34
|
+
const { values, page, set } = useUrlFilters("er", FILTERS);
|
|
35
|
+
const [search, setSearch] = useState(values.q);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const timer = setTimeout(() => {
|
|
38
|
+
if (search.trim() !== values.q) set({ q: search.trim() });
|
|
39
|
+
}, 300);
|
|
40
|
+
return () => clearTimeout(timer);
|
|
41
|
+
}, [search]);
|
|
42
|
+
const filters = useMemo(
|
|
43
|
+
() => ({ startDate, endDate, source: values.source, merchantId: values.merchant, q: values.q, page, limit: PAGE_SIZE }),
|
|
44
|
+
[startDate, endDate, values, page]
|
|
45
|
+
);
|
|
46
|
+
const merchants = useMerchantOptions({ startDate, endDate });
|
|
47
|
+
const merchantChoices = [
|
|
48
|
+
{ value: "", label: "Every merchant" },
|
|
49
|
+
...(merchants.data?.merchants ?? []).map((one) => ({ value: one.id, label: one.name }))
|
|
50
|
+
];
|
|
51
|
+
const { data, isLoading, isError, refetch } = useEarningsTransactions(filters);
|
|
52
|
+
if (isError)
|
|
53
|
+
return /* @__PURE__ */ jsx(
|
|
54
|
+
LoadFailed,
|
|
55
|
+
{
|
|
56
|
+
message: "The earnings could not be loaded. This box may not have them yet.",
|
|
57
|
+
onRetry: () => void refetch()
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
if (isLoading || !data) return /* @__PURE__ */ jsx(AdmLoading, { label: "Loading what Olive earned\u2026" });
|
|
61
|
+
return /* @__PURE__ */ jsxs("div", { children: [
|
|
62
|
+
/* @__PURE__ */ jsx("p", { className: styles.sectionNote, children: "Every fee Olive earned in this period, and where it came from. Use the filters to narrow it, and download exactly what you see." }),
|
|
63
|
+
/* @__PURE__ */ jsxs("div", { className: styles.toolbar, children: [
|
|
64
|
+
/* @__PURE__ */ jsxs(Filters, { label: "Filter earnings", children: [
|
|
65
|
+
/* @__PURE__ */ jsx(
|
|
66
|
+
FilterSearch,
|
|
67
|
+
{
|
|
68
|
+
value: search,
|
|
69
|
+
onChange: setSearch,
|
|
70
|
+
placeholder: "Search a merchant, what it was for, or a source",
|
|
71
|
+
label: "Search earnings"
|
|
72
|
+
}
|
|
73
|
+
),
|
|
74
|
+
/* @__PURE__ */ jsx(FilterSelect, { value: values.source, onChange: (source) => set({ source }), options: SOURCES, label: "Source" }),
|
|
75
|
+
/* @__PURE__ */ jsx(
|
|
76
|
+
FilterSelect,
|
|
77
|
+
{
|
|
78
|
+
value: values.merchant,
|
|
79
|
+
onChange: (merchant) => set({ merchant }),
|
|
80
|
+
options: merchantChoices,
|
|
81
|
+
label: "Merchant",
|
|
82
|
+
searchable: true
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
] }),
|
|
86
|
+
/* @__PURE__ */ jsx(
|
|
87
|
+
DownloadButton,
|
|
88
|
+
{
|
|
89
|
+
filename: downloadName("olive-earnings", startDate, endDate),
|
|
90
|
+
load: () => adminEarningsApi.transactionsCsv({ ...filters, page: void 0, limit: void 0 })
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
] }),
|
|
94
|
+
data.rows.length === 0 ? /* @__PURE__ */ jsx(EmptyNote, { className: table.empty, children: "No earnings match. Try a wider date range or clear a filter." }) : /* @__PURE__ */ jsx(
|
|
95
|
+
TableShell,
|
|
96
|
+
{
|
|
97
|
+
minWidth: 960,
|
|
98
|
+
density: "compact",
|
|
99
|
+
head: /* @__PURE__ */ jsxs("tr", { children: [
|
|
100
|
+
/* @__PURE__ */ jsx(Th, { children: "When" }),
|
|
101
|
+
/* @__PURE__ */ jsx(Th, { children: "Source" }),
|
|
102
|
+
/* @__PURE__ */ jsx(Th, { children: "Merchant" }),
|
|
103
|
+
/* @__PURE__ */ jsx(Th, { children: "What it was for" }),
|
|
104
|
+
/* @__PURE__ */ jsx(Th, { align: "right", children: "Olive earned" }),
|
|
105
|
+
/* @__PURE__ */ jsx(Th, { align: "right", children: "Processor cost" }),
|
|
106
|
+
/* @__PURE__ */ jsx(Th, { align: "right", children: "Kept" })
|
|
107
|
+
] }),
|
|
108
|
+
children: data.rows.map((row) => /* @__PURE__ */ jsx(EarningRow, { row, color: colors[row.source] }, `${row.id}-${row.currency}`))
|
|
109
|
+
}
|
|
110
|
+
),
|
|
111
|
+
/* @__PURE__ */ jsx(
|
|
112
|
+
Pagination,
|
|
113
|
+
{
|
|
114
|
+
page,
|
|
115
|
+
totalPages: data.totalPages,
|
|
116
|
+
total: data.total,
|
|
117
|
+
noun: data.total === 1 ? "earning" : "earnings",
|
|
118
|
+
onPage: (next) => set({ page: next })
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
] });
|
|
122
|
+
}
|
|
123
|
+
function EarningRow({ row, color }) {
|
|
124
|
+
const money = (minor) => formatMinor(minor, row.currency);
|
|
125
|
+
return /* @__PURE__ */ jsxs(TableRow, { children: [
|
|
126
|
+
/* @__PURE__ */ jsx("td", { className: table.num, children: new Date(row.at).toLocaleString(void 0, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) }),
|
|
127
|
+
/* @__PURE__ */ jsx("td", { children: /* @__PURE__ */ jsxs("span", { style: { display: "inline-flex", alignItems: "center", gap: 8 }, children: [
|
|
128
|
+
/* @__PURE__ */ jsx("span", { "aria-hidden": true, style: { width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 } }),
|
|
129
|
+
row.sourceLabel
|
|
130
|
+
] }) }),
|
|
131
|
+
/* @__PURE__ */ jsx("td", { className: table.name, children: row.merchant.id && row.merchant.name ? /* @__PURE__ */ jsx(Link, { href: `/admin/tenants/${row.merchant.id}`, children: row.merchant.name }) : "No merchant recorded" }),
|
|
132
|
+
/* @__PURE__ */ jsx("td", { children: row.description }),
|
|
133
|
+
/* @__PURE__ */ jsx("td", { className: table.numStrong, children: money(row.feeMinor) }),
|
|
134
|
+
/* @__PURE__ */ jsx("td", { className: table.num, children: row.providerMinor === "0" ? "\u2013" : money(row.providerMinor) }),
|
|
135
|
+
/* @__PURE__ */ jsx("td", { className: table.num, children: money(row.netMinor) })
|
|
136
|
+
] });
|
|
137
|
+
}
|
|
138
|
+
export {
|
|
139
|
+
EarningsTable
|
|
140
|
+
};
|
|
141
|
+
//# sourceMappingURL=earnings-table.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../src/admin/revenue/earnings/earnings-table.tsx"],"sourcesContent":["'use client';\n\nimport { useEffect, useMemo, useState } from 'react';\nimport Link from 'next/link';\nimport { EmptyNote, TableRow, TableShell, Th } from '@withpotter/ui-kit';\nimport { adminEarningsApi, type EarningsRow } from '@withpotter/lib/admin-earnings-api';\nimport { formatMinor } from '@withpotter/lib/money-minor';\nimport { useMerchantOptions } from '../../hooks';\nimport { LoadFailed } from '../../audit/load-failed';\nimport { AdmLoading, FilterSearch, FilterSelect, Filters, Pagination } from '../../_ui';\nimport { DownloadButton } from '../download-button';\nimport { downloadName } from '../ledger-model';\nimport { useUrlFilters } from '../use-url-filters';\nimport { useSourceColors } from './earnings-colors';\nimport { useEarningsTransactions } from './use-earnings';\nimport styles from '../ledger.module.css';\nimport table from '../revenue.module.css';\n\nconst PAGE_SIZE = 25;\nconst FILTERS = { q: '', source: '', merchant: '' };\n\nconst SOURCES = [\n { value: '', label: 'Every source' },\n { value: 'payments', label: 'Customer payments' },\n { value: 'payouts', label: 'Payouts (money sent out)' },\n { value: 'settlements', label: 'Settlements to bank' },\n { value: 'cash', label: 'Cash received' },\n { value: 'plans', label: 'Plan subscriptions (from wallet)' },\n { value: 'transfers', label: 'Wallet transfers' },\n { value: 'fx', label: 'Currency conversion' },\n { value: 'lending', label: 'Lending' },\n { value: 'other', label: 'Other fees' },\n];\n\n/**\n * Every fee Olive earned, one row each: what it came from, which merchant, what Olive kept and what the processor\n * charged. The filters live in the page address so a view can be shared. Plans paid another way than from a wallet are\n * on the Subscriptions tab.\n */\nexport function EarningsTable({ startDate, endDate }: { startDate?: string; endDate?: string }) {\n const colors = useSourceColors();\n const { values, page, set } = useUrlFilters('er', FILTERS);\n const [search, setSearch] = useState(values.q);\n\n // Typing waits a moment, so a search is one request and not one per key.\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, source: values.source, merchantId: values.merchant, q: values.q, page, limit: PAGE_SIZE }),\n [startDate, endDate, values, page],\n );\n const merchants = useMerchantOptions({ startDate, endDate });\n const merchantChoices = [\n { value: '', label: 'Every merchant' },\n ...(merchants.data?.merchants ?? []).map((one) => ({ value: one.id, label: one.name })),\n ];\n const { data, isLoading, isError, refetch } = useEarningsTransactions(filters);\n\n if (isError)\n return (\n <LoadFailed\n message=\"The earnings could not be loaded. This box may not have them yet.\"\n onRetry={() => void refetch()}\n />\n );\n\n if (isLoading || !data) return <AdmLoading label=\"Loading what Olive earned…\" />;\n\n return (\n <div>\n <p className={styles.sectionNote}>\n Every fee Olive earned in this period, and where it came from. Use the filters to narrow it, and download exactly\n what you see.\n </p>\n <div className={styles.toolbar}>\n <Filters label=\"Filter earnings\">\n <FilterSearch\n value={search}\n onChange={setSearch}\n placeholder=\"Search a merchant, what it was for, or a source\"\n label=\"Search earnings\"\n />\n <FilterSelect value={values.source} onChange={(source) => set({ source })} options={SOURCES} label=\"Source\" />\n <FilterSelect\n value={values.merchant}\n onChange={(merchant) => set({ merchant })}\n options={merchantChoices}\n label=\"Merchant\"\n searchable\n />\n </Filters>\n <DownloadButton\n filename={downloadName('olive-earnings', startDate, endDate)}\n load={() => adminEarningsApi.transactionsCsv({ ...filters, page: undefined, limit: undefined })}\n />\n </div>\n\n {data.rows.length === 0 ? (\n <EmptyNote className={table.empty}>No earnings match. Try a wider date range or clear a filter.</EmptyNote>\n ) : (\n <TableShell\n minWidth={960}\n density=\"compact\"\n head={\n <tr>\n <Th>When</Th>\n <Th>Source</Th>\n <Th>Merchant</Th>\n <Th>What it was for</Th>\n <Th align=\"right\">Olive earned</Th>\n <Th align=\"right\">Processor cost</Th>\n <Th align=\"right\">Kept</Th>\n </tr>\n }\n >\n {data.rows.map((row) => (\n <EarningRow key={`${row.id}-${row.currency}`} row={row} color={colors[row.source]} />\n ))}\n </TableShell>\n )}\n\n <Pagination\n page={page}\n totalPages={data.totalPages}\n total={data.total}\n noun={data.total === 1 ? 'earning' : 'earnings'}\n onPage={(next) => set({ page: next })}\n />\n </div>\n );\n}\n\nfunction EarningRow({ row, color }: { row: EarningsRow; color: string }) {\n const money = (minor: string) => formatMinor(minor, row.currency);\n\n return (\n <TableRow>\n <td className={table.num}>\n {new Date(row.at).toLocaleString(undefined, { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })}\n </td>\n <td>\n <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>\n <span aria-hidden style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />\n {row.sourceLabel}\n </span>\n </td>\n <td className={table.name}>\n {row.merchant.id && row.merchant.name ? (\n <Link href={`/admin/tenants/${row.merchant.id}`}>{row.merchant.name}</Link>\n ) : (\n 'No merchant recorded'\n )}\n </td>\n <td>{row.description}</td>\n <td className={table.numStrong}>{money(row.feeMinor)}</td>\n <td className={table.num}>{row.providerMinor === '0' ? '–' : money(row.providerMinor)}</td>\n <td className={table.num}>{money(row.netMinor)}</td>\n </TableRow>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/* Earnings: a pie with its key, and the ranked list of where fees come from. */
|
|
2
|
+
|
|
3
|
+
.pie {
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-wrap: wrap;
|
|
6
|
+
align-items: center;
|
|
7
|
+
justify-content: center;
|
|
8
|
+
gap: 20px;
|
|
9
|
+
padding: 4px 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.legend {
|
|
13
|
+
display: grid;
|
|
14
|
+
gap: 10px;
|
|
15
|
+
margin: 0;
|
|
16
|
+
padding: 0;
|
|
17
|
+
list-style: none;
|
|
18
|
+
flex: 1 1 200px;
|
|
19
|
+
min-width: 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.legendRow {
|
|
23
|
+
display: grid;
|
|
24
|
+
grid-template-columns: 12px minmax(0, 1fr) auto;
|
|
25
|
+
align-items: center;
|
|
26
|
+
gap: 10px;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.swatch {
|
|
30
|
+
width: 12px;
|
|
31
|
+
height: 12px;
|
|
32
|
+
border-radius: 4px;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.legendText {
|
|
36
|
+
display: grid;
|
|
37
|
+
min-width: 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.legendLabel {
|
|
41
|
+
font-size: 13px;
|
|
42
|
+
line-height: 18px;
|
|
43
|
+
color: var(--text-primary);
|
|
44
|
+
overflow: hidden;
|
|
45
|
+
text-overflow: ellipsis;
|
|
46
|
+
white-space: nowrap;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.legendDetail {
|
|
50
|
+
font-size: 12px;
|
|
51
|
+
line-height: 16px;
|
|
52
|
+
color: var(--text-tertiary);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.legendNumbers {
|
|
56
|
+
display: grid;
|
|
57
|
+
justify-items: end;
|
|
58
|
+
font-size: 12px;
|
|
59
|
+
line-height: 16px;
|
|
60
|
+
color: var(--text-tertiary);
|
|
61
|
+
font-variant-numeric: tabular-nums;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.legendNumbers strong {
|
|
65
|
+
font-size: 13px;
|
|
66
|
+
font-weight: 500;
|
|
67
|
+
color: var(--text-primary);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.rankList {
|
|
71
|
+
display: grid;
|
|
72
|
+
gap: 14px;
|
|
73
|
+
margin: 0;
|
|
74
|
+
padding: 0;
|
|
75
|
+
list-style: none;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.rankRow {
|
|
79
|
+
display: grid;
|
|
80
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
81
|
+
gap: 4px 16px;
|
|
82
|
+
align-items: baseline;
|
|
83
|
+
font-size: 14px;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.rankName {
|
|
87
|
+
min-width: 0;
|
|
88
|
+
overflow: hidden;
|
|
89
|
+
text-overflow: ellipsis;
|
|
90
|
+
white-space: nowrap;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.rankSub {
|
|
94
|
+
grid-column: 1;
|
|
95
|
+
font-size: 12px;
|
|
96
|
+
color: var(--text-tertiary);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.rankAmount {
|
|
100
|
+
grid-row: 1;
|
|
101
|
+
grid-column: 2;
|
|
102
|
+
font-weight: 500;
|
|
103
|
+
font-variant-numeric: tabular-nums;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.rankBar {
|
|
107
|
+
grid-column: 1 / -1;
|
|
108
|
+
height: 6px;
|
|
109
|
+
border-radius: 999px;
|
|
110
|
+
background: var(--bg-subtle);
|
|
111
|
+
overflow: hidden;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.rankFill {
|
|
115
|
+
display: block;
|
|
116
|
+
height: 100%;
|
|
117
|
+
border-radius: 999px;
|
|
118
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
|
3
|
+
import {
|
|
4
|
+
adminEarningsApi
|
|
5
|
+
} from "@withpotter/lib/admin-earnings-api";
|
|
6
|
+
function useEarnings(range) {
|
|
7
|
+
return useQuery({
|
|
8
|
+
queryKey: ["platform-admin", "earnings", "summary", range],
|
|
9
|
+
queryFn: () => adminEarningsApi.summary(range),
|
|
10
|
+
retry: false,
|
|
11
|
+
staleTime: 6e4
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
function useEarningsTransactions(filters) {
|
|
15
|
+
return useQuery({
|
|
16
|
+
queryKey: ["platform-admin", "earnings", "transactions", filters],
|
|
17
|
+
queryFn: () => adminEarningsApi.transactions(filters),
|
|
18
|
+
retry: false,
|
|
19
|
+
staleTime: 3e4,
|
|
20
|
+
placeholderData: keepPreviousData
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export {
|
|
24
|
+
useEarnings,
|
|
25
|
+
useEarningsTransactions
|
|
26
|
+
};
|
|
27
|
+
//# sourceMappingURL=use-earnings.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../src/admin/revenue/earnings/use-earnings.ts"],"sourcesContent":["'use client';\n\nimport { keepPreviousData, useQuery } from '@tanstack/react-query';\nimport {\n adminEarningsApi,\n type EarningsFilters,\n} from '@withpotter/lib/admin-earnings-api';\n\n/** What Olive earns in the window, by source, day and merchant. A box without the route answers 404: not available here. */\nexport function useEarnings(range: { startDate?: string; endDate?: string }) {\n return useQuery({\n queryKey: ['platform-admin', 'earnings', 'summary', range],\n queryFn: () => adminEarningsApi.summary(range),\n retry: false,\n staleTime: 60_000,\n });\n}\n\n/** The fee transactions behind the earnings, a page at a time. */\nexport function useEarningsTransactions(filters: EarningsFilters) {\n return useQuery({\n queryKey: ['platform-admin', 'earnings', 'transactions', filters],\n queryFn: () => adminEarningsApi.transactions(filters),\n retry: false,\n staleTime: 30_000,\n placeholderData: keepPreviousData,\n });\n}\n"],"mappings":";AAEA,SAAS,kBAAkB,gBAAgB;AAC3C;AAAA,EACE;AAAA,OAEK;AAGA,SAAS,YAAY,OAAiD;AAC3E,SAAO,SAAS;AAAA,IACd,UAAU,CAAC,kBAAkB,YAAY,WAAW,KAAK;AAAA,IACzD,SAAS,MAAM,iBAAiB,QAAQ,KAAK;AAAA,IAC7C,OAAO;AAAA,IACP,WAAW;AAAA,EACb,CAAC;AACH;AAGO,SAAS,wBAAwB,SAA0B;AAChE,SAAO,SAAS;AAAA,IACd,UAAU,CAAC,kBAAkB,YAAY,gBAAgB,OAAO;AAAA,IAChE,SAAS,MAAM,iBAAiB,aAAa,OAAO;AAAA,IACpD,OAAO;AAAA,IACP,WAAW;AAAA,IACX,iBAAiB;AAAA,EACnB,CAAC;AACH;","names":[]}
|
|
@@ -2,6 +2,7 @@ import { jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { AdmLoading, Panel } from "../_ui";
|
|
3
3
|
import { FigureList } from "./figure-list";
|
|
4
4
|
const integer = new Intl.NumberFormat("en-US");
|
|
5
|
+
const pct = (value) => value == null ? "\u2013" : `${value}%`;
|
|
5
6
|
function HealthPanel({
|
|
6
7
|
health,
|
|
7
8
|
money,
|
|
@@ -21,12 +22,12 @@ function HealthPanel({
|
|
|
21
22
|
{
|
|
22
23
|
label: "Plan income kept",
|
|
23
24
|
note: "Share of the plan income at the start that is still being paid",
|
|
24
|
-
value:
|
|
25
|
+
value: pct(health.grossRevenueRetentionPct)
|
|
25
26
|
},
|
|
26
27
|
{
|
|
27
28
|
label: "Plan income lost",
|
|
28
29
|
note: "Share of plan income lost to merchants who left",
|
|
29
|
-
value:
|
|
30
|
+
value: pct(health.grossMrrChurnPct),
|
|
30
31
|
tone: health.grossMrrChurnPct > 0 ? "danger" : "default"
|
|
31
32
|
},
|
|
32
33
|
{
|
|
@@ -41,13 +42,13 @@ function HealthPanel({
|
|
|
41
42
|
{
|
|
42
43
|
label: "Paying merchants lost",
|
|
43
44
|
note: "Share of paying merchants who stopped paying",
|
|
44
|
-
value:
|
|
45
|
+
value: pct(health.logoChurnPct),
|
|
45
46
|
tone: health.logoChurnPct > 0 ? "danger" : "default"
|
|
46
47
|
},
|
|
47
48
|
{
|
|
48
49
|
label: "Reliance on the top 10",
|
|
49
50
|
note: "Share of merchant sales that comes from the 10 biggest merchants",
|
|
50
|
-
value:
|
|
51
|
+
value: pct(health.gmvConcentrationTop10Pct),
|
|
51
52
|
tone: health.gmvConcentrationTop10Pct > 50 ? "danger" : "default"
|
|
52
53
|
}
|
|
53
54
|
];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/admin/revenue/health-panel.tsx"],"sourcesContent":["import type { PlatformCockpit } from '@withpotter/lib/admin-api';\nimport { AdmLoading, Panel } from '../_ui';\nimport { FigureList, type Figure } from './figure-list';\n\nconst integer = new Intl.NumberFormat('en-US');\n\n/** MRR movement, logo churn and concentration risk — how healthy the base is. */\nexport function HealthPanel({\n health,\n money,\n caption,\n}: {\n health: PlatformCockpit['health'] | undefined;\n money: (value: number) => string;\n caption: string;\n}) {\n if (!health) {\n return (\n <Panel title=\"How plan income is changing\" caption={caption}>\n <AdmLoading label=\"Loading revenue health…\" size=\"sm\" />\n </Panel>\n );\n }\n\n const rows: Figure[] = [\n {\n label: 'Plan income change',\n note: 'Gained from new merchants, less lost from merchants who left',\n value: money(health.netNewMrr),\n hint: `+${money(health.newMrr)} gained · −${money(health.churnedMrr)} lost`,\n tone: health.netNewMrr < 0 ? 'danger' : 'default',\n },\n {\n label: 'Plan income kept',\n note: 'Share of the plan income at the start that is still being paid',\n value:
|
|
1
|
+
{"version":3,"sources":["../../../src/admin/revenue/health-panel.tsx"],"sourcesContent":["import type { PlatformCockpit } from '@withpotter/lib/admin-api';\nimport { AdmLoading, Panel } from '../_ui';\nimport { FigureList, type Figure } from './figure-list';\n\nconst integer = new Intl.NumberFormat('en-US');\n\n/** A share, or a dash when there is no base to take it of (the api sends null then, not 0). */\nconst pct = (value: number | null | undefined) => (value == null ? '–' : `${value}%`);\n\n/** MRR movement, logo churn and concentration risk — how healthy the base is. */\nexport function HealthPanel({\n health,\n money,\n caption,\n}: {\n health: PlatformCockpit['health'] | undefined;\n money: (value: number) => string;\n caption: string;\n}) {\n if (!health) {\n return (\n <Panel title=\"How plan income is changing\" caption={caption}>\n <AdmLoading label=\"Loading revenue health…\" size=\"sm\" />\n </Panel>\n );\n }\n\n const rows: Figure[] = [\n {\n label: 'Plan income change',\n note: 'Gained from new merchants, less lost from merchants who left',\n value: money(health.netNewMrr),\n hint: `+${money(health.newMrr)} gained · −${money(health.churnedMrr)} lost`,\n tone: health.netNewMrr < 0 ? 'danger' : 'default',\n },\n {\n label: 'Plan income kept',\n note: 'Share of the plan income at the start that is still being paid',\n value: pct(health.grossRevenueRetentionPct),\n },\n {\n label: 'Plan income lost',\n note: 'Share of plan income lost to merchants who left',\n value: pct(health.grossMrrChurnPct),\n tone: health.grossMrrChurnPct > 0 ? 'danger' : 'default',\n },\n {\n label: 'Paying merchants gained',\n note: 'Merchants who started paying, less merchants who stopped',\n value: integer.format(health.netNewLogos),\n hint: `+${integer.format(health.newLogos)} joined · −${integer.format(\n health.churnedLogos,\n )} left`,\n tone: health.netNewLogos < 0 ? 'danger' : 'default',\n },\n {\n label: 'Paying merchants lost',\n note: 'Share of paying merchants who stopped paying',\n value: pct(health.logoChurnPct),\n tone: health.logoChurnPct > 0 ? 'danger' : 'default',\n },\n {\n label: 'Reliance on the top 10',\n note: 'Share of merchant sales that comes from the 10 biggest merchants',\n value: pct(health.gmvConcentrationTop10Pct),\n tone: health.gmvConcentrationTop10Pct > 50 ? 'danger' : 'default',\n },\n ];\n\n return (\n <Panel title=\"How plan income is changing\" caption={caption}>\n <FigureList rows={rows} />\n </Panel>\n );\n}\n"],"mappings":"AAsBQ;AArBR,SAAS,YAAY,aAAa;AAClC,SAAS,kBAA+B;AAExC,MAAM,UAAU,IAAI,KAAK,aAAa,OAAO;AAG7C,MAAM,MAAM,CAAC,UAAsC,SAAS,OAAO,WAAM,GAAG,KAAK;AAG1E,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AACF,GAIG;AACD,MAAI,CAAC,QAAQ;AACX,WACE,oBAAC,SAAM,OAAM,+BAA8B,SACzC,8BAAC,cAAW,OAAM,gCAA0B,MAAK,MAAK,GACxD;AAAA,EAEJ;AAEA,QAAM,OAAiB;AAAA,IACrB;AAAA,MACE,OAAO;AAAA,MACP,MAAM;AAAA,MACN,OAAO,MAAM,OAAO,SAAS;AAAA,MAC7B,MAAM,IAAI,MAAM,OAAO,MAAM,CAAC,sBAAc,MAAM,OAAO,UAAU,CAAC;AAAA,MACpE,MAAM,OAAO,YAAY,IAAI,WAAW;AAAA,IAC1C;AAAA,IACA;AAAA,MACE,OAAO;AAAA,MACP,MAAM;AAAA,MACN,OAAO,IAAI,OAAO,wBAAwB;AAAA,IAC5C;AAAA,IACA;AAAA,MACE,OAAO;AAAA,MACP,MAAM;AAAA,MACN,OAAO,IAAI,OAAO,gBAAgB;AAAA,MAClC,MAAM,OAAO,mBAAmB,IAAI,WAAW;AAAA,IACjD;AAAA,IACA;AAAA,MACE,OAAO;AAAA,MACP,MAAM;AAAA,MACN,OAAO,QAAQ,OAAO,OAAO,WAAW;AAAA,MACxC,MAAM,IAAI,QAAQ,OAAO,OAAO,QAAQ,CAAC,sBAAc,QAAQ;AAAA,QAC7D,OAAO;AAAA,MACT,CAAC;AAAA,MACD,MAAM,OAAO,cAAc,IAAI,WAAW;AAAA,IAC5C;AAAA,IACA;AAAA,MACE,OAAO;AAAA,MACP,MAAM;AAAA,MACN,OAAO,IAAI,OAAO,YAAY;AAAA,MAC9B,MAAM,OAAO,eAAe,IAAI,WAAW;AAAA,IAC7C;AAAA,IACA;AAAA,MACE,OAAO;AAAA,MACP,MAAM;AAAA,MACN,OAAO,IAAI,OAAO,wBAAwB;AAAA,MAC1C,MAAM,OAAO,2BAA2B,KAAK,WAAW;AAAA,IAC1D;AAAA,EACF;AAEA,SACE,oBAAC,SAAM,OAAM,+BAA8B,SACzC,8BAAC,cAAW,MAAY,GAC1B;AAEJ;","names":[]}
|
|
@@ -41,7 +41,7 @@ function RevenueKpis({
|
|
|
41
41
|
label: "Plan income each month",
|
|
42
42
|
value: tracked ? /* @__PURE__ */ jsx(CountUp, { value: recurring.mrr, format: money }) : notTracked,
|
|
43
43
|
hint: `What merchants on a paid plan are expected to pay ${ADMIN_NAME} every month, in ${data.currency || "NGN"}. Free plans, trials and pauses are not counted.`,
|
|
44
|
-
sub: tracked ? `${integer.format(recurring.payingMerchants ?? recurring.activeSubscriptions)} merchants paying` : noSource
|
|
44
|
+
sub: tracked ? `${integer.format(recurring.payingMerchants ?? recurring.activeSubscriptions)} ${(recurring.payingMerchants ?? recurring.activeSubscriptions) === 1 ? "merchant" : "merchants"} paying` : noSource
|
|
45
45
|
}
|
|
46
46
|
) }),
|
|
47
47
|
/* @__PURE__ */ jsx(Reveal, { index: 1, className: "adm-span-3", children: /* @__PURE__ */ jsx(
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/admin/revenue/revenue-kpis.tsx"],"sourcesContent":["import { ADMIN_NAME } from '../_shell/admin-brand';\nimport type { ReactNode } from 'react';\nimport type { PlatformRevenue } from '@withpotter/lib/admin-api';\nimport { CountUp, Reveal, TONE } from '../_fx';\nimport { MoneyLines, StatCard, rowFor } from '../_ui';\n\nconst integer = new Intl.NumberFormat('en-US');\n\n/**\n * The headline figures, in two groups that are never added together: what\n * merchants sold (`merchant`), and what Olive itself earns from plans (`olive`).\n */\nexport function RevenueKpis({\n part,\n extra,\n data,\n money,\n money2,\n currency,\n focus,\n gmvSpark,\n}: {\n part: 'merchant' | 'olive';\n /** More cards for the merchant group, in the same row. */\n extra?: ReactNode;\n data: PlatformRevenue;\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 share and sparkline follow, or null. */\n focus: string | null;\n gmvSpark: number[];\n}) {\n const { recurring } = data;\n // Without the plan payments connected the API sends 0s; say so rather than show a zero.\n const tracked = recurring.tracked !== false;\n const notTracked = 'Not set up yet';\n const noSource = 'The plan payments are not connected yet';\n const gmv = rowFor(data.gmvByCurrency, focus);\n const lifetimeShare =\n gmv && gmv.lifetime > 0\n ? `${Math.round((gmv.inRange / gmv.lifetime) * 100)}% of lifetime`\n : undefined;\n\n return (\n <section\n aria-label={part === 'olive' ? 'What Olive earns' : 'What merchants sold'}\n className=\"adm-grid\"\n >\n {part === 'olive' && (\n <>\n <Reveal index={0} className=\"adm-span-3\">\n <StatCard\n label=\"Plan income collected\"\n value={\n tracked && recurring.collected !== undefined ? (\n <CountUp value={recurring.collected} format={money2} />\n ) : (\n notTracked\n )\n }\n hint={`Plan payments that actually went through in this window, in ${data.currency || 'NGN'}. The monthly figure beside it is what is expected, this is what came in.`}\n sub={tracked ? 'In this window' : noSource}\n />\n </Reveal>\n <Reveal index={1} className=\"adm-span-3\">\n <StatCard\n label=\"Plan income each month\"\n value={\n tracked ? (\n <CountUp value={recurring.mrr} format={money} />\n ) : (\n notTracked\n )\n }\n hint={`What merchants on a paid plan are expected to pay ${ADMIN_NAME} every month, in ${data.currency || 'NGN'}. Free plans, trials and pauses are not counted.`}\n sub={\n tracked\n ? `${integer.format(recurring.payingMerchants ?? recurring.activeSubscriptions)} merchants paying`\n : noSource\n }\n />\n </Reveal>\n <Reveal index={1} className=\"adm-span-3\">\n <StatCard\n label=\"Plan income over a year\"\n value={\n tracked ? (\n <CountUp value={recurring.arr} format={money} />\n ) : (\n notTracked\n )\n }\n hint=\"What plan income adds up to over a year if nothing changes: the monthly figure times 12.\"\n sub={tracked ? 'Each month times 12' : noSource}\n />\n </Reveal>\n <Reveal index={2} className=\"adm-span-3\">\n <StatCard\n label=\"Average per paying merchant\"\n value={\n tracked ? (\n <CountUp value={recurring.arpa} format={money2} />\n ) : (\n notTracked\n )\n }\n hint=\"What one paying merchant pays in a month, on average.\"\n sub={tracked ? 'Each month' : noSource}\n />\n </Reveal>\n </>\n )}\n {part === 'merchant' && (\n <>\n <Reveal index={3} className=\"adm-span-3\">\n <StatCard\n label=\"Merchant sales\"\n value={\n <MoneyLines\n rows={data.gmvByCurrency}\n currency={currency}\n amount={(g) => g.inRange}\n empty=\"No sales yet\"\n />\n }\n href=\"/admin/tenants\"\n sub={lifetimeShare}\n hint={`Everything merchants sold through ${ADMIN_NAME}. This is the merchants' money, not ${ADMIN_NAME}'s own income. One line per currency; currencies are never added together. (GMV)`}\n spark={focus ? gmvSpark : undefined}\n sparkColor={TONE.money.fg}\n />\n </Reveal>\n {extra}\n {data.merchantWalletsByCurrency && (\n <Reveal index={4} className=\"adm-span-3\">\n <StatCard\n label=\"In merchants' wallets\"\n value={\n <MoneyLines\n rows={data.merchantWalletsByCurrency}\n currency={currency}\n amount={(r) => r.amount}\n decimals={2}\n empty=\"Nothing held\"\n />\n }\n hint=\"What merchants hold in their wallets right now: pending, available, held back and on its way to a bank, added together. A balance today, so it does not follow the dates above.\"\n sub=\"Right now\"\n />\n </Reveal>\n )}\n {data.merchantPayoutsByCurrency && (\n <Reveal index={5} className=\"adm-span-3\">\n <StatCard\n label=\"Merchants sent out\"\n value={\n <MoneyLines\n rows={data.merchantPayoutsByCurrency}\n currency={currency}\n amount={(r) => r.amount}\n decimals={2}\n empty=\"Nothing sent out\"\n />\n }\n hint=\"What merchants withdrew to their bank accounts in this window. Payouts the bank sent back are taken off.\"\n />\n </Reveal>\n )}\n </>\n )}\n </section>\n );\n}\n"],"mappings":"AAoDQ,mBAMM,KANN;AApDR,SAAS,kBAAkB;AAG3B,SAAS,SAAS,QAAQ,YAAY;AACtC,SAAS,YAAY,UAAU,cAAc;AAE7C,MAAM,UAAU,IAAI,KAAK,aAAa,OAAO;AAMtC,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAaG;AACD,QAAM,EAAE,UAAU,IAAI;AAEtB,QAAM,UAAU,UAAU,YAAY;AACtC,QAAM,aAAa;AACnB,QAAM,WAAW;AACjB,QAAM,MAAM,OAAO,KAAK,eAAe,KAAK;AAC5C,QAAM,gBACJ,OAAO,IAAI,WAAW,IAClB,GAAG,KAAK,MAAO,IAAI,UAAU,IAAI,WAAY,GAAG,CAAC,kBACjD;AAEN,SACE;AAAA,IAAC;AAAA;AAAA,MACC,cAAY,SAAS,UAAU,qBAAqB;AAAA,MACpD,WAAU;AAAA,MAET;AAAA,iBAAS,WACR,iCACF;AAAA,8BAAC,UAAO,OAAO,GAAG,WAAU,cAC1B;AAAA,YAAC;AAAA;AAAA,cACC,OAAM;AAAA,cACN,OACE,WAAW,UAAU,cAAc,SACjC,oBAAC,WAAQ,OAAO,UAAU,WAAW,QAAQ,QAAQ,IAErD;AAAA,cAGJ,MAAM,+DAA+D,KAAK,YAAY,KAAK;AAAA,cAC3F,KAAK,UAAU,mBAAmB;AAAA;AAAA,UACpC,GACF;AAAA,UACA,oBAAC,UAAO,OAAO,GAAG,WAAU,cAC1B;AAAA,YAAC;AAAA;AAAA,cACC,OAAM;AAAA,cACN,OACE,UACE,oBAAC,WAAQ,OAAO,UAAU,KAAK,QAAQ,OAAO,IAE9C;AAAA,cAGJ,MAAM,qDAAqD,UAAU,oBAAoB,KAAK,YAAY,KAAK;AAAA,cAC/G,KACE,UACI,GAAG,QAAQ,OAAO,UAAU,mBAAmB,UAAU,mBAAmB,CAAC,
|
|
1
|
+
{"version":3,"sources":["../../../src/admin/revenue/revenue-kpis.tsx"],"sourcesContent":["import { ADMIN_NAME } from '../_shell/admin-brand';\nimport type { ReactNode } from 'react';\nimport type { PlatformRevenue } from '@withpotter/lib/admin-api';\nimport { CountUp, Reveal, TONE } from '../_fx';\nimport { MoneyLines, StatCard, rowFor } from '../_ui';\n\nconst integer = new Intl.NumberFormat('en-US');\n\n/**\n * The headline figures, in two groups that are never added together: what\n * merchants sold (`merchant`), and what Olive itself earns from plans (`olive`).\n */\nexport function RevenueKpis({\n part,\n extra,\n data,\n money,\n money2,\n currency,\n focus,\n gmvSpark,\n}: {\n part: 'merchant' | 'olive';\n /** More cards for the merchant group, in the same row. */\n extra?: ReactNode;\n data: PlatformRevenue;\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 share and sparkline follow, or null. */\n focus: string | null;\n gmvSpark: number[];\n}) {\n const { recurring } = data;\n // Without the plan payments connected the API sends 0s; say so rather than show a zero.\n const tracked = recurring.tracked !== false;\n const notTracked = 'Not set up yet';\n const noSource = 'The plan payments are not connected yet';\n const gmv = rowFor(data.gmvByCurrency, focus);\n const lifetimeShare =\n gmv && gmv.lifetime > 0\n ? `${Math.round((gmv.inRange / gmv.lifetime) * 100)}% of lifetime`\n : undefined;\n\n return (\n <section\n aria-label={part === 'olive' ? 'What Olive earns' : 'What merchants sold'}\n className=\"adm-grid\"\n >\n {part === 'olive' && (\n <>\n <Reveal index={0} className=\"adm-span-3\">\n <StatCard\n label=\"Plan income collected\"\n value={\n tracked && recurring.collected !== undefined ? (\n <CountUp value={recurring.collected} format={money2} />\n ) : (\n notTracked\n )\n }\n hint={`Plan payments that actually went through in this window, in ${data.currency || 'NGN'}. The monthly figure beside it is what is expected, this is what came in.`}\n sub={tracked ? 'In this window' : noSource}\n />\n </Reveal>\n <Reveal index={1} className=\"adm-span-3\">\n <StatCard\n label=\"Plan income each month\"\n value={\n tracked ? (\n <CountUp value={recurring.mrr} format={money} />\n ) : (\n notTracked\n )\n }\n hint={`What merchants on a paid plan are expected to pay ${ADMIN_NAME} every month, in ${data.currency || 'NGN'}. Free plans, trials and pauses are not counted.`}\n sub={\n tracked\n ? `${integer.format(recurring.payingMerchants ?? recurring.activeSubscriptions)} ${(recurring.payingMerchants ?? recurring.activeSubscriptions) === 1 ? 'merchant' : 'merchants'} paying`\n : noSource\n }\n />\n </Reveal>\n <Reveal index={1} className=\"adm-span-3\">\n <StatCard\n label=\"Plan income over a year\"\n value={\n tracked ? (\n <CountUp value={recurring.arr} format={money} />\n ) : (\n notTracked\n )\n }\n hint=\"What plan income adds up to over a year if nothing changes: the monthly figure times 12.\"\n sub={tracked ? 'Each month times 12' : noSource}\n />\n </Reveal>\n <Reveal index={2} className=\"adm-span-3\">\n <StatCard\n label=\"Average per paying merchant\"\n value={\n tracked ? (\n <CountUp value={recurring.arpa} format={money2} />\n ) : (\n notTracked\n )\n }\n hint=\"What one paying merchant pays in a month, on average.\"\n sub={tracked ? 'Each month' : noSource}\n />\n </Reveal>\n </>\n )}\n {part === 'merchant' && (\n <>\n <Reveal index={3} className=\"adm-span-3\">\n <StatCard\n label=\"Merchant sales\"\n value={\n <MoneyLines\n rows={data.gmvByCurrency}\n currency={currency}\n amount={(g) => g.inRange}\n empty=\"No sales yet\"\n />\n }\n href=\"/admin/tenants\"\n sub={lifetimeShare}\n hint={`Everything merchants sold through ${ADMIN_NAME}. This is the merchants' money, not ${ADMIN_NAME}'s own income. One line per currency; currencies are never added together. (GMV)`}\n spark={focus ? gmvSpark : undefined}\n sparkColor={TONE.money.fg}\n />\n </Reveal>\n {extra}\n {data.merchantWalletsByCurrency && (\n <Reveal index={4} className=\"adm-span-3\">\n <StatCard\n label=\"In merchants' wallets\"\n value={\n <MoneyLines\n rows={data.merchantWalletsByCurrency}\n currency={currency}\n amount={(r) => r.amount}\n decimals={2}\n empty=\"Nothing held\"\n />\n }\n hint=\"What merchants hold in their wallets right now: pending, available, held back and on its way to a bank, added together. A balance today, so it does not follow the dates above.\"\n sub=\"Right now\"\n />\n </Reveal>\n )}\n {data.merchantPayoutsByCurrency && (\n <Reveal index={5} className=\"adm-span-3\">\n <StatCard\n label=\"Merchants sent out\"\n value={\n <MoneyLines\n rows={data.merchantPayoutsByCurrency}\n currency={currency}\n amount={(r) => r.amount}\n decimals={2}\n empty=\"Nothing sent out\"\n />\n }\n hint=\"What merchants withdrew to their bank accounts in this window. Payouts the bank sent back are taken off.\"\n />\n </Reveal>\n )}\n </>\n )}\n </section>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -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":"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","names":[]}
|
|
@@ -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":";AA6GM,
|
|
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":[]}
|