@capxul/sdk-react 2.3.2 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/dist/{controllers-VwUPB619.mjs → controllers-BJ_uFXT3.mjs} +28 -2
- package/dist/index.d.mts +473 -20
- package/dist/index.mjs +1267 -24
- package/dist/testing/index.mjs +1 -1
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { a as useCapxulAuth, c as useCapxulIdentityOrNull, d as capxulKeys, f as useCapxulClientOrNull, i as entered, l as useCapxulSend, n as CapxulOnboardingController, o as useCapxulDestination, p as useCapxul, r as CapxulProvider, s as useCapxulIdentity, t as CapxulAuthenticationController, u as useCapxulTransitions } from "./controllers-
|
|
2
|
+
import { a as useCapxulAuth, c as useCapxulIdentityOrNull, d as capxulKeys, f as useCapxulClientOrNull, i as entered, l as useCapxulSend, n as CapxulOnboardingController, o as useCapxulDestination, p as useCapxul, r as CapxulProvider, s as useCapxulIdentity, t as CapxulAuthenticationController, u as useCapxulTransitions } from "./controllers-BJ_uFXT3.mjs";
|
|
3
3
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
4
|
-
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
5
|
-
import { CapxulError, Errors, fingerprintPaymentIntent, formatMoney, isClaimed, isClaimed as isClaimed$1, isMoneyParseError, isRestoring, isRestoring as isRestoring$1, parseMoney, resolveIdentityDestination } from "@capxul/sdk";
|
|
4
|
+
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
5
|
+
import { CapxulError, Errors, PAYMENT_DIRECTIONS, PAYMENT_STATUSES, fingerprintPaymentIntent, formatMoney, isCapxulError, isClaimed, isClaimed as isClaimed$1, isMoneyParseError, isRestoring, isRestoring as isRestoring$1, parseMoney, paymentPhase, resolveIdentityDestination } from "@capxul/sdk";
|
|
6
6
|
import { Fragment, jsx } from "react/jsx-runtime";
|
|
7
7
|
//#region src/internal/require-bootstrapped-client.ts
|
|
8
8
|
/**
|
|
@@ -429,14 +429,14 @@ function useCapxulActivity(params) {
|
|
|
429
429
|
enabled: client !== null && identity !== null && isClaimed$1(identity)
|
|
430
430
|
});
|
|
431
431
|
}
|
|
432
|
-
function useCapxulActivityDetail(reference) {
|
|
432
|
+
function useCapxulActivityDetail(reference, actor) {
|
|
433
433
|
const client = useCapxulClientOrNull();
|
|
434
434
|
const identity = useCapxulIdentityOrNull();
|
|
435
435
|
return useQuery({
|
|
436
|
-
queryKey: capxulKeys.activityDetail(reference),
|
|
436
|
+
queryKey: capxulKeys.activityDetail(reference, actor),
|
|
437
437
|
queryFn: async () => {
|
|
438
438
|
if (reference === void 0) throw Errors.invalidInput("reference", "required for activity.get");
|
|
439
|
-
return unwrapCapxulResult(await requireBootstrappedClient(client, "activity.get").activity.get(reference));
|
|
439
|
+
return unwrapCapxulResult(await requireBootstrappedClient(client, "activity.get").activity.get(reference, actor === void 0 ? void 0 : { actor }));
|
|
440
440
|
},
|
|
441
441
|
enabled: client !== null && reference !== void 0 && identity !== null && isClaimed$1(identity)
|
|
442
442
|
});
|
|
@@ -453,13 +453,14 @@ function useCapxulAnnotateMovement() {
|
|
|
453
453
|
}
|
|
454
454
|
//#endregion
|
|
455
455
|
//#region src/hooks/use-capxul-holdings.ts
|
|
456
|
-
function useCapxulHoldings() {
|
|
456
|
+
function useCapxulHoldings(params) {
|
|
457
457
|
const client = useCapxulClientOrNull();
|
|
458
458
|
const identity = useCapxulIdentityOrNull();
|
|
459
|
+
const actor = params?.actor;
|
|
459
460
|
return useQuery({
|
|
460
|
-
queryKey: capxulKeys.holdings,
|
|
461
|
+
queryKey: capxulKeys.holdings(actor),
|
|
461
462
|
queryFn: async () => {
|
|
462
|
-
return unwrapCapxulResult(await requireBootstrappedClient(client, "holdings.current").holdings.current());
|
|
463
|
+
return unwrapCapxulResult(await requireBootstrappedClient(client, "holdings.current").holdings.current(actor === void 0 ? void 0 : { actor }));
|
|
463
464
|
},
|
|
464
465
|
enabled: client !== null && identity !== null && isClaimed$1(identity)
|
|
465
466
|
});
|
|
@@ -1013,7 +1014,7 @@ function useContactsContext() {
|
|
|
1013
1014
|
if (engine === null) throw new Error("CapxulContacts parts must be used inside <CapxulContacts>");
|
|
1014
1015
|
return engine;
|
|
1015
1016
|
}
|
|
1016
|
-
function Root$
|
|
1017
|
+
function Root$6({ actor, onAdded, onFailed, children }) {
|
|
1017
1018
|
const engine = useContacts({
|
|
1018
1019
|
actor,
|
|
1019
1020
|
onAdded,
|
|
@@ -1024,7 +1025,7 @@ function Root$3({ actor, onAdded, onFailed, children }) {
|
|
|
1024
1025
|
children
|
|
1025
1026
|
});
|
|
1026
1027
|
}
|
|
1027
|
-
function Summary({ children }) {
|
|
1028
|
+
function Summary$3({ children }) {
|
|
1028
1029
|
return /* @__PURE__ */ jsx(Fragment, { children: children(useContactsSummary(useContactsContext())) });
|
|
1029
1030
|
}
|
|
1030
1031
|
function List({ limit, show, children }) {
|
|
@@ -1037,12 +1038,674 @@ function Add({ children }) {
|
|
|
1037
1038
|
return /* @__PURE__ */ jsx(Fragment, { children: children(useContactsContext().add) });
|
|
1038
1039
|
}
|
|
1039
1040
|
/** The compound shape the app's own design system already uses (`StatusTabs`). */
|
|
1040
|
-
const CapxulContacts = Object.assign(Root$
|
|
1041
|
-
Summary,
|
|
1041
|
+
const CapxulContacts = Object.assign(Root$6, {
|
|
1042
|
+
Summary: Summary$3,
|
|
1042
1043
|
List,
|
|
1043
1044
|
Add
|
|
1044
1045
|
});
|
|
1045
1046
|
//#endregion
|
|
1047
|
+
//#region src/internal/as-capxul-error.ts
|
|
1048
|
+
/**
|
|
1049
|
+
* TanStack types a query error as `Error`. Every query in this package throws
|
|
1050
|
+
* through `unwrapCapxulResult` or `requireBootstrappedClient`, so the value IS a
|
|
1051
|
+
* `CapxulError`. Anything else is a defect inside this package: it is WRAPPED
|
|
1052
|
+
* with the `UNKNOWN` code and its cause, never swallowed, because a slice hands
|
|
1053
|
+
* the app an error VALUE carrying a code and the app writes the sentence
|
|
1054
|
+
* (ADR-0023).
|
|
1055
|
+
*/
|
|
1056
|
+
function asCapxulError$1(error) {
|
|
1057
|
+
if (error === null) return null;
|
|
1058
|
+
return isCapxulError(error) ? error : Errors.unknown(error);
|
|
1059
|
+
}
|
|
1060
|
+
//#endregion
|
|
1061
|
+
//#region src/headless/activity/enabled.ts
|
|
1062
|
+
/**
|
|
1063
|
+
* The one gate every read under `CapxulActivity` shares — the engine, each
|
|
1064
|
+
* `.Summary` lane, and each `.Detail` lane. One function, because a gate that
|
|
1065
|
+
* differs between the list and the detail lets a screen load rows it can never
|
|
1066
|
+
* open.
|
|
1067
|
+
*
|
|
1068
|
+
* Every backend activity read resolves the authenticated Account before it
|
|
1069
|
+
* resolves either a personal or Organization actor. Wait for that Account so a
|
|
1070
|
+
* new session cannot issue requests the backend must refuse.
|
|
1071
|
+
*/
|
|
1072
|
+
function useActivityEnabled() {
|
|
1073
|
+
const client = useCapxulClientOrNull();
|
|
1074
|
+
const identity = useCapxulIdentityOrNull();
|
|
1075
|
+
return client !== null && identity !== null && isClaimed$1(identity);
|
|
1076
|
+
}
|
|
1077
|
+
//#endregion
|
|
1078
|
+
//#region src/headless/activity/detail.ts
|
|
1079
|
+
/**
|
|
1080
|
+
* ONE finished document, never the array, so no consumer writes `documents[0]`:
|
|
1081
|
+
* the payslip, else the receipt, else nothing. Those two kinds are the ones the
|
|
1082
|
+
* Download receipt button is for. A Movement detail carries no documents.
|
|
1083
|
+
*/
|
|
1084
|
+
function pickReceiptDocument(detail) {
|
|
1085
|
+
if (detail === null || detail.kind !== "payment") return null;
|
|
1086
|
+
const documents = detail.payment.documents;
|
|
1087
|
+
return documents.find((document) => document.kind === "payslip") ?? documents.find((document) => document.kind === "receipt") ?? null;
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* The root actor and the whole reference are included. A payment reference can
|
|
1091
|
+
* carry its own actor, and a Movement reference relies on the root actor. A key
|
|
1092
|
+
* of kind and id alone would show one actor's draft memo on the other actor's
|
|
1093
|
+
* row, and save it there.
|
|
1094
|
+
*
|
|
1095
|
+
* The parts go into an ARRAY, and the array is what gets serialized. A joined
|
|
1096
|
+
* string collides: an organization whose id is the sentinel `self`, or any id
|
|
1097
|
+
* that carries the separator, produces another reference's key. Serializing the
|
|
1098
|
+
* reference OBJECT collides the other way, because property order is not part of
|
|
1099
|
+
* a reference's meaning and a literal written `{ id, kind }` must not read as a
|
|
1100
|
+
* different row. The array is ordered here, by this function, so neither the
|
|
1101
|
+
* caller's property order nor the content of an id can move a part.
|
|
1102
|
+
*/
|
|
1103
|
+
function actorKey(actor) {
|
|
1104
|
+
if (actor === void 0) return null;
|
|
1105
|
+
return actor.kind === "organization" ? [actor.kind, actor.organizationId] : [actor.kind];
|
|
1106
|
+
}
|
|
1107
|
+
function activityDetailKey(actor, reference) {
|
|
1108
|
+
if (reference === void 0) return JSON.stringify([actorKey(actor), null]);
|
|
1109
|
+
const referenceActor = reference.kind === "payment" ? reference.actor : void 0;
|
|
1110
|
+
return JSON.stringify([actorKey(actor), [
|
|
1111
|
+
reference.kind,
|
|
1112
|
+
reference.id,
|
|
1113
|
+
actorKey(referenceActor)
|
|
1114
|
+
]]);
|
|
1115
|
+
}
|
|
1116
|
+
function useActivityDetail(reference, actor) {
|
|
1117
|
+
const client = useCapxulClientOrNull();
|
|
1118
|
+
const enabled = useActivityEnabled() && reference !== void 0;
|
|
1119
|
+
const detail = useQuery({
|
|
1120
|
+
queryKey: capxulKeys.activityDetail(reference, actor),
|
|
1121
|
+
queryFn: async () => {
|
|
1122
|
+
if (reference === void 0) throw Errors.invalidInput("reference", "required for activity.get");
|
|
1123
|
+
return unwrapCapxulResult(await requireBootstrappedClient(client, "activity.get").activity.get(reference, actor === void 0 ? void 0 : { actor }));
|
|
1124
|
+
},
|
|
1125
|
+
enabled
|
|
1126
|
+
});
|
|
1127
|
+
const receiptDocument = pickReceiptDocument(detail.data ?? null);
|
|
1128
|
+
const [isOpeningDocument, setIsOpeningDocument] = useState(false);
|
|
1129
|
+
const openReceiptDocument = useCallback(async () => {
|
|
1130
|
+
if (reference === void 0 || receiptDocument === null) throw Errors.invalidInput("receiptDocument", "no renderable document on this row");
|
|
1131
|
+
setIsOpeningDocument(true);
|
|
1132
|
+
try {
|
|
1133
|
+
return unwrapCapxulResult(await requireBootstrappedClient(client, "paymentDocuments.render").paymentDocuments.render(receiptDocument.documentHash));
|
|
1134
|
+
} finally {
|
|
1135
|
+
setIsOpeningDocument(false);
|
|
1136
|
+
}
|
|
1137
|
+
}, [
|
|
1138
|
+
client,
|
|
1139
|
+
receiptDocument,
|
|
1140
|
+
reference
|
|
1141
|
+
]);
|
|
1142
|
+
const [typed, setTyped] = useState(null);
|
|
1143
|
+
const annotate = useCapxulAnnotateMovement();
|
|
1144
|
+
const saveMemo = useCallback(() => {
|
|
1145
|
+
if (reference === void 0 || typed === null) return;
|
|
1146
|
+
annotate.mutate({
|
|
1147
|
+
...actor === void 0 ? {} : { actor },
|
|
1148
|
+
reference,
|
|
1149
|
+
memo: typed
|
|
1150
|
+
});
|
|
1151
|
+
}, [
|
|
1152
|
+
actor,
|
|
1153
|
+
annotate,
|
|
1154
|
+
reference,
|
|
1155
|
+
typed
|
|
1156
|
+
]);
|
|
1157
|
+
return {
|
|
1158
|
+
receiptDocument,
|
|
1159
|
+
openReceiptDocument,
|
|
1160
|
+
isOpeningDocument,
|
|
1161
|
+
memo: typed ?? detail.data?.annotation?.memo ?? "",
|
|
1162
|
+
setMemo: setTyped,
|
|
1163
|
+
saveMemo,
|
|
1164
|
+
isSavingMemo: annotate.isPending,
|
|
1165
|
+
isLoading: enabled && detail.isLoading,
|
|
1166
|
+
error: asCapxulError$1(detail.error) ?? annotate.error ?? null
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
//#endregion
|
|
1170
|
+
//#region src/headless/activity/summary.ts
|
|
1171
|
+
const DAY_MS = 864e5;
|
|
1172
|
+
/**
|
|
1173
|
+
* `"last30days"` is the last 30 whole UTC days including today. Quantizing to
|
|
1174
|
+
* the UTC day keeps the cache key stable for a whole day instead of changing on
|
|
1175
|
+
* every render.
|
|
1176
|
+
*/
|
|
1177
|
+
function resolveWindow(window, now) {
|
|
1178
|
+
if (window === "all") return void 0;
|
|
1179
|
+
if (window === "last30days") return { from: Math.floor(now / DAY_MS) * DAY_MS - 29 * DAY_MS };
|
|
1180
|
+
return window;
|
|
1181
|
+
}
|
|
1182
|
+
/** An integer raw-unit string lifted into a major-unit decimal string. */
|
|
1183
|
+
function majorUnits(raw, decimals) {
|
|
1184
|
+
const negative = raw.startsWith("-");
|
|
1185
|
+
const digits = negative ? raw.slice(1) : raw;
|
|
1186
|
+
const sign = negative ? "-" : "";
|
|
1187
|
+
if (decimals === 0) return `${sign}${digits}`;
|
|
1188
|
+
const padded = digits.padStart(decimals + 1, "0");
|
|
1189
|
+
const cut = padded.length - decimals;
|
|
1190
|
+
return `${sign}${padded.slice(0, cut)}.${padded.slice(cut)}`;
|
|
1191
|
+
}
|
|
1192
|
+
function absoluteNet(total) {
|
|
1193
|
+
const net = BigInt(total.net);
|
|
1194
|
+
return net < 0n ? -net : net;
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Buckets carry different `decimals`, so their raw integers are not comparable.
|
|
1198
|
+
* Both are scaled to the wider precision before the magnitudes are ordered.
|
|
1199
|
+
*/
|
|
1200
|
+
function compareNet(left, right) {
|
|
1201
|
+
const width = Math.max(left.decimals, right.decimals);
|
|
1202
|
+
const scaledLeft = absoluteNet(left) * 10n ** BigInt(width - left.decimals);
|
|
1203
|
+
const scaledRight = absoluteNet(right) * 10n ** BigInt(width - right.decimals);
|
|
1204
|
+
if (scaledLeft === scaledRight) return 0;
|
|
1205
|
+
return scaledLeft < scaledRight ? -1 : 1;
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* Which currency `.Summary` shows: the bucket matching the primary holding's
|
|
1209
|
+
* currency, else the bucket with the largest absolute `net`, breaking a tie on
|
|
1210
|
+
* currency code ascending so the choice is stable across reloads.
|
|
1211
|
+
*
|
|
1212
|
+
* The primary holding is matched on `available.currency` — the consumer
|
|
1213
|
+
* `CurrencyCode` — and NOT on `Holding.symbol`, which is the raw token ticker
|
|
1214
|
+
* ("USDX") and can never equal a bucket's currency ("USD"). See the contract
|
|
1215
|
+
* correction on #1449.
|
|
1216
|
+
*/
|
|
1217
|
+
function pickBucket(totals, primaryCurrency) {
|
|
1218
|
+
if (primaryCurrency !== null) {
|
|
1219
|
+
const matched = totals.find((total) => total.currency === primaryCurrency);
|
|
1220
|
+
if (matched !== void 0) return matched;
|
|
1221
|
+
}
|
|
1222
|
+
let best = null;
|
|
1223
|
+
for (const total of totals) {
|
|
1224
|
+
if (best === null) {
|
|
1225
|
+
best = total;
|
|
1226
|
+
continue;
|
|
1227
|
+
}
|
|
1228
|
+
const order = compareNet(total, best);
|
|
1229
|
+
if (order > 0 || order === 0 && total.currency < best.currency) best = total;
|
|
1230
|
+
}
|
|
1231
|
+
return best;
|
|
1232
|
+
}
|
|
1233
|
+
/**
|
|
1234
|
+
* The current UTC day number, which advances ON ITS OWN at UTC midnight.
|
|
1235
|
+
*
|
|
1236
|
+
* The day is NOT held in state. It is read at every render, so it can never be
|
|
1237
|
+
* stale — not after a mount that sat through midnight, and not when a part
|
|
1238
|
+
* mounted on a fixed window is later switched to a rolling one. State holds
|
|
1239
|
+
* only `tick`, whose single job is to force the render that a screen sitting
|
|
1240
|
+
* idle would otherwise never do: React recomputes nothing on its own, so an
|
|
1241
|
+
* untouched summary would hold yesterday's boundary indefinitely.
|
|
1242
|
+
*
|
|
1243
|
+
* The effect depends on `tick`, not on the day, so the timer re-arms whatever
|
|
1244
|
+
* the clock says when it fires. A wake a millisecond early re-arms for the
|
|
1245
|
+
* boundary still ahead instead of silently ending the rollover, and a wake
|
|
1246
|
+
* hours late — a throttled tab, a sleeping machine — lands on the true day
|
|
1247
|
+
* rather than drifting one day per sleep.
|
|
1248
|
+
*
|
|
1249
|
+
* `active` is false for the windows that do not roll, so a fixed window arms no
|
|
1250
|
+
* timer at all.
|
|
1251
|
+
*/
|
|
1252
|
+
function useUtcDay(active) {
|
|
1253
|
+
const [tick, setTick] = useState(0);
|
|
1254
|
+
useEffect(() => {
|
|
1255
|
+
if (!active) return;
|
|
1256
|
+
const timer = setTimeout(() => {
|
|
1257
|
+
setTick((value) => value + 1);
|
|
1258
|
+
}, DAY_MS - Date.now() % DAY_MS + 1);
|
|
1259
|
+
return () => {
|
|
1260
|
+
clearTimeout(timer);
|
|
1261
|
+
};
|
|
1262
|
+
}, [active, tick]);
|
|
1263
|
+
return Math.floor(Date.now() / DAY_MS);
|
|
1264
|
+
}
|
|
1265
|
+
function useActivitySummary(actor, window) {
|
|
1266
|
+
const client = useCapxulClientOrNull();
|
|
1267
|
+
const enabled = useActivityEnabled();
|
|
1268
|
+
const range = resolveWindow(window, useUtcDay(window === "last30days") * DAY_MS);
|
|
1269
|
+
const summary = useQuery({
|
|
1270
|
+
queryKey: capxulKeys.activitySummary(actor, range),
|
|
1271
|
+
queryFn: async () => {
|
|
1272
|
+
const params = {};
|
|
1273
|
+
if (actor !== void 0) params.actor = actor;
|
|
1274
|
+
if (range !== void 0) params.window = range;
|
|
1275
|
+
return unwrapCapxulResult(await requireBootstrappedClient(client, "activity.summary").activity.summary(params));
|
|
1276
|
+
},
|
|
1277
|
+
enabled
|
|
1278
|
+
});
|
|
1279
|
+
const primary = useQuery({
|
|
1280
|
+
queryKey: [...capxulKeys.holdings(actor), "primary"],
|
|
1281
|
+
queryFn: async () => {
|
|
1282
|
+
return unwrapCapxulResult(await requireBootstrappedClient(client, "holdings.primary").holdings.primary(actor === void 0 ? void 0 : { actor }));
|
|
1283
|
+
},
|
|
1284
|
+
enabled
|
|
1285
|
+
});
|
|
1286
|
+
const totals = summary.data?.totals;
|
|
1287
|
+
const primaryCurrency = primary.data?.available.currency ?? null;
|
|
1288
|
+
const isLoading = summary.isLoading || primary.isLoading;
|
|
1289
|
+
const bucket = useMemo(() => totals === void 0 || isLoading ? null : pickBucket(totals, primaryCurrency), [
|
|
1290
|
+
isLoading,
|
|
1291
|
+
primaryCurrency,
|
|
1292
|
+
totals
|
|
1293
|
+
]);
|
|
1294
|
+
if (bucket === null) return {
|
|
1295
|
+
currency: null,
|
|
1296
|
+
moneyIn: null,
|
|
1297
|
+
moneyOut: null,
|
|
1298
|
+
net: null,
|
|
1299
|
+
pending: null,
|
|
1300
|
+
count: summary.data?.count ?? 0,
|
|
1301
|
+
isLoading,
|
|
1302
|
+
error: asCapxulError$1(summary.error)
|
|
1303
|
+
};
|
|
1304
|
+
const display = (raw) => formatMoney({
|
|
1305
|
+
currency: bucket.currency,
|
|
1306
|
+
value: majorUnits(raw, bucket.decimals),
|
|
1307
|
+
decimals: bucket.decimals
|
|
1308
|
+
});
|
|
1309
|
+
return {
|
|
1310
|
+
currency: bucket.currency,
|
|
1311
|
+
moneyIn: display(bucket.in),
|
|
1312
|
+
moneyOut: display(bucket.out),
|
|
1313
|
+
net: display(bucket.net),
|
|
1314
|
+
pending: display(bucket.pendingIn),
|
|
1315
|
+
count: summary.data?.count ?? 0,
|
|
1316
|
+
isLoading,
|
|
1317
|
+
error: asCapxulError$1(summary.error)
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
//#endregion
|
|
1321
|
+
//#region src/headless/activity/rows.ts
|
|
1322
|
+
/**
|
|
1323
|
+
* A Movement's phase is DERIVED, not asserted: `movementItems` returns `null`
|
|
1324
|
+
* for any Movement whose `canonicalState` is not `"finalized"`
|
|
1325
|
+
* (`activityList.ts:120`), so a Movement that reaches a page is finished and
|
|
1326
|
+
* carries no verdict of its own.
|
|
1327
|
+
*/
|
|
1328
|
+
const MOVEMENT_PHASE = Object.freeze({
|
|
1329
|
+
terminal: true,
|
|
1330
|
+
outcome: "neutral",
|
|
1331
|
+
needsAction: false
|
|
1332
|
+
});
|
|
1333
|
+
/** The sign grammar of the app's `formatDirectionalAmount` — unsigned for `self`. */
|
|
1334
|
+
const SIGN = Object.freeze({
|
|
1335
|
+
in: "+",
|
|
1336
|
+
out: "-",
|
|
1337
|
+
self: ""
|
|
1338
|
+
});
|
|
1339
|
+
function toActivityRow(item, actor) {
|
|
1340
|
+
const amountDisplay = formatMoney(item.amount);
|
|
1341
|
+
const signedAmountDisplay = `${SIGN[item.direction]}${amountDisplay}`;
|
|
1342
|
+
if (item.kind === "movement") return {
|
|
1343
|
+
kind: "movement",
|
|
1344
|
+
reference: {
|
|
1345
|
+
kind: "movement",
|
|
1346
|
+
id: item.id
|
|
1347
|
+
},
|
|
1348
|
+
direction: item.direction,
|
|
1349
|
+
counterpartyLabel: item.counterpartyLabel,
|
|
1350
|
+
amountDisplay,
|
|
1351
|
+
signedAmountDisplay,
|
|
1352
|
+
currency: item.amount.currency,
|
|
1353
|
+
createdAt: item.createdAt,
|
|
1354
|
+
classification: item.classification,
|
|
1355
|
+
phase: MOVEMENT_PHASE,
|
|
1356
|
+
receipt: item.receipt
|
|
1357
|
+
};
|
|
1358
|
+
return {
|
|
1359
|
+
kind: "payment",
|
|
1360
|
+
reference: actor === void 0 ? {
|
|
1361
|
+
kind: "payment",
|
|
1362
|
+
id: item.id
|
|
1363
|
+
} : {
|
|
1364
|
+
kind: "payment",
|
|
1365
|
+
id: item.id,
|
|
1366
|
+
actor
|
|
1367
|
+
},
|
|
1368
|
+
direction: item.direction,
|
|
1369
|
+
counterpartyLabel: item.counterpartyLabel,
|
|
1370
|
+
amountDisplay,
|
|
1371
|
+
signedAmountDisplay,
|
|
1372
|
+
currency: item.amount.currency,
|
|
1373
|
+
createdAt: item.createdAt,
|
|
1374
|
+
ledgerKind: item.ledgerKind,
|
|
1375
|
+
paymentType: item.paymentType,
|
|
1376
|
+
status: item.status,
|
|
1377
|
+
phase: paymentPhase(item.status),
|
|
1378
|
+
receipt: item.receipt
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
/**
|
|
1382
|
+
* The exact field set `.Search` matches, case-insensitively, joined with a
|
|
1383
|
+
* space. Two of today's searchable strings drop out on purpose: `account` is
|
|
1384
|
+
* the currency-to-name guess this item deletes, and `statusLabel` is app copy —
|
|
1385
|
+
* the Status dropdown, wired up in the same change, is the affordance for
|
|
1386
|
+
* filtering by status and it uses the app's own words.
|
|
1387
|
+
*/
|
|
1388
|
+
function searchableText(row) {
|
|
1389
|
+
return (row.kind === "payment" ? [
|
|
1390
|
+
row.counterpartyLabel,
|
|
1391
|
+
row.amountDisplay,
|
|
1392
|
+
row.status,
|
|
1393
|
+
row.paymentType,
|
|
1394
|
+
row.ledgerKind
|
|
1395
|
+
] : [
|
|
1396
|
+
row.counterpartyLabel,
|
|
1397
|
+
row.amountDisplay,
|
|
1398
|
+
row.classification
|
|
1399
|
+
]).join(" ").toLowerCase();
|
|
1400
|
+
}
|
|
1401
|
+
function matchesSearch(row, query) {
|
|
1402
|
+
const needle = query.trim().toLowerCase();
|
|
1403
|
+
return needle === "" || searchableText(row).includes(needle);
|
|
1404
|
+
}
|
|
1405
|
+
//#endregion
|
|
1406
|
+
//#region src/headless/activity/csv.ts
|
|
1407
|
+
/** In this order. A header row is always written, even with no rows. */
|
|
1408
|
+
const COLUMNS = [
|
|
1409
|
+
"Date",
|
|
1410
|
+
"Kind",
|
|
1411
|
+
"Type",
|
|
1412
|
+
"Counterparty",
|
|
1413
|
+
"Direction",
|
|
1414
|
+
"Amount",
|
|
1415
|
+
"Currency",
|
|
1416
|
+
"Status",
|
|
1417
|
+
"Reference",
|
|
1418
|
+
"Transaction hash"
|
|
1419
|
+
];
|
|
1420
|
+
const FORMULA_LEAD = /^[=+\-@\t\r\n]/;
|
|
1421
|
+
const PLAIN_NUMBER = /^-?\d+(\.\d+)?$/;
|
|
1422
|
+
/**
|
|
1423
|
+
* RFC 4180: every field is quoted, and an embedded quote is doubled. A field
|
|
1424
|
+
* that would open a formula is first prefixed with an apostrophe, which the
|
|
1425
|
+
* spreadsheet consumes and renders as text.
|
|
1426
|
+
*/
|
|
1427
|
+
function escape(field) {
|
|
1428
|
+
return `"${(FORMULA_LEAD.test(field) && !PLAIN_NUMBER.test(field) ? `'${field}` : field).replaceAll("\"", "\"\"")}"`;
|
|
1429
|
+
}
|
|
1430
|
+
function record(fields) {
|
|
1431
|
+
return fields.map(escape).join(",");
|
|
1432
|
+
}
|
|
1433
|
+
/**
|
|
1434
|
+
* `Amount` is the major-unit decimal value, not the display string, because a
|
|
1435
|
+
* CSV is data and the sheet has its own `Currency` column to pair it with. The
|
|
1436
|
+
* sign lives in `Direction`, which is the code the row carries.
|
|
1437
|
+
*/
|
|
1438
|
+
function buildCsv(items, actor, search) {
|
|
1439
|
+
const records = [record(COLUMNS)];
|
|
1440
|
+
for (const item of items) {
|
|
1441
|
+
const row = toActivityRow(item, actor);
|
|
1442
|
+
if (!matchesSearch(row, search)) continue;
|
|
1443
|
+
records.push(record([
|
|
1444
|
+
new Date(row.createdAt).toISOString(),
|
|
1445
|
+
row.kind,
|
|
1446
|
+
row.kind === "payment" ? row.paymentType : row.classification,
|
|
1447
|
+
row.counterpartyLabel,
|
|
1448
|
+
row.direction,
|
|
1449
|
+
item.amount.value,
|
|
1450
|
+
item.amount.currency,
|
|
1451
|
+
row.kind === "payment" ? row.status : "",
|
|
1452
|
+
row.reference.id,
|
|
1453
|
+
row.receipt?.transactionHash ?? ""
|
|
1454
|
+
]));
|
|
1455
|
+
}
|
|
1456
|
+
return {
|
|
1457
|
+
text: records.join("\r\n"),
|
|
1458
|
+
rowCount: records.length - 1
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
//#endregion
|
|
1462
|
+
//#region src/headless/activity/use-activity.ts
|
|
1463
|
+
/** The band is the backend's own — it hard-rejects a limit outside it. */
|
|
1464
|
+
const MIN_PAGE_SIZE = 1;
|
|
1465
|
+
const MAX_PAGE_SIZE = 100;
|
|
1466
|
+
const DEFAULT_PAGE_SIZE = 25;
|
|
1467
|
+
/**
|
|
1468
|
+
* The export ceiling is HARD: 50 pages of 100 is at most 5,000 rows. An
|
|
1469
|
+
* unbounded `do…while (cursor)` drain is the exact defect this item deletes
|
|
1470
|
+
* from the app; re-creating it inside the SDK would move the bug, not fix it.
|
|
1471
|
+
*/
|
|
1472
|
+
const EXPORT_PAGE_SIZE = MAX_PAGE_SIZE;
|
|
1473
|
+
const EXPORT_PAGE_CEILING = 50;
|
|
1474
|
+
const KIND_OPTIONS = Object.freeze(["payment", "movement"]);
|
|
1475
|
+
/** Clamps into 1..100. A non-number, or a fraction, lands on the default band. */
|
|
1476
|
+
function clampPageSize(pageSize) {
|
|
1477
|
+
if (pageSize === void 0 || !Number.isFinite(pageSize)) return DEFAULT_PAGE_SIZE;
|
|
1478
|
+
return Math.min(MAX_PAGE_SIZE, Math.max(MIN_PAGE_SIZE, Math.trunc(pageSize)));
|
|
1479
|
+
}
|
|
1480
|
+
function useActivity(input) {
|
|
1481
|
+
const { actor, pageSize } = input;
|
|
1482
|
+
const client = useCapxulClientOrNull();
|
|
1483
|
+
const enabled = useActivityEnabled();
|
|
1484
|
+
const limit = clampPageSize(pageSize);
|
|
1485
|
+
const warned = useRef(false);
|
|
1486
|
+
useEffect(() => {
|
|
1487
|
+
if (warned.current || pageSize === void 0 || pageSize === limit) return;
|
|
1488
|
+
warned.current = true;
|
|
1489
|
+
if (process.env.NODE_ENV !== "production") console.warn(`CapxulActivity: pageSize ${String(pageSize)} is outside 1..100 and was clamped to ${String(limit)}.`);
|
|
1490
|
+
}, [limit, pageSize]);
|
|
1491
|
+
const [kind, setKind] = useState(void 0);
|
|
1492
|
+
const [direction, setDirection] = useState(void 0);
|
|
1493
|
+
const [status, setStatus] = useState([]);
|
|
1494
|
+
const [searchText, setSearchText] = useState("");
|
|
1495
|
+
const values = useMemo(() => ({
|
|
1496
|
+
...kind === void 0 ? {} : { kind },
|
|
1497
|
+
...direction === void 0 ? {} : { direction },
|
|
1498
|
+
...status.length === 0 ? {} : { status }
|
|
1499
|
+
}), [
|
|
1500
|
+
direction,
|
|
1501
|
+
kind,
|
|
1502
|
+
status
|
|
1503
|
+
]);
|
|
1504
|
+
const filter = useMemo(() => Object.keys(values).length === 0 ? void 0 : values, [values]);
|
|
1505
|
+
const listParams = useCallback((cursor, pageLimit) => {
|
|
1506
|
+
const params = { limit: pageLimit };
|
|
1507
|
+
if (actor !== void 0) params.actor = actor;
|
|
1508
|
+
if (cursor !== void 0) params.cursor = cursor;
|
|
1509
|
+
if (filter !== void 0) params.filter = filter;
|
|
1510
|
+
return params;
|
|
1511
|
+
}, [actor, filter]);
|
|
1512
|
+
const query = useInfiniteQuery({
|
|
1513
|
+
queryKey: [
|
|
1514
|
+
...capxulKeys.activity,
|
|
1515
|
+
"list",
|
|
1516
|
+
actor ?? "self",
|
|
1517
|
+
limit,
|
|
1518
|
+
filter ?? "none"
|
|
1519
|
+
],
|
|
1520
|
+
queryFn: async ({ pageParam }) => {
|
|
1521
|
+
return unwrapCapxulResult(await requireBootstrappedClient(client, "activity.list").activity.list(listParams(pageParam, limit)));
|
|
1522
|
+
},
|
|
1523
|
+
initialPageParam: void 0,
|
|
1524
|
+
getNextPageParam: (last) => last.cursor ?? void 0,
|
|
1525
|
+
enabled
|
|
1526
|
+
});
|
|
1527
|
+
const pages = query.data?.pages;
|
|
1528
|
+
const rows = useMemo(() => {
|
|
1529
|
+
return (pages?.flatMap((page) => page.items) ?? []).map((item) => toActivityRow(item, actor)).filter((row) => matchesSearch(row, searchText));
|
|
1530
|
+
}, [
|
|
1531
|
+
actor,
|
|
1532
|
+
pages,
|
|
1533
|
+
searchText
|
|
1534
|
+
]);
|
|
1535
|
+
const { fetchNextPage, hasNextPage, isFetchingNextPage, refetch } = query;
|
|
1536
|
+
const pagingRef = useRef({
|
|
1537
|
+
fetchNextPage,
|
|
1538
|
+
hasNextPage,
|
|
1539
|
+
isFetchingNextPage
|
|
1540
|
+
});
|
|
1541
|
+
useEffect(() => {
|
|
1542
|
+
pagingRef.current = {
|
|
1543
|
+
fetchNextPage,
|
|
1544
|
+
hasNextPage,
|
|
1545
|
+
isFetchingNextPage
|
|
1546
|
+
};
|
|
1547
|
+
});
|
|
1548
|
+
const loadMore = useCallback(() => {
|
|
1549
|
+
const paging = pagingRef.current;
|
|
1550
|
+
if (!paging.hasNextPage || paging.isFetchingNextPage) return;
|
|
1551
|
+
paging.fetchNextPage();
|
|
1552
|
+
}, []);
|
|
1553
|
+
const observerRef = useRef(null);
|
|
1554
|
+
const sentinelRef = useCallback((node) => {
|
|
1555
|
+
observerRef.current?.disconnect();
|
|
1556
|
+
observerRef.current = null;
|
|
1557
|
+
if (node === null || typeof IntersectionObserver === "undefined") return;
|
|
1558
|
+
const observer = new IntersectionObserver((entries) => {
|
|
1559
|
+
if (entries.some((entry) => entry.isIntersecting)) loadMore();
|
|
1560
|
+
}, {
|
|
1561
|
+
root: null,
|
|
1562
|
+
rootMargin: "200px",
|
|
1563
|
+
threshold: 0
|
|
1564
|
+
});
|
|
1565
|
+
observer.observe(node);
|
|
1566
|
+
observerRef.current = observer;
|
|
1567
|
+
}, [loadMore]);
|
|
1568
|
+
const [isExporting, setIsExporting] = useState(false);
|
|
1569
|
+
const toCsv = useCallback(async () => {
|
|
1570
|
+
setIsExporting(true);
|
|
1571
|
+
try {
|
|
1572
|
+
const bootstrapped = requireBootstrappedClient(client, "activity.list");
|
|
1573
|
+
const items = [];
|
|
1574
|
+
let cursor;
|
|
1575
|
+
for (let page = 0; page < EXPORT_PAGE_CEILING; page += 1) {
|
|
1576
|
+
const result = unwrapCapxulResult(await bootstrapped.activity.list(listParams(cursor, EXPORT_PAGE_SIZE)));
|
|
1577
|
+
items.push(...result.items);
|
|
1578
|
+
cursor = result.cursor ?? void 0;
|
|
1579
|
+
if (cursor === void 0) break;
|
|
1580
|
+
}
|
|
1581
|
+
return {
|
|
1582
|
+
...buildCsv(items, actor, searchText),
|
|
1583
|
+
truncated: cursor !== void 0
|
|
1584
|
+
};
|
|
1585
|
+
} finally {
|
|
1586
|
+
setIsExporting(false);
|
|
1587
|
+
}
|
|
1588
|
+
}, [
|
|
1589
|
+
actor,
|
|
1590
|
+
client,
|
|
1591
|
+
listParams,
|
|
1592
|
+
searchText
|
|
1593
|
+
]);
|
|
1594
|
+
const clear = useCallback(() => {
|
|
1595
|
+
setKind(void 0);
|
|
1596
|
+
setDirection(void 0);
|
|
1597
|
+
setStatus([]);
|
|
1598
|
+
}, []);
|
|
1599
|
+
const retry = useCallback(() => {
|
|
1600
|
+
refetch();
|
|
1601
|
+
}, [refetch]);
|
|
1602
|
+
return {
|
|
1603
|
+
actor,
|
|
1604
|
+
filters: {
|
|
1605
|
+
values,
|
|
1606
|
+
set: {
|
|
1607
|
+
kind: setKind,
|
|
1608
|
+
direction: setDirection,
|
|
1609
|
+
status: setStatus,
|
|
1610
|
+
clear
|
|
1611
|
+
},
|
|
1612
|
+
options: {
|
|
1613
|
+
kind: KIND_OPTIONS,
|
|
1614
|
+
direction: PAYMENT_DIRECTIONS,
|
|
1615
|
+
status: PAYMENT_STATUSES
|
|
1616
|
+
}
|
|
1617
|
+
},
|
|
1618
|
+
search: {
|
|
1619
|
+
value: searchText,
|
|
1620
|
+
change: setSearchText
|
|
1621
|
+
},
|
|
1622
|
+
rows: {
|
|
1623
|
+
rows,
|
|
1624
|
+
isLoading: query.isLoading,
|
|
1625
|
+
error: asCapxulError$1(query.error),
|
|
1626
|
+
retry,
|
|
1627
|
+
observedAt: pages === void 0 ? null : pages[pages.length - 1]?.observedAt ?? null
|
|
1628
|
+
},
|
|
1629
|
+
loadMore: {
|
|
1630
|
+
hasMore: hasNextPage,
|
|
1631
|
+
loadMore,
|
|
1632
|
+
isLoadingMore: isFetchingNextPage,
|
|
1633
|
+
sentinelRef
|
|
1634
|
+
},
|
|
1635
|
+
csv: {
|
|
1636
|
+
toCsv,
|
|
1637
|
+
isExporting
|
|
1638
|
+
}
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
//#endregion
|
|
1642
|
+
//#region src/headless/activity/activity.tsx
|
|
1643
|
+
const ActivityContext = createContext(null);
|
|
1644
|
+
function useActivityContext() {
|
|
1645
|
+
const engine = useContext(ActivityContext);
|
|
1646
|
+
if (engine === null) throw new Error("CapxulActivity parts must be used inside <CapxulActivity>");
|
|
1647
|
+
return engine;
|
|
1648
|
+
}
|
|
1649
|
+
function Root$5({ actor, pageSize, children }) {
|
|
1650
|
+
const engine = useActivity({
|
|
1651
|
+
...actor === void 0 ? {} : { actor },
|
|
1652
|
+
...pageSize === void 0 ? {} : { pageSize }
|
|
1653
|
+
});
|
|
1654
|
+
return /* @__PURE__ */ jsx(ActivityContext.Provider, {
|
|
1655
|
+
value: engine,
|
|
1656
|
+
children
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
/**
|
|
1660
|
+
* The one part that holds a query rather than reading the engine's: it takes a
|
|
1661
|
+
* `window` prop, and two placements can ask for different windows, so a single
|
|
1662
|
+
* pre-fetched result cannot serve both. It still holds no logic and draws
|
|
1663
|
+
* nothing.
|
|
1664
|
+
*/
|
|
1665
|
+
function Summary$2({ window = "all", children }) {
|
|
1666
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(useActivitySummary(useActivityContext().actor, window)) });
|
|
1667
|
+
}
|
|
1668
|
+
function Filters({ children }) {
|
|
1669
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(useActivityContext().filters) });
|
|
1670
|
+
}
|
|
1671
|
+
function Search({ children }) {
|
|
1672
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(useActivityContext().search) });
|
|
1673
|
+
}
|
|
1674
|
+
function Rows({ children }) {
|
|
1675
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(useActivityContext().rows) });
|
|
1676
|
+
}
|
|
1677
|
+
function LoadMore({ children }) {
|
|
1678
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(useActivityContext().loadMore) });
|
|
1679
|
+
}
|
|
1680
|
+
/**
|
|
1681
|
+
* The second part that holds a query: it takes a `reference` prop and the root
|
|
1682
|
+
* has no idea which row was clicked. With no `reference` it issues no request.
|
|
1683
|
+
*/
|
|
1684
|
+
function DetailInstance({ actor, reference, children }) {
|
|
1685
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(useActivityDetail(reference, actor)) });
|
|
1686
|
+
}
|
|
1687
|
+
function Detail({ reference, children }) {
|
|
1688
|
+
const { actor } = useActivityContext();
|
|
1689
|
+
return /* @__PURE__ */ jsx(DetailInstance, {
|
|
1690
|
+
actor,
|
|
1691
|
+
reference,
|
|
1692
|
+
children
|
|
1693
|
+
}, activityDetailKey(actor, reference));
|
|
1694
|
+
}
|
|
1695
|
+
function Export({ children }) {
|
|
1696
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(useActivityContext().csv) });
|
|
1697
|
+
}
|
|
1698
|
+
/** The compound shape the app's own design system already uses (`StatusTabs`). */
|
|
1699
|
+
const CapxulActivity = Object.assign(Root$5, {
|
|
1700
|
+
Summary: Summary$2,
|
|
1701
|
+
Filters,
|
|
1702
|
+
Search,
|
|
1703
|
+
Rows,
|
|
1704
|
+
LoadMore,
|
|
1705
|
+
Detail,
|
|
1706
|
+
Export
|
|
1707
|
+
});
|
|
1708
|
+
//#endregion
|
|
1046
1709
|
//#region src/headless/send-money/signer-status.ts
|
|
1047
1710
|
const NO_SUBSCRIPTION = () => () => {};
|
|
1048
1711
|
function useSignerStatus(client) {
|
|
@@ -1205,7 +1868,7 @@ function useSendMoneyContext() {
|
|
|
1205
1868
|
if (engine === null) throw new Error("CapxulSendMoney parts must be used inside <CapxulSendMoney>");
|
|
1206
1869
|
return engine;
|
|
1207
1870
|
}
|
|
1208
|
-
function Root$
|
|
1871
|
+
function Root$4({ actor, onSent, onFailed, children }) {
|
|
1209
1872
|
const engine = useSendMoney({
|
|
1210
1873
|
actor,
|
|
1211
1874
|
onSent,
|
|
@@ -1216,7 +1879,7 @@ function Root$2({ actor, onSent, onFailed, children }) {
|
|
|
1216
1879
|
children
|
|
1217
1880
|
});
|
|
1218
1881
|
}
|
|
1219
|
-
function Asset({ children }) {
|
|
1882
|
+
function Asset$1({ children }) {
|
|
1220
1883
|
return /* @__PURE__ */ jsx(Fragment, { children: children(useSendMoneyContext().asset) });
|
|
1221
1884
|
}
|
|
1222
1885
|
function Amount({ children }) {
|
|
@@ -1225,15 +1888,15 @@ function Amount({ children }) {
|
|
|
1225
1888
|
function Recipient({ children }) {
|
|
1226
1889
|
return /* @__PURE__ */ jsx(Fragment, { children: children(useSendMoneyContext().recipient) });
|
|
1227
1890
|
}
|
|
1228
|
-
function Actions({ children }) {
|
|
1891
|
+
function Actions$1({ children }) {
|
|
1229
1892
|
return /* @__PURE__ */ jsx(Fragment, { children: children(useSendMoneyContext().actions) });
|
|
1230
1893
|
}
|
|
1231
1894
|
/** The compound shape the app's own design system already uses (`StatusTabs`). */
|
|
1232
|
-
const CapxulSendMoney = Object.assign(Root$
|
|
1233
|
-
Asset,
|
|
1895
|
+
const CapxulSendMoney = Object.assign(Root$4, {
|
|
1896
|
+
Asset: Asset$1,
|
|
1234
1897
|
Amount,
|
|
1235
1898
|
Recipient,
|
|
1236
|
-
Actions
|
|
1899
|
+
Actions: Actions$1
|
|
1237
1900
|
});
|
|
1238
1901
|
//#endregion
|
|
1239
1902
|
//#region src/headless/access/org-member.tsx
|
|
@@ -1344,7 +2007,7 @@ function useOrgMemberContext() {
|
|
|
1344
2007
|
if (state === null) throw new Error("CapxulOrgMember parts must be used inside <CapxulOrgMember>");
|
|
1345
2008
|
return state;
|
|
1346
2009
|
}
|
|
1347
|
-
function Root$
|
|
2010
|
+
function Root$3({ orgId, children }) {
|
|
1348
2011
|
const state = useOrgMember(orgId);
|
|
1349
2012
|
return /* @__PURE__ */ jsx(OrgMemberContext.Provider, {
|
|
1350
2013
|
value: state,
|
|
@@ -1364,7 +2027,7 @@ function Standing({ children }) {
|
|
|
1364
2027
|
return /* @__PURE__ */ jsx(Fragment, { children: children(useOrgMemberContext().standing) });
|
|
1365
2028
|
}
|
|
1366
2029
|
/** The compound shape the app's own design system already uses (`StatusTabs`). */
|
|
1367
|
-
const CapxulOrgMember = Object.assign(Root$
|
|
2030
|
+
const CapxulOrgMember = Object.assign(Root$3, {
|
|
1368
2031
|
Can,
|
|
1369
2032
|
Standing
|
|
1370
2033
|
});
|
|
@@ -1511,7 +2174,7 @@ function usePart(phase) {
|
|
|
1511
2174
|
}, [placed, phase]);
|
|
1512
2175
|
return state.phase === phase ? state : null;
|
|
1513
2176
|
}
|
|
1514
|
-
function Root({ scope, children }) {
|
|
2177
|
+
function Root$2({ scope, children }) {
|
|
1515
2178
|
const state = useDashboardAccess(scope);
|
|
1516
2179
|
const [placed] = useState(() => /* @__PURE__ */ new Set());
|
|
1517
2180
|
const value = useMemo(() => ({
|
|
@@ -1549,11 +2212,591 @@ function Granted({ children }) {
|
|
|
1549
2212
|
return usePart("granted") === null ? null : /* @__PURE__ */ jsx(Fragment, { children });
|
|
1550
2213
|
}
|
|
1551
2214
|
/** The compound shape the app's own design system already uses (`StatusTabs`). */
|
|
1552
|
-
const CapxulDashboardAccess = Object.assign(Root, {
|
|
2215
|
+
const CapxulDashboardAccess = Object.assign(Root$2, {
|
|
1553
2216
|
Checking,
|
|
1554
2217
|
Error: DashboardError,
|
|
1555
2218
|
Redirect,
|
|
1556
2219
|
Granted
|
|
1557
2220
|
});
|
|
1558
2221
|
//#endregion
|
|
1559
|
-
|
|
2222
|
+
//#region src/headless/payroll/money.ts
|
|
2223
|
+
/** `{ value: "12.34", decimals: 6 }` → `12340000n`. */
|
|
2224
|
+
function toMinorUnits(money) {
|
|
2225
|
+
const [whole = "0", fraction = ""] = money.value.split(".");
|
|
2226
|
+
return BigInt(`${whole}${fraction.padEnd(money.decimals, "0")}`);
|
|
2227
|
+
}
|
|
2228
|
+
const TYPED_AMOUNT = /^(-?)(\d+)(?:\.(\d+))?$/;
|
|
2229
|
+
/**
|
|
2230
|
+
* Σ of the employer's TYPED amounts on a saved group — a DISPLAY total.
|
|
2231
|
+
*
|
|
2232
|
+
* A group is a draft, so its amounts were never required to parse (backend
|
|
2233
|
+
* `payroll/groups.ts` validates the name and the recipients, never the money).
|
|
2234
|
+
* What cannot be read is skipped rather than poisoning the card's total, and
|
|
2235
|
+
* the widest fraction any member typed sets the scale so nothing rounds away.
|
|
2236
|
+
*/
|
|
2237
|
+
function sumTypedAmounts(amounts, currency) {
|
|
2238
|
+
const parsed = amounts.map((amount) => TYPED_AMOUNT.exec(amount.trim().replaceAll(",", ""))).filter((match) => match !== null);
|
|
2239
|
+
const decimals = parsed.reduce((widest, match) => Math.max(widest, (match[3] ?? "").length), 0);
|
|
2240
|
+
return fromMinorUnits(parsed.reduce((sum, match) => {
|
|
2241
|
+
const scaled = BigInt(`${match[2] ?? "0"}${(match[3] ?? "").padEnd(decimals, "0")}`);
|
|
2242
|
+
return match[1] === "-" ? sum - scaled : sum + scaled;
|
|
2243
|
+
}, 0n), {
|
|
2244
|
+
currency,
|
|
2245
|
+
decimals
|
|
2246
|
+
});
|
|
2247
|
+
}
|
|
2248
|
+
/** The inverse, so a summed total can be formatted by the shipped `formatMoney`. */
|
|
2249
|
+
function fromMinorUnits(raw, asset) {
|
|
2250
|
+
const negative = raw < 0n;
|
|
2251
|
+
const digits = (negative ? -raw : raw).toString().padStart(asset.decimals + 1, "0");
|
|
2252
|
+
const whole = digits.slice(0, digits.length - asset.decimals);
|
|
2253
|
+
const fraction = asset.decimals === 0 ? "" : `.${digits.slice(digits.length - asset.decimals)}`;
|
|
2254
|
+
return {
|
|
2255
|
+
currency: asset.currency,
|
|
2256
|
+
value: `${negative ? "-" : ""}${whole}${fraction}`,
|
|
2257
|
+
decimals: asset.decimals
|
|
2258
|
+
};
|
|
2259
|
+
}
|
|
2260
|
+
//#endregion
|
|
2261
|
+
//#region src/headless/payroll/reads.ts
|
|
2262
|
+
function toRecipientOption(entry) {
|
|
2263
|
+
return {
|
|
2264
|
+
id: entry.id,
|
|
2265
|
+
name: entry.label,
|
|
2266
|
+
email: entry.label.includes("@") ? entry.label : null
|
|
2267
|
+
};
|
|
2268
|
+
}
|
|
2269
|
+
/**
|
|
2270
|
+
* The roster: active `employed` edges ∪ org members, deduped by party
|
|
2271
|
+
* (ruling Q1 = C).
|
|
2272
|
+
*
|
|
2273
|
+
* The organization address book computes exactly that union server-side —
|
|
2274
|
+
* members accumulate from `orgMembers`, employment is PROJECTED from open
|
|
2275
|
+
* `employed` edges, and both land in ONE map keyed by party
|
|
2276
|
+
* (`financialOps/addressBook.ts`). Contact edges live in the same book and are
|
|
2277
|
+
* not the roster, so the two relationships the roster is made of are the
|
|
2278
|
+
* filter.
|
|
2279
|
+
*/
|
|
2280
|
+
function useOrgRoster(orgId) {
|
|
2281
|
+
const client = useCapxulClientOrNull();
|
|
2282
|
+
const query = useQuery({
|
|
2283
|
+
queryKey: capxulKeys.contactsList(orgId ?? "pending", false),
|
|
2284
|
+
queryFn: async () => unwrapCapxulResult(await scopedClient(client, "org.addressBook.list", orgId).addressBook.list({ includeHidden: false })),
|
|
2285
|
+
enabled: client !== null && orgId !== void 0
|
|
2286
|
+
});
|
|
2287
|
+
const entries = query.data;
|
|
2288
|
+
return useMemo(() => {
|
|
2289
|
+
const roster = (entries ?? []).filter((entry) => entry.relationship.includes("member") || entry.relationship.includes("employed"));
|
|
2290
|
+
return {
|
|
2291
|
+
options: roster.map(toRecipientOption),
|
|
2292
|
+
refs: new Map(roster.map((entry) => [entry.id, entry.ref])),
|
|
2293
|
+
isLoading: orgId !== void 0 && entries === void 0 && query.error === null,
|
|
2294
|
+
error: query.error
|
|
2295
|
+
};
|
|
2296
|
+
}, [
|
|
2297
|
+
entries,
|
|
2298
|
+
orgId,
|
|
2299
|
+
query.error
|
|
2300
|
+
]);
|
|
2301
|
+
}
|
|
2302
|
+
function usePayrollGroups(orgId) {
|
|
2303
|
+
const client = useCapxulClientOrNull();
|
|
2304
|
+
return useQuery({
|
|
2305
|
+
queryKey: capxulKeys.payrollGroups(orgId),
|
|
2306
|
+
queryFn: async () => unwrapCapxulResult(await scopedClient(client, "org.payroll.groups.list", orgId).payroll.groups.list()),
|
|
2307
|
+
enabled: client !== null && orgId !== void 0
|
|
2308
|
+
});
|
|
2309
|
+
}
|
|
2310
|
+
function usePayrollRuns(orgId) {
|
|
2311
|
+
const client = useCapxulClientOrNull();
|
|
2312
|
+
return useQuery({
|
|
2313
|
+
queryKey: capxulKeys.payrollRuns(orgId),
|
|
2314
|
+
queryFn: async () => unwrapCapxulResult(await scopedClient(client, "org.payroll.runs", orgId).payroll.runs()),
|
|
2315
|
+
enabled: client !== null && orgId !== void 0
|
|
2316
|
+
});
|
|
2317
|
+
}
|
|
2318
|
+
function useOrgTreasury(orgId) {
|
|
2319
|
+
const client = useCapxulClientOrNull();
|
|
2320
|
+
return useQuery({
|
|
2321
|
+
queryKey: capxulKeys.orgTreasury(orgId),
|
|
2322
|
+
queryFn: async () => unwrapCapxulResult(await scopedClient(client, "org.treasury", orgId).treasury()),
|
|
2323
|
+
enabled: client !== null && orgId !== void 0
|
|
2324
|
+
});
|
|
2325
|
+
}
|
|
2326
|
+
/**
|
|
2327
|
+
* The caller's own standing, read for its Budgets alone (ruling Q2 grades
|
|
2328
|
+
* them). Keyed as `CapxulOrgMember` keys it, so a payroll screen inside a
|
|
2329
|
+
* `.Can` wrapper reads `org(id).me()` once.
|
|
2330
|
+
*/
|
|
2331
|
+
function useOrgMe(orgId) {
|
|
2332
|
+
const client = useCapxulClientOrNull();
|
|
2333
|
+
return useQuery({
|
|
2334
|
+
queryKey: capxulKeys.orgMe(orgId),
|
|
2335
|
+
queryFn: async () => unwrapCapxulResult(await scopedClient(client, "org.me", orgId).me()),
|
|
2336
|
+
enabled: client !== null && orgId !== void 0
|
|
2337
|
+
});
|
|
2338
|
+
}
|
|
2339
|
+
/**
|
|
2340
|
+
* The org-scoped bundle inside a query or mutation function. Every caller gates
|
|
2341
|
+
* on `orgId !== undefined`, so the second throw is only reachable from a
|
|
2342
|
+
* mutation fired while the provider is still bootstrapping.
|
|
2343
|
+
*/
|
|
2344
|
+
function scopedClient(client, method, orgId) {
|
|
2345
|
+
const bootstrapped = requireBootstrappedClient(client, method);
|
|
2346
|
+
if (orgId === void 0) throw Errors.invalidInput("orgId", `required for ${method}`);
|
|
2347
|
+
return bootstrapped.org(orgId);
|
|
2348
|
+
}
|
|
2349
|
+
//#endregion
|
|
2350
|
+
//#region src/headless/payroll/use-payroll.ts
|
|
2351
|
+
/** Money reads the same way everywhere on this screen: amount then code. */
|
|
2352
|
+
const GRAMMAR$1 = { grammar: "code" };
|
|
2353
|
+
function toPayrollGroupRow(group) {
|
|
2354
|
+
const currency = new Set(group.members.map((member) => member.currency)).size === 1 ? group.members[0]?.currency : void 0;
|
|
2355
|
+
return {
|
|
2356
|
+
id: group.id,
|
|
2357
|
+
name: group.name,
|
|
2358
|
+
tone: group.tone,
|
|
2359
|
+
memberCount: group.members.length,
|
|
2360
|
+
totalDisplay: currency === void 0 ? "" : formatMoney(sumTypedAmounts(group.members.map((member) => member.amount), currency), GRAMMAR$1),
|
|
2361
|
+
members: group.members.map((member) => ({
|
|
2362
|
+
recipientId: member.partyId,
|
|
2363
|
+
amount: member.amount,
|
|
2364
|
+
currency: member.currency
|
|
2365
|
+
}))
|
|
2366
|
+
};
|
|
2367
|
+
}
|
|
2368
|
+
/**
|
|
2369
|
+
* The validity `buildOrganizationPayrollGroupRecord` applied in the browser
|
|
2370
|
+
* (payroll groups hook `:57-82`) — a trimmed name, at least one deduped
|
|
2371
|
+
* recipient — checked here so an invalid group never costs a round trip. The
|
|
2372
|
+
* backend re-checks it; this is honest UX, not the authority.
|
|
2373
|
+
*
|
|
2374
|
+
* ADR-0023 R1: the refusal is a CODE, never a toast sentence.
|
|
2375
|
+
*/
|
|
2376
|
+
function invalidGroup(input) {
|
|
2377
|
+
if (input.name.trim().length === 0) return Errors.invalidInput("name", "must not be empty");
|
|
2378
|
+
const deduped = new Set(input.members.map((member) => member.partyId));
|
|
2379
|
+
deduped.delete("");
|
|
2380
|
+
return deduped.size === 0 ? Errors.invalidInput("members", "must not be empty") : null;
|
|
2381
|
+
}
|
|
2382
|
+
function usePayroll(orgId) {
|
|
2383
|
+
const client = useCapxulClientOrNull();
|
|
2384
|
+
const queryClient = useQueryClient();
|
|
2385
|
+
const treasury = useOrgTreasury(orgId);
|
|
2386
|
+
const runs = usePayrollRuns(orgId);
|
|
2387
|
+
const groups = usePayrollGroups(orgId);
|
|
2388
|
+
const write = useMutation({
|
|
2389
|
+
mutationFn: async (command) => {
|
|
2390
|
+
const payroll = scopedClient(client, `org.payroll.groups.${command.kind}`, orgId).payroll;
|
|
2391
|
+
if (command.kind === "remove") {
|
|
2392
|
+
unwrapCapxulResult(await payroll.groups.remove(command.id));
|
|
2393
|
+
return null;
|
|
2394
|
+
}
|
|
2395
|
+
return unwrapCapxulResult(await payroll.groups.save(command.input));
|
|
2396
|
+
},
|
|
2397
|
+
onSettled: useCallback(() => queryClient.invalidateQueries({ queryKey: capxulKeys.payrollGroups(orgId) }), [orgId, queryClient])
|
|
2398
|
+
});
|
|
2399
|
+
const mutateAsync = write.mutateAsync;
|
|
2400
|
+
const save = useCallback(async (input) => {
|
|
2401
|
+
const refusal = invalidGroup(input);
|
|
2402
|
+
if (refusal !== null) return {
|
|
2403
|
+
ok: false,
|
|
2404
|
+
error: refusal
|
|
2405
|
+
};
|
|
2406
|
+
try {
|
|
2407
|
+
const saved = await mutateAsync({
|
|
2408
|
+
kind: "save",
|
|
2409
|
+
input
|
|
2410
|
+
});
|
|
2411
|
+
return saved === null ? {
|
|
2412
|
+
ok: false,
|
|
2413
|
+
error: Errors.unknown()
|
|
2414
|
+
} : {
|
|
2415
|
+
ok: true,
|
|
2416
|
+
value: toPayrollGroupRow(saved)
|
|
2417
|
+
};
|
|
2418
|
+
} catch (cause) {
|
|
2419
|
+
return {
|
|
2420
|
+
ok: false,
|
|
2421
|
+
error: asCapxulError(cause)
|
|
2422
|
+
};
|
|
2423
|
+
}
|
|
2424
|
+
}, [mutateAsync]);
|
|
2425
|
+
const remove = useCallback(async (id) => {
|
|
2426
|
+
try {
|
|
2427
|
+
await mutateAsync({
|
|
2428
|
+
kind: "remove",
|
|
2429
|
+
id
|
|
2430
|
+
});
|
|
2431
|
+
return {
|
|
2432
|
+
ok: true,
|
|
2433
|
+
value: void 0
|
|
2434
|
+
};
|
|
2435
|
+
} catch (cause) {
|
|
2436
|
+
return {
|
|
2437
|
+
ok: false,
|
|
2438
|
+
error: asCapxulError(cause)
|
|
2439
|
+
};
|
|
2440
|
+
}
|
|
2441
|
+
}, [mutateAsync]);
|
|
2442
|
+
const rows = useMemo(() => (groups.data ?? []).map(toPayrollGroupRow), [groups.data]);
|
|
2443
|
+
const started = orgId !== void 0;
|
|
2444
|
+
return {
|
|
2445
|
+
summary: {
|
|
2446
|
+
walletBalanceDisplay: treasury.data === void 0 ? null : formatMoney(treasury.data.available, GRAMMAR$1),
|
|
2447
|
+
paidThisMonthDisplay: runs.data === void 0 ? null : formatMoney(runs.data.settledThisMonth, GRAMMAR$1),
|
|
2448
|
+
isLoading: started && (treasury.data === void 0 && treasury.error === null || runs.data === void 0 && runs.error === null),
|
|
2449
|
+
error: treasury.error ?? runs.error
|
|
2450
|
+
},
|
|
2451
|
+
groups: {
|
|
2452
|
+
groups: rows,
|
|
2453
|
+
save,
|
|
2454
|
+
remove,
|
|
2455
|
+
isSaving: write.isPending,
|
|
2456
|
+
isLoading: started && groups.data === void 0 && groups.error === null,
|
|
2457
|
+
error: groups.error
|
|
2458
|
+
}
|
|
2459
|
+
};
|
|
2460
|
+
}
|
|
2461
|
+
/**
|
|
2462
|
+
* React Query rejects with whatever the query function threw. Every throw on
|
|
2463
|
+
* this path is a `CapxulError` the SDK produced; anything else is a defect and
|
|
2464
|
+
* normalizes once, exactly as the facade boundary does.
|
|
2465
|
+
*/
|
|
2466
|
+
function asCapxulError(cause) {
|
|
2467
|
+
return isCapxulError$1(cause) ? cause : Errors.unknown(cause);
|
|
2468
|
+
}
|
|
2469
|
+
function isCapxulError$1(cause) {
|
|
2470
|
+
return typeof cause === "object" && cause !== null && "code" in cause;
|
|
2471
|
+
}
|
|
2472
|
+
//#endregion
|
|
2473
|
+
//#region src/headless/payroll/payroll.tsx
|
|
2474
|
+
const PayrollContext = createContext(null);
|
|
2475
|
+
function usePayrollContext() {
|
|
2476
|
+
const engine = useContext(PayrollContext);
|
|
2477
|
+
if (engine === null) throw new Error("CapxulPayroll parts must be used inside <CapxulPayroll>");
|
|
2478
|
+
return engine;
|
|
2479
|
+
}
|
|
2480
|
+
function Root$1({ orgId, children }) {
|
|
2481
|
+
const engine = usePayroll(orgId);
|
|
2482
|
+
return /* @__PURE__ */ jsx(PayrollContext.Provider, {
|
|
2483
|
+
value: engine,
|
|
2484
|
+
children
|
|
2485
|
+
});
|
|
2486
|
+
}
|
|
2487
|
+
function Summary$1({ children }) {
|
|
2488
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(usePayrollContext().summary) });
|
|
2489
|
+
}
|
|
2490
|
+
function Groups({ children }) {
|
|
2491
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(usePayrollContext().groups) });
|
|
2492
|
+
}
|
|
2493
|
+
/**
|
|
2494
|
+
* The compound shape the app's own design system already uses (`StatusTabs`).
|
|
2495
|
+
* There is NO `.Runs` part (ruling Q6): no shipped region has the right shape
|
|
2496
|
+
* for a run list, and a part without a production caller may not ship.
|
|
2497
|
+
*/
|
|
2498
|
+
const CapxulPayroll = Object.assign(Root$1, {
|
|
2499
|
+
Summary: Summary$1,
|
|
2500
|
+
Groups
|
|
2501
|
+
});
|
|
2502
|
+
//#endregion
|
|
2503
|
+
//#region src/headless/payroll/use-payroll-run.ts
|
|
2504
|
+
const GRAMMAR = { grammar: "code" };
|
|
2505
|
+
/**
|
|
2506
|
+
* The one place the composer states a currency it has not read yet. The token
|
|
2507
|
+
* dropdown holds exactly one option — the treasury's — so before that read
|
|
2508
|
+
* lands there is no currency to name, and naming a wrong one would be a fake
|
|
2509
|
+
* green on the money itself.
|
|
2510
|
+
*/
|
|
2511
|
+
const CURRENCY_UNREAD = "";
|
|
2512
|
+
/**
|
|
2513
|
+
* WHICH run the composer is composing, by MEANING rather than by object shape.
|
|
2514
|
+
*
|
|
2515
|
+
* `recipientIds` order is meaning — it is the order `.Amounts` rows appear in.
|
|
2516
|
+
* The `amounts` order is not, so the key reads the seed `initialAmounts` will
|
|
2517
|
+
* actually build rather than the array it was handed: one collapse rule, in one
|
|
2518
|
+
* place, so the two can never disagree about a repeated recipient. A parent
|
|
2519
|
+
* that rebuilds an equivalent `prefill` therefore keeps its key, and the
|
|
2520
|
+
* employer's in-progress edits with it. An absent `prefill` and one that seeds
|
|
2521
|
+
* nothing open the same empty composer, so they share a key on purpose.
|
|
2522
|
+
*/
|
|
2523
|
+
function composerIdentity(orgId, prefill) {
|
|
2524
|
+
const seeds = Object.entries(initialAmounts(prefill)).map(([recipientId, draft]) => `${recipientId}=${draft.value}`).sort();
|
|
2525
|
+
return [
|
|
2526
|
+
orgId ?? "",
|
|
2527
|
+
...prefill?.recipientIds ?? [],
|
|
2528
|
+
"|",
|
|
2529
|
+
...seeds
|
|
2530
|
+
].join("\0");
|
|
2531
|
+
}
|
|
2532
|
+
/** Seeded from `prefill` on first render; the employer owns it after that. */
|
|
2533
|
+
function initialAmounts(prefill) {
|
|
2534
|
+
const seeded = {};
|
|
2535
|
+
for (const entry of prefill?.amounts ?? []) seeded[entry.recipientId] = {
|
|
2536
|
+
value: entry.amount,
|
|
2537
|
+
prefilled: true
|
|
2538
|
+
};
|
|
2539
|
+
return seeded;
|
|
2540
|
+
}
|
|
2541
|
+
function usePayrollRun(input) {
|
|
2542
|
+
const { orgId, prefill, onRun, onFailed } = input;
|
|
2543
|
+
const client = useCapxulClientOrNull();
|
|
2544
|
+
const queryClient = useQueryClient();
|
|
2545
|
+
const signerStatus = useSignerStatus(client);
|
|
2546
|
+
const roster = useOrgRoster(orgId);
|
|
2547
|
+
const groups = usePayrollGroups(orgId);
|
|
2548
|
+
const treasury = useOrgTreasury(orgId);
|
|
2549
|
+
const me = useOrgMe(orgId);
|
|
2550
|
+
const [selected, setSelected] = useState(() => prefill?.recipientIds ?? []);
|
|
2551
|
+
const [amounts, setAmounts] = useState(() => initialAmounts(prefill));
|
|
2552
|
+
const composerKey = composerIdentity(orgId, prefill);
|
|
2553
|
+
const [seededKey, setSeededKey] = useState(composerKey);
|
|
2554
|
+
if (seededKey !== composerKey) {
|
|
2555
|
+
setSeededKey(composerKey);
|
|
2556
|
+
setSelected(prefill?.recipientIds ?? []);
|
|
2557
|
+
setAmounts(initialAmounts(prefill));
|
|
2558
|
+
}
|
|
2559
|
+
const toggle = useCallback((id) => setSelected((current) => current.includes(id) ? current.filter((held) => held !== id) : [...current, id]), []);
|
|
2560
|
+
const remove = useCallback((id) => setSelected((current) => current.filter((held) => held !== id)), []);
|
|
2561
|
+
const change = useCallback((id, text) => {
|
|
2562
|
+
setAmounts((current) => ({
|
|
2563
|
+
...current,
|
|
2564
|
+
[id]: {
|
|
2565
|
+
value: text,
|
|
2566
|
+
prefilled: false
|
|
2567
|
+
}
|
|
2568
|
+
}));
|
|
2569
|
+
}, []);
|
|
2570
|
+
const account = treasury.data ?? null;
|
|
2571
|
+
const assetOptions = useMemo(() => account === null ? [] : [{
|
|
2572
|
+
id: account.id,
|
|
2573
|
+
symbol: account.available.currency,
|
|
2574
|
+
availableDisplay: formatMoney(account.available, GRAMMAR)
|
|
2575
|
+
}], [account]);
|
|
2576
|
+
const [selectedAssetId, setSelectedAssetId] = useState(null);
|
|
2577
|
+
useEffect(() => {
|
|
2578
|
+
if (!assetOptions.some((option) => option.id === selectedAssetId)) setSelectedAssetId(assetOptions[0]?.id ?? null);
|
|
2579
|
+
}, [assetOptions, selectedAssetId]);
|
|
2580
|
+
const selectedAsset = assetOptions.find((option) => option.id === selectedAssetId) ?? null;
|
|
2581
|
+
const selectAsset = useCallback((id) => {
|
|
2582
|
+
setSelectedAssetId((current) => assetOptions.some((option) => option.id === id) ? id : current);
|
|
2583
|
+
}, [assetOptions]);
|
|
2584
|
+
const assetMoney = selectedAsset !== null && account !== null ? account.available : null;
|
|
2585
|
+
const named = useMemo(() => new Map(roster.options.map((option) => [option.id, option])), [roster.options]);
|
|
2586
|
+
const refs = roster.refs;
|
|
2587
|
+
const payableRefs = useMemo(() => {
|
|
2588
|
+
const resolved = [];
|
|
2589
|
+
for (const recipientId of selected) {
|
|
2590
|
+
const ref = refs.get(recipientId);
|
|
2591
|
+
if (ref === void 0) return null;
|
|
2592
|
+
resolved.push(ref);
|
|
2593
|
+
}
|
|
2594
|
+
return resolved;
|
|
2595
|
+
}, [refs, selected]);
|
|
2596
|
+
const entries = useMemo(() => selected.map((recipientId) => {
|
|
2597
|
+
const draft = amounts[recipientId];
|
|
2598
|
+
const value = draft?.value ?? "";
|
|
2599
|
+
const parsed = assetMoney === null || value.trim() === "" ? null : parseMoney(value, assetMoney);
|
|
2600
|
+
return {
|
|
2601
|
+
recipientId,
|
|
2602
|
+
name: named.get(recipientId)?.name ?? recipientId,
|
|
2603
|
+
value,
|
|
2604
|
+
change: (text) => change(recipientId, text),
|
|
2605
|
+
error: parsed !== null && isMoneyParseError(parsed) ? parsed.reason : null,
|
|
2606
|
+
prefilled: draft?.prefilled ?? false,
|
|
2607
|
+
currency: assetMoney?.currency ?? CURRENCY_UNREAD
|
|
2608
|
+
};
|
|
2609
|
+
}), [
|
|
2610
|
+
amounts,
|
|
2611
|
+
assetMoney,
|
|
2612
|
+
change,
|
|
2613
|
+
named,
|
|
2614
|
+
selected
|
|
2615
|
+
]);
|
|
2616
|
+
const parsedAmounts = useMemo(() => {
|
|
2617
|
+
if (assetMoney === null || selected.length === 0) return null;
|
|
2618
|
+
const money = [];
|
|
2619
|
+
for (const recipientId of selected) {
|
|
2620
|
+
const parsed = parseMoney(amounts[recipientId]?.value ?? "", assetMoney);
|
|
2621
|
+
if (isMoneyParseError(parsed)) return null;
|
|
2622
|
+
money.push(parsed);
|
|
2623
|
+
}
|
|
2624
|
+
return money;
|
|
2625
|
+
}, [
|
|
2626
|
+
amounts,
|
|
2627
|
+
assetMoney,
|
|
2628
|
+
selected
|
|
2629
|
+
]);
|
|
2630
|
+
const total = useMemo(() => parsedAmounts === null || assetMoney === null ? null : fromMinorUnits(parsedAmounts.reduce((sum, money) => sum + toMinorUnits(money), 0n), assetMoney), [assetMoney, parsedAmounts]);
|
|
2631
|
+
const availableRaw = assetMoney === null ? null : toMinorUnits(assetMoney);
|
|
2632
|
+
const totalRaw = total === null ? null : toMinorUnits(total);
|
|
2633
|
+
const funds = availableRaw === null || totalRaw === null ? "unknown" : totalRaw > availableRaw ? "insufficient" : "available";
|
|
2634
|
+
const budgets = me.data?.budgets ?? [];
|
|
2635
|
+
const blockedReason = firstFalseFact({
|
|
2636
|
+
signerReady: signerStatus === "ready",
|
|
2637
|
+
budgetCount: budgets.length,
|
|
2638
|
+
hasAsset: selectedAsset !== null,
|
|
2639
|
+
recipientCount: payableRefs === null ? 0 : selected.length,
|
|
2640
|
+
amountsParse: total !== null,
|
|
2641
|
+
funds
|
|
2642
|
+
});
|
|
2643
|
+
const authorize = useMutation({
|
|
2644
|
+
mutationFn: async (command) => {
|
|
2645
|
+
const runAt = Date.now();
|
|
2646
|
+
return unwrapCapxulResult(await scopedClient(client, "org.payroll.authorizeRun", orgId).payroll.authorizeRun({
|
|
2647
|
+
permissionId: command.permissionId,
|
|
2648
|
+
period: {
|
|
2649
|
+
start: runAt,
|
|
2650
|
+
end: runAt
|
|
2651
|
+
},
|
|
2652
|
+
items: command.items
|
|
2653
|
+
}));
|
|
2654
|
+
},
|
|
2655
|
+
onSettled: () => {
|
|
2656
|
+
queryClient.invalidateQueries({ queryKey: capxulKeys.payrollRuns(orgId) });
|
|
2657
|
+
}
|
|
2658
|
+
});
|
|
2659
|
+
const submitGate = useRef(false);
|
|
2660
|
+
const totalDisplay = total === null ? null : formatMoney(total, GRAMMAR);
|
|
2661
|
+
const authorizeMutate = authorize.mutate;
|
|
2662
|
+
const submit = useCallback(() => {
|
|
2663
|
+
const permissionId = budgets[0]?.id;
|
|
2664
|
+
if (blockedReason !== null || submitGate.current) return;
|
|
2665
|
+
if (permissionId === void 0 || totalDisplay === null || parsedAmounts === null) return;
|
|
2666
|
+
if (payableRefs === null) return;
|
|
2667
|
+
const recipientCount = selected.length;
|
|
2668
|
+
const items = [];
|
|
2669
|
+
for (const [index, recipientId] of selected.entries()) {
|
|
2670
|
+
const to = payableRefs[index];
|
|
2671
|
+
const parsed = parsedAmounts[index];
|
|
2672
|
+
if (to === void 0 || parsed === void 0) return;
|
|
2673
|
+
const raw = toMinorUnits(parsed).toString();
|
|
2674
|
+
items.push({
|
|
2675
|
+
to,
|
|
2676
|
+
partyId: recipientId,
|
|
2677
|
+
gross: raw,
|
|
2678
|
+
net: raw,
|
|
2679
|
+
adjustments: []
|
|
2680
|
+
});
|
|
2681
|
+
}
|
|
2682
|
+
submitGate.current = true;
|
|
2683
|
+
authorizeMutate({
|
|
2684
|
+
permissionId,
|
|
2685
|
+
items
|
|
2686
|
+
}, {
|
|
2687
|
+
onSuccess: (run) => onRun({
|
|
2688
|
+
id: run.id,
|
|
2689
|
+
totalDisplay,
|
|
2690
|
+
recipientCount
|
|
2691
|
+
}),
|
|
2692
|
+
onError: onFailed,
|
|
2693
|
+
onSettled: () => {
|
|
2694
|
+
submitGate.current = false;
|
|
2695
|
+
}
|
|
2696
|
+
});
|
|
2697
|
+
}, [
|
|
2698
|
+
authorizeMutate,
|
|
2699
|
+
blockedReason,
|
|
2700
|
+
budgets,
|
|
2701
|
+
onFailed,
|
|
2702
|
+
onRun,
|
|
2703
|
+
parsedAmounts,
|
|
2704
|
+
payableRefs,
|
|
2705
|
+
selected,
|
|
2706
|
+
totalDisplay
|
|
2707
|
+
]);
|
|
2708
|
+
return {
|
|
2709
|
+
recipients: {
|
|
2710
|
+
options: roster.options,
|
|
2711
|
+
groups: (groups.data ?? []).map((group) => ({
|
|
2712
|
+
id: group.id,
|
|
2713
|
+
label: group.name,
|
|
2714
|
+
badgeLabel: String(group.members.length),
|
|
2715
|
+
recipientIds: group.members.map((member) => member.partyId)
|
|
2716
|
+
})),
|
|
2717
|
+
selected,
|
|
2718
|
+
toggle,
|
|
2719
|
+
remove,
|
|
2720
|
+
isLoading: roster.isLoading,
|
|
2721
|
+
error: roster.error ?? groups.error
|
|
2722
|
+
},
|
|
2723
|
+
amounts: { entries },
|
|
2724
|
+
asset: {
|
|
2725
|
+
options: assetOptions,
|
|
2726
|
+
selected: selectedAsset,
|
|
2727
|
+
select: selectAsset,
|
|
2728
|
+
error: treasury.error === null ? null : "read-failed"
|
|
2729
|
+
},
|
|
2730
|
+
summary: {
|
|
2731
|
+
recipientCount: selected.length,
|
|
2732
|
+
totalDisplay,
|
|
2733
|
+
balanceAfterDisplay: totalRaw === null || availableRaw === null || assetMoney === null ? null : formatMoney(fromMinorUnits(availableRaw - totalRaw, assetMoney), GRAMMAR),
|
|
2734
|
+
funds
|
|
2735
|
+
},
|
|
2736
|
+
actions: {
|
|
2737
|
+
submit,
|
|
2738
|
+
isSubmitting: authorize.isPending,
|
|
2739
|
+
blocked: blockedReason !== null,
|
|
2740
|
+
blockedReason
|
|
2741
|
+
}
|
|
2742
|
+
};
|
|
2743
|
+
}
|
|
2744
|
+
/**
|
|
2745
|
+
* The seven facts, in the ONE order the contract fixes. Exactly one code comes
|
|
2746
|
+
* out, and it is always the first thing that is false — so two problems never
|
|
2747
|
+
* race to name the button.
|
|
2748
|
+
*/
|
|
2749
|
+
function firstFalseFact(facts) {
|
|
2750
|
+
if (!facts.signerReady) return "signer-not-ready";
|
|
2751
|
+
if (facts.budgetCount === 0) return "no-source";
|
|
2752
|
+
if (facts.budgetCount > 1) return "ambiguous-source";
|
|
2753
|
+
if (!facts.hasAsset) return "no-asset";
|
|
2754
|
+
if (facts.recipientCount === 0) return "no-recipients";
|
|
2755
|
+
if (!facts.amountsParse) return "amount-invalid";
|
|
2756
|
+
return facts.funds === "insufficient" ? "insufficient-funds" : null;
|
|
2757
|
+
}
|
|
2758
|
+
//#endregion
|
|
2759
|
+
//#region src/headless/payroll/payroll-run.tsx
|
|
2760
|
+
const PayrollRunContext = createContext(null);
|
|
2761
|
+
function usePayrollRunContext() {
|
|
2762
|
+
const engine = useContext(PayrollRunContext);
|
|
2763
|
+
if (engine === null) throw new Error("CapxulPayrollRun parts must be used inside <CapxulPayrollRun>");
|
|
2764
|
+
return engine;
|
|
2765
|
+
}
|
|
2766
|
+
function Root({ orgId, prefill, onRun, onFailed, children }) {
|
|
2767
|
+
const engine = usePayrollRun({
|
|
2768
|
+
orgId,
|
|
2769
|
+
prefill,
|
|
2770
|
+
onRun,
|
|
2771
|
+
onFailed
|
|
2772
|
+
});
|
|
2773
|
+
return /* @__PURE__ */ jsx(PayrollRunContext.Provider, {
|
|
2774
|
+
value: engine,
|
|
2775
|
+
children
|
|
2776
|
+
});
|
|
2777
|
+
}
|
|
2778
|
+
function Recipients({ children }) {
|
|
2779
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(usePayrollRunContext().recipients) });
|
|
2780
|
+
}
|
|
2781
|
+
function Amounts({ children }) {
|
|
2782
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(usePayrollRunContext().amounts) });
|
|
2783
|
+
}
|
|
2784
|
+
function Asset({ children }) {
|
|
2785
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(usePayrollRunContext().asset) });
|
|
2786
|
+
}
|
|
2787
|
+
function Summary({ children }) {
|
|
2788
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(usePayrollRunContext().summary) });
|
|
2789
|
+
}
|
|
2790
|
+
function Actions({ children }) {
|
|
2791
|
+
return /* @__PURE__ */ jsx(Fragment, { children: children(usePayrollRunContext().actions) });
|
|
2792
|
+
}
|
|
2793
|
+
/** The compound shape the app's own design system already uses (`StatusTabs`). */
|
|
2794
|
+
const CapxulPayrollRun = Object.assign(Root, {
|
|
2795
|
+
Recipients,
|
|
2796
|
+
Amounts,
|
|
2797
|
+
Asset,
|
|
2798
|
+
Summary,
|
|
2799
|
+
Actions
|
|
2800
|
+
});
|
|
2801
|
+
//#endregion
|
|
2802
|
+
export { CapxulActivity, CapxulAuthenticationController, CapxulContacts, CapxulDashboardAccess, CapxulOnboardingController, CapxulOrgMember, CapxulPayroll, CapxulPayrollRun, CapxulProvider, CapxulSendMoney, acknowledgeOnboardingDestination, activeOnboardingRecovery, capxulKeys, clearOnboardingJourney, currentOnboardingJourneyId, entered, invalidateOnboardingJourneyObservation, isClaimed, isRestoring, loadOnboardingJourney, onboardingJourneyPosition, saveOnboardingJourney, startOnboardingJourney, useCapxul, useCapxulAccountBalance, useCapxulAccountFund, useCapxulActivity, useCapxulActivityDetail, useCapxulAnnotateMovement, useCapxulAuth, useCapxulCancelPayment, useCapxulClaimPayment, useCapxulClientOrNull, useCapxulCreateCommitment, useCapxulCreateOrg, useCapxulDestination, useCapxulHoldings, useCapxulIdentity, useCapxulImageUpload, useCapxulInviteMember, useCapxulOrgMembers, useCapxulOrgRoles, useCapxulOrgTreasury, useCapxulOrganizationPay, useCapxulOrganizationPayBatch, useCapxulOrgs, useCapxulPay, useCapxulPayment, useCapxulPayments, useCapxulPermission, useCapxulPermissionAssign, useCapxulPermissionChange, useCapxulPermissionCreate, useCapxulPermissionReplace, useCapxulPermissionRevoke, useCapxulPermissions, useCapxulProfile, useCapxulRedirectPayment, useCapxulSend, useCapxulTransitions, useCapxulUsernameAvailability };
|