@open-mercato/ui 0.6.1-develop.3227.1.2b7b8ab258 → 0.6.1-develop.3246.1.dbef9d7392

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.
@@ -1,3 +1,3 @@
1
1
  Generated lucide registry with 134 icons -> /home/runner/work/open-mercato/open-mercato/packages/ui/src/backend/icons/lucideRegistry.generated.tsx
2
- [build:ui] found 310 entry points
2
+ [build:ui] found 311 entry points
3
3
  [build:ui] built successfully
@@ -21,18 +21,23 @@ function LastOperationBanner() {
21
21
  async function handleUndo() {
22
22
  const undoToken = operation?.undoToken;
23
23
  if (!undoToken || isPending) return;
24
+ const tokens = operation.bulkUndoTokens && operation.bulkUndoTokens.length > 0 ? operation.bulkUndoTokens : [undoToken];
24
25
  setPendingToken(undoToken);
26
+ const completed = [];
25
27
  try {
26
- const call = await apiCall("/api/audit_logs/audit-logs/actions/undo", {
27
- method: "POST",
28
- headers: { "Content-Type": "application/json" },
29
- body: JSON.stringify({ undoToken })
30
- });
31
- if (!call.ok) {
32
- const message = call.result && typeof call.result.error === "string" && call.result.error || "";
33
- throw new Error(message || t("audit_logs.banner.undo_failed", "Failed to undo"));
28
+ for (const token of tokens.slice().reverse()) {
29
+ const call = await apiCall("/api/audit_logs/audit-logs/actions/undo", {
30
+ method: "POST",
31
+ headers: { "Content-Type": "application/json" },
32
+ body: JSON.stringify({ undoToken: token })
33
+ });
34
+ if (!call.ok) {
35
+ const message = call.result && typeof call.result.error === "string" && call.result.error || "";
36
+ throw new Error(message || t("audit_logs.banner.undo_failed", "Failed to undo"));
37
+ }
38
+ completed.push(token);
34
39
  }
35
- markUndoSuccess(undoToken);
40
+ markUndoSuccess(tokens);
36
41
  flash(t("audit_logs.banner.undo_success"), "success");
37
42
  router.refresh();
38
43
  if (typeof window !== "undefined") {
@@ -45,6 +50,7 @@ function LastOperationBanner() {
45
50
  }
46
51
  }
47
52
  } catch (err) {
53
+ if (completed.length > 0) markUndoSuccess(completed);
48
54
  const message = err instanceof Error && err.message ? err.message : t("audit_logs.banner.undo_error");
49
55
  flash(message, "error");
50
56
  } finally {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/backend/operations/LastOperationBanner.tsx"],
4
- "sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { Undo2 } from 'lucide-react'\nimport { useRouter } from 'next/navigation'\nimport { Button } from '../../primitives/button'\nimport { apiCall } from '../utils/apiCall'\nimport { flash } from '../FlashMessages'\nimport { useLastOperation, markUndoSuccess } from './store'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\nexport function LastOperationBanner() {\n const t = useT()\n const operation = useLastOperation()\n const [pendingToken, setPendingToken] = React.useState<string | null>(null)\n const router = useRouter()\n\n if (!operation) return null\n\n const rawLabel = operation.actionLabel ?? operation.commandId\n const translatedLabel = t(rawLabel)\n const label = translatedLabel === rawLabel ? rawLabel : translatedLabel\n const isPending = pendingToken === operation.undoToken\n\n async function handleUndo() {\n const undoToken = operation?.undoToken\n if (!undoToken || isPending) return\n setPendingToken(undoToken)\n try {\n const call = await apiCall<Record<string, unknown>>('/api/audit_logs/audit-logs/actions/undo', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ undoToken }),\n })\n if (!call.ok) {\n const message =\n (call.result && typeof call.result.error === 'string' && call.result.error) ||\n ''\n throw new Error(message || t('audit_logs.banner.undo_failed', 'Failed to undo'))\n }\n markUndoSuccess(undoToken)\n flash(t('audit_logs.banner.undo_success'), 'success')\n router.refresh()\n if (typeof window !== 'undefined') {\n try {\n const isJSDOM = typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'\n ? navigator.userAgent.toLowerCase().includes('jsdom')\n : false\n if (!isJSDOM && typeof window.location?.reload === 'function') {\n window.location.reload()\n }\n } catch {\n // noop in non-browser or jsdom environments\n }\n }\n } catch (err) {\n const message = err instanceof Error && err.message ? err.message : t('audit_logs.banner.undo_error')\n flash(message, 'error')\n } finally {\n setPendingToken(null)\n }\n }\n\n return (\n <div className=\"mb-4 flex items-center justify-between gap-3 rounded-md border border-amber-200/80 bg-amber-50/95 pl-3 pr-2 py-2 text-sm text-amber-900 shadow-xs sm:pr-3\">\n <div className=\"min-w-0 truncate\">\n <span className=\"font-medium text-amber-950\">\n {t('audit_logs.banner.last_operation')}\n </span>\n <span className=\"ml-2 truncate text-amber-900\">\n {label}\n </span>\n </div>\n <Button\n variant=\"outline\"\n size=\"sm\"\n onClick={() => { void handleUndo() }}\n disabled={isPending}\n className=\"border-amber-200/80 bg-amber-50 text-amber-900 hover:bg-amber-100 hover:text-amber-900 px-2.5 sm:px-3\"\n >\n <Undo2 className=\"mr-1 size-4\" aria-hidden=\"true\" />\n {isPending ? t('audit_logs.actions.undoing') : t('audit_logs.banner.undo')}\n </Button>\n </div>\n )\n}\n"],
5
- "mappings": ";AAgEM,SACE,KADF;AA/DN,YAAY,WAAW;AACvB,SAAS,aAAa;AACtB,SAAS,iBAAiB;AAC1B,SAAS,cAAc;AACvB,SAAS,eAAe;AACxB,SAAS,aAAa;AACtB,SAAS,kBAAkB,uBAAuB;AAClD,SAAS,YAAY;AAEd,SAAS,sBAAsB;AACpC,QAAM,IAAI,KAAK;AACf,QAAM,YAAY,iBAAiB;AACnC,QAAM,CAAC,cAAc,eAAe,IAAI,MAAM,SAAwB,IAAI;AAC1E,QAAM,SAAS,UAAU;AAEzB,MAAI,CAAC,UAAW,QAAO;AAEvB,QAAM,WAAW,UAAU,eAAe,UAAU;AACpD,QAAM,kBAAkB,EAAE,QAAQ;AAClC,QAAM,QAAQ,oBAAoB,WAAW,WAAW;AACxD,QAAM,YAAY,iBAAiB,UAAU;AAE7C,iBAAe,aAAa;AAC1B,UAAM,YAAY,WAAW;AAC7B,QAAI,CAAC,aAAa,UAAW;AAC7B,oBAAgB,SAAS;AACzB,QAAI;AACF,YAAM,OAAO,MAAM,QAAiC,2CAA2C;AAAA,QAC7F,QAAQ;AAAA,QACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,QAC9C,MAAM,KAAK,UAAU,EAAE,UAAU,CAAC;AAAA,MACpC,CAAC;AACD,UAAI,CAAC,KAAK,IAAI;AACZ,cAAM,UACH,KAAK,UAAU,OAAO,KAAK,OAAO,UAAU,YAAY,KAAK,OAAO,SACrE;AACF,cAAM,IAAI,MAAM,WAAW,EAAE,iCAAiC,gBAAgB,CAAC;AAAA,MACjF;AACA,sBAAgB,SAAS;AACzB,YAAM,EAAE,gCAAgC,GAAG,SAAS;AACpD,aAAO,QAAQ;AACf,UAAI,OAAO,WAAW,aAAa;AACjC,YAAI;AACF,gBAAM,UAAU,OAAO,cAAc,eAAe,OAAO,UAAU,cAAc,WAC/E,UAAU,UAAU,YAAY,EAAE,SAAS,OAAO,IAClD;AACJ,cAAI,CAAC,WAAW,OAAO,OAAO,UAAU,WAAW,YAAY;AAC7D,mBAAO,SAAS,OAAO;AAAA,UACzB;AAAA,QACF,QAAQ;AAAA,QAER;AAAA,MACF;AAAA,IACF,SAAS,KAAK;AACZ,YAAM,UAAU,eAAe,SAAS,IAAI,UAAU,IAAI,UAAU,EAAE,8BAA8B;AACpG,YAAM,SAAS,OAAO;AAAA,IACxB,UAAE;AACA,sBAAgB,IAAI;AAAA,IACtB;AAAA,EACF;AAEA,SACE,qBAAC,SAAI,WAAU,6JACb;AAAA,yBAAC,SAAI,WAAU,oBACb;AAAA,0BAAC,UAAK,WAAU,8BACb,YAAE,kCAAkC,GACvC;AAAA,MACA,oBAAC,UAAK,WAAU,gCACb,iBACH;AAAA,OACF;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC,SAAQ;AAAA,QACR,MAAK;AAAA,QACL,SAAS,MAAM;AAAE,eAAK,WAAW;AAAA,QAAE;AAAA,QACnC,UAAU;AAAA,QACV,WAAU;AAAA,QAEV;AAAA,8BAAC,SAAM,WAAU,eAAc,eAAY,QAAO;AAAA,UACjD,YAAY,EAAE,4BAA4B,IAAI,EAAE,wBAAwB;AAAA;AAAA;AAAA,IAC3E;AAAA,KACF;AAEJ;",
4
+ "sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { Undo2 } from 'lucide-react'\nimport { useRouter } from 'next/navigation'\nimport { Button } from '../../primitives/button'\nimport { apiCall } from '../utils/apiCall'\nimport { flash } from '../FlashMessages'\nimport { useLastOperation, markUndoSuccess } from './store'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\nexport function LastOperationBanner() {\n const t = useT()\n const operation = useLastOperation()\n const [pendingToken, setPendingToken] = React.useState<string | null>(null)\n const router = useRouter()\n\n if (!operation) return null\n\n const rawLabel = operation.actionLabel ?? operation.commandId\n const translatedLabel = t(rawLabel)\n const label = translatedLabel === rawLabel ? rawLabel : translatedLabel\n const isPending = pendingToken === operation.undoToken\n\n async function handleUndo() {\n const undoToken = operation?.undoToken\n if (!undoToken || isPending) return\n const tokens = operation.bulkUndoTokens && operation.bulkUndoTokens.length > 0\n ? operation.bulkUndoTokens\n : [undoToken]\n setPendingToken(undoToken)\n const completed: string[] = []\n try {\n for (const token of tokens.slice().reverse()) {\n const call = await apiCall<Record<string, unknown>>('/api/audit_logs/audit-logs/actions/undo', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ undoToken: token }),\n })\n if (!call.ok) {\n const message =\n (call.result && typeof call.result.error === 'string' && call.result.error) ||\n ''\n throw new Error(message || t('audit_logs.banner.undo_failed', 'Failed to undo'))\n }\n completed.push(token)\n }\n markUndoSuccess(tokens)\n flash(t('audit_logs.banner.undo_success'), 'success')\n router.refresh()\n if (typeof window !== 'undefined') {\n try {\n const isJSDOM = typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'\n ? navigator.userAgent.toLowerCase().includes('jsdom')\n : false\n if (!isJSDOM && typeof window.location?.reload === 'function') {\n window.location.reload()\n }\n } catch {\n // noop in non-browser or jsdom environments\n }\n }\n } catch (err) {\n if (completed.length > 0) markUndoSuccess(completed)\n const message = err instanceof Error && err.message ? err.message : t('audit_logs.banner.undo_error')\n flash(message, 'error')\n } finally {\n setPendingToken(null)\n }\n }\n\n return (\n <div className=\"mb-4 flex items-center justify-between gap-3 rounded-md border border-amber-200/80 bg-amber-50/95 pl-3 pr-2 py-2 text-sm text-amber-900 shadow-xs sm:pr-3\">\n <div className=\"min-w-0 truncate\">\n <span className=\"font-medium text-amber-950\">\n {t('audit_logs.banner.last_operation')}\n </span>\n <span className=\"ml-2 truncate text-amber-900\">\n {label}\n </span>\n </div>\n <Button\n variant=\"outline\"\n size=\"sm\"\n onClick={() => { void handleUndo() }}\n disabled={isPending}\n className=\"border-amber-200/80 bg-amber-50 text-amber-900 hover:bg-amber-100 hover:text-amber-900 px-2.5 sm:px-3\"\n >\n <Undo2 className=\"mr-1 size-4\" aria-hidden=\"true\" />\n {isPending ? t('audit_logs.actions.undoing') : t('audit_logs.banner.undo')}\n </Button>\n </div>\n )\n}\n"],
5
+ "mappings": ";AAwEM,SACE,KADF;AAvEN,YAAY,WAAW;AACvB,SAAS,aAAa;AACtB,SAAS,iBAAiB;AAC1B,SAAS,cAAc;AACvB,SAAS,eAAe;AACxB,SAAS,aAAa;AACtB,SAAS,kBAAkB,uBAAuB;AAClD,SAAS,YAAY;AAEd,SAAS,sBAAsB;AACpC,QAAM,IAAI,KAAK;AACf,QAAM,YAAY,iBAAiB;AACnC,QAAM,CAAC,cAAc,eAAe,IAAI,MAAM,SAAwB,IAAI;AAC1E,QAAM,SAAS,UAAU;AAEzB,MAAI,CAAC,UAAW,QAAO;AAEvB,QAAM,WAAW,UAAU,eAAe,UAAU;AACpD,QAAM,kBAAkB,EAAE,QAAQ;AAClC,QAAM,QAAQ,oBAAoB,WAAW,WAAW;AACxD,QAAM,YAAY,iBAAiB,UAAU;AAE7C,iBAAe,aAAa;AAC1B,UAAM,YAAY,WAAW;AAC7B,QAAI,CAAC,aAAa,UAAW;AAC7B,UAAM,SAAS,UAAU,kBAAkB,UAAU,eAAe,SAAS,IACzE,UAAU,iBACV,CAAC,SAAS;AACd,oBAAgB,SAAS;AACzB,UAAM,YAAsB,CAAC;AAC7B,QAAI;AACF,iBAAW,SAAS,OAAO,MAAM,EAAE,QAAQ,GAAG;AAC5C,cAAM,OAAO,MAAM,QAAiC,2CAA2C;AAAA,UAC7F,QAAQ;AAAA,UACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,UAC9C,MAAM,KAAK,UAAU,EAAE,WAAW,MAAM,CAAC;AAAA,QAC3C,CAAC;AACD,YAAI,CAAC,KAAK,IAAI;AACZ,gBAAM,UACH,KAAK,UAAU,OAAO,KAAK,OAAO,UAAU,YAAY,KAAK,OAAO,SACrE;AACF,gBAAM,IAAI,MAAM,WAAW,EAAE,iCAAiC,gBAAgB,CAAC;AAAA,QACjF;AACA,kBAAU,KAAK,KAAK;AAAA,MACtB;AACA,sBAAgB,MAAM;AACtB,YAAM,EAAE,gCAAgC,GAAG,SAAS;AACpD,aAAO,QAAQ;AACf,UAAI,OAAO,WAAW,aAAa;AACjC,YAAI;AACF,gBAAM,UAAU,OAAO,cAAc,eAAe,OAAO,UAAU,cAAc,WAC/E,UAAU,UAAU,YAAY,EAAE,SAAS,OAAO,IAClD;AACJ,cAAI,CAAC,WAAW,OAAO,OAAO,UAAU,WAAW,YAAY;AAC7D,mBAAO,SAAS,OAAO;AAAA,UACzB;AAAA,QACF,QAAQ;AAAA,QAER;AAAA,MACF;AAAA,IACF,SAAS,KAAK;AACZ,UAAI,UAAU,SAAS,EAAG,iBAAgB,SAAS;AACnD,YAAM,UAAU,eAAe,SAAS,IAAI,UAAU,IAAI,UAAU,EAAE,8BAA8B;AACpG,YAAM,SAAS,OAAO;AAAA,IACxB,UAAE;AACA,sBAAgB,IAAI;AAAA,IACtB;AAAA,EACF;AAEA,SACE,qBAAC,SAAI,WAAU,6JACb;AAAA,yBAAC,SAAI,WAAU,oBACb;AAAA,0BAAC,UAAK,WAAU,8BACb,YAAE,kCAAkC,GACvC;AAAA,MACA,oBAAC,UAAK,WAAU,gCACb,iBACH;AAAA,OACF;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC,SAAQ;AAAA,QACR,MAAK;AAAA,QACL,SAAS,MAAM;AAAE,eAAK,WAAW;AAAA,QAAE;AAAA,QACnC,UAAU;AAAA,QACV,WAAU;AAAA,QAEV;AAAA,8BAAC,SAAM,WAAU,eAAc,eAAY,QAAO;AAAA,UACjD,YAAY,EAAE,4BAA4B,IAAI,EAAE,wBAAwB;AAAA;AAAA;AAAA,IAC3E;AAAA,KACF;AAEJ;",
6
6
  "names": []
7
7
  }
@@ -38,6 +38,8 @@ function isValidEntry(entry) {
38
38
  }
39
39
  function hydrateEntry(entry) {
40
40
  const source = entry;
41
+ const bulkTokens = Array.isArray(source.bulkUndoTokens) ? source.bulkUndoTokens.filter((t) => typeof t === "string" && t.length > 0) : void 0;
42
+ const bulkCount = typeof source.bulkCount === "number" && Number.isFinite(source.bulkCount) ? source.bulkCount : void 0;
41
43
  return {
42
44
  id: String(source.id),
43
45
  undoToken: String(source.undoToken),
@@ -46,7 +48,9 @@ function hydrateEntry(entry) {
46
48
  resourceKind: typeof source.resourceKind === "string" ? source.resourceKind : null,
47
49
  resourceId: typeof source.resourceId === "string" ? source.resourceId : null,
48
50
  executedAt: typeof source.executedAt === "string" ? source.executedAt : new Date(source.receivedAt || now()).toISOString(),
49
- receivedAt: typeof source.receivedAt === "number" ? source.receivedAt : now()
51
+ receivedAt: typeof source.receivedAt === "number" ? source.receivedAt : now(),
52
+ ...bulkTokens && bulkTokens.length > 0 ? { bulkUndoTokens: bulkTokens } : {},
53
+ ...bulkCount && bulkCount > 0 ? { bulkCount } : {}
50
54
  };
51
55
  }
52
56
  function persist(state) {
@@ -104,19 +108,70 @@ function pushOperation(meta) {
104
108
  return { stack, undone: [] };
105
109
  });
106
110
  }
107
- function markUndoSuccess(undoToken) {
111
+ function markUndoSuccess(undoTokens) {
108
112
  if (typeof window === "undefined") return;
113
+ const tokenSet = new Set(Array.isArray(undoTokens) ? undoTokens : [undoTokens]);
114
+ if (tokenSet.size === 0) return;
109
115
  const removed = [];
110
116
  updateState((prev) => {
111
- const stack = prev.stack.filter((entry) => {
112
- if (entry.undoToken === undoToken) {
117
+ const nextStack = [];
118
+ for (const entry of prev.stack) {
119
+ const bulk = entry.bulkUndoTokens && entry.bulkUndoTokens.length > 0 ? entry.bulkUndoTokens : null;
120
+ if (!bulk) {
121
+ if (tokenSet.has(entry.undoToken)) removed.push(entry);
122
+ else nextStack.push(entry);
123
+ continue;
124
+ }
125
+ const consumed = [];
126
+ const remaining = [];
127
+ for (const token of bulk) {
128
+ if (tokenSet.has(token)) consumed.push(token);
129
+ else remaining.push(token);
130
+ }
131
+ if (consumed.length === 0) {
132
+ nextStack.push(entry);
133
+ } else if (remaining.length === 0) {
113
134
  removed.push(entry);
114
- return false;
135
+ } else {
136
+ removed.push({ ...entry, bulkUndoTokens: consumed, bulkCount: consumed.length });
137
+ nextStack.push({ ...entry, bulkUndoTokens: remaining, bulkCount: remaining.length });
115
138
  }
116
- return true;
117
- });
139
+ }
118
140
  const undone = removed.length ? [...prev.undone, ...removed.map((entry) => ({ ...entry, undoneAt: now() }))] : prev.undone;
119
- return { stack, undone };
141
+ return { stack: nextStack, undone };
142
+ });
143
+ }
144
+ function generateBulkId(seed) {
145
+ const cryptoRef = typeof globalThis !== "undefined" ? globalThis.crypto : void 0;
146
+ if (cryptoRef && typeof cryptoRef.randomUUID === "function") {
147
+ return `bulk:${cryptoRef.randomUUID()}`;
148
+ }
149
+ return `bulk:${seed}:${now()}`;
150
+ }
151
+ function coalesceLastOperations(count, options = {}) {
152
+ if (typeof window === "undefined" || count <= 1) return;
153
+ updateState((prev) => {
154
+ if (prev.stack.length < count) return prev;
155
+ const tail = prev.stack.slice(-count);
156
+ if (options.commandId && !tail.every((entry) => entry.commandId === options.commandId)) {
157
+ return prev;
158
+ }
159
+ const head = prev.stack.slice(0, prev.stack.length - count);
160
+ const last = tail[tail.length - 1];
161
+ const tokens = tail.map((entry) => entry.undoToken);
162
+ const bulkId = generateBulkId(last.id);
163
+ const synthetic = {
164
+ ...last,
165
+ id: bulkId,
166
+ undoToken: bulkId,
167
+ actionLabel: options.actionLabel ?? last.actionLabel,
168
+ resourceKind: options.resourceKind ?? last.resourceKind,
169
+ resourceId: null,
170
+ bulkUndoTokens: tokens,
171
+ bulkCount: tail.length,
172
+ receivedAt: now()
173
+ };
174
+ return { stack: [...head, synthetic], undone: prev.undone };
120
175
  });
121
176
  }
122
177
  function markRedoConsumed(logId) {
@@ -170,6 +225,7 @@ const operationStackConstants = {
170
225
  };
171
226
  export {
172
227
  clearAllOperations,
228
+ coalesceLastOperations,
173
229
  getLastOperation,
174
230
  hasRedoCandidate,
175
231
  markRedoConsumed,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/backend/operations/store.ts"],
4
- "sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport type { OperationMetadataPayload } from '@open-mercato/shared/lib/commands/operationMetadata'\n\nexport type OperationEntry = OperationMetadataPayload & {\n receivedAt: number\n}\n\nexport type UndoneEntry = OperationEntry & {\n undoneAt: number\n}\n\ntype OperationStoreState = {\n stack: OperationEntry[]\n undone: UndoneEntry[]\n}\n\nconst DEFAULT_STATE: OperationStoreState = { stack: [], undone: [] }\n\nconst STORAGE_KEY = 'om:last-operations:v1'\nconst STACK_LIMIT = 20\nconst LAST_OPERATION_TTL_MS = 60_000\nconst STACK_RETENTION_MS = 10 * 60_000\n\nlet internalState: OperationStoreState = DEFAULT_STATE\n\nif (typeof window !== 'undefined') {\n internalState = loadState()\n}\n\nconst emitter = new EventTarget()\n\nfunction now() {\n return typeof performance !== 'undefined' && performance.now\n ? Math.round(performance.timeOrigin + performance.now())\n : Date.now()\n}\n\nfunction loadState(): OperationStoreState {\n try {\n const raw = window.localStorage.getItem(STORAGE_KEY)\n if (!raw) return DEFAULT_STATE\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') return DEFAULT_STATE\n const stack = Array.isArray(parsed.stack) ? parsed.stack.filter(isValidEntry).map(hydrateEntry) : []\n const undone = Array.isArray(parsed.undone)\n ? parsed.undone.filter(isValidEntry).map((raw: unknown) => {\n const hydrated = hydrateEntry(raw)\n const candidate = raw as { undoneAt?: unknown }\n const undoneAt = typeof candidate.undoneAt === 'number' ? candidate.undoneAt : now()\n return { ...hydrated, undoneAt }\n })\n : []\n return pruneState({ stack, undone })\n } catch {\n return DEFAULT_STATE\n }\n}\n\nfunction isValidEntry(entry: unknown): entry is OperationEntry {\n if (entry == null || typeof entry !== 'object') return false\n const candidate = entry as Record<string, unknown>\n return (\n typeof candidate.id === 'string'\n && typeof candidate.undoToken === 'string'\n && typeof candidate.commandId === 'string'\n && typeof candidate.receivedAt === 'number'\n && typeof candidate.executedAt === 'string'\n )\n}\n\nfunction hydrateEntry(entry: unknown): OperationEntry {\n const source = entry as Partial<OperationEntry> & Record<string, unknown>\n return {\n id: String(source.id),\n undoToken: String(source.undoToken),\n commandId: String(source.commandId),\n actionLabel: typeof source.actionLabel === 'string' ? source.actionLabel : null,\n resourceKind: typeof source.resourceKind === 'string' ? source.resourceKind : null,\n resourceId: typeof source.resourceId === 'string' ? source.resourceId : null,\n executedAt: typeof source.executedAt === 'string' ? source.executedAt : new Date((source.receivedAt as number | undefined) || now()).toISOString(),\n receivedAt: typeof source.receivedAt === 'number' ? source.receivedAt : now(),\n }\n}\n\nfunction persist(state: OperationStoreState) {\n if (typeof window === 'undefined') return\n try {\n window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state))\n } catch {\n // ignore storage quota errors\n }\n}\n\nfunction pruneState(state: OperationStoreState): OperationStoreState {\n const timestamp = now()\n const stack = state.stack\n .filter((entry, index, arr) => {\n // Deduplicate by id/undoToken keeping latest\n const duplicateIndex = arr.findIndex((candidate) => candidate.id === entry.id || candidate.undoToken === entry.undoToken)\n if (duplicateIndex !== index) return false\n return timestamp - entry.receivedAt <= STACK_RETENTION_MS\n })\n .sort((a, b) => a.receivedAt - b.receivedAt)\n .slice(-STACK_LIMIT)\n const undone = state.undone\n .filter((entry) => timestamp - entry.undoneAt <= STACK_RETENTION_MS)\n .sort((a, b) => a.undoneAt - b.undoneAt)\n .slice(-STACK_LIMIT)\n const next = { stack, undone }\n return next\n}\n\nfunction emit() {\n emitter.dispatchEvent(new Event('change'))\n}\n\nfunction updateState(updater: (prev: OperationStoreState) => OperationStoreState) {\n const next = pruneState(updater(internalState))\n internalState = next\n persist(next)\n emit()\n}\n\nfunction subscribe(listener: () => void) {\n const wrapped = () => listener()\n emitter.addEventListener('change', wrapped)\n return () => emitter.removeEventListener('change', wrapped)\n}\n\nfunction getClientSnapshot(): OperationStoreState {\n internalState = pruneState(internalState)\n return internalState\n}\n\nexport function useOperationStore<T>(selector: (state: OperationStoreState) => T): T {\n return React.useSyncExternalStore(\n subscribe,\n () => selector(getClientSnapshot()),\n () => selector(DEFAULT_STATE),\n )\n}\n\nexport function pushOperation(meta: OperationMetadataPayload) {\n if (typeof window === 'undefined') return\n updateState((prev) => {\n const entry: OperationEntry = {\n ...meta,\n receivedAt: now(),\n }\n const stack = prev.stack.filter((item) => item.id !== entry.id && item.undoToken !== entry.undoToken)\n stack.push(entry)\n return { stack, undone: [] }\n })\n}\n\nexport function markUndoSuccess(undoToken: string) {\n if (typeof window === 'undefined') return\n const removed: OperationEntry[] = []\n updateState((prev) => {\n const stack = prev.stack.filter((entry) => {\n if (entry.undoToken === undoToken) {\n removed.push(entry)\n return false\n }\n return true\n })\n const undone = removed.length\n ? [...prev.undone, ...removed.map((entry) => ({ ...entry, undoneAt: now() }))]\n : prev.undone\n return { stack, undone }\n })\n}\n\nexport function markRedoConsumed(logId: string) {\n if (typeof window === 'undefined') return\n updateState((prev) => ({\n stack: prev.stack,\n undone: prev.undone.filter((entry) => entry.id !== logId),\n }))\n}\n\nexport function getLastOperation(): OperationEntry | null {\n const state = getClientSnapshot()\n if (!state.stack.length) return null\n const last = state.stack[state.stack.length - 1]\n const lastExecuted = Date.parse(last.executedAt)\n const cutoff = now() - LAST_OPERATION_TTL_MS\n if (Number.isFinite(lastExecuted) && lastExecuted < cutoff) return null\n if (!Number.isFinite(lastExecuted) && last.receivedAt < cutoff) return null\n return last\n}\n\nexport function useLastOperation(): OperationEntry | null {\n return useOperationStore(getLastOperationFromState)\n}\n\nfunction getLastOperationFromState(state: OperationStoreState): OperationEntry | null {\n if (!state.stack.length) return null\n const last = state.stack[state.stack.length - 1]\n const timestamp = now()\n const executedAt = Date.parse(last.executedAt)\n const cutoff = timestamp - LAST_OPERATION_TTL_MS\n if (Number.isFinite(executedAt)) {\n return executedAt >= cutoff ? last : null\n }\n return last.receivedAt >= cutoff ? last : null\n}\n\nexport function useRedoCandidate(): UndoneEntry | null {\n return useOperationStore((state) => (state.undone.length ? state.undone[state.undone.length - 1] : null))\n}\n\nexport function hasRedoCandidate(logId: string): boolean {\n const state = getClientSnapshot()\n if (!state.undone.length) return false\n const top = state.undone[state.undone.length - 1]\n return top.id === logId\n}\n\nexport function clearAllOperations() {\n if (typeof window === 'undefined') return\n internalState = DEFAULT_STATE\n persist(internalState)\n emit()\n}\n\nexport const operationStackConstants = {\n LAST_OPERATION_TTL_MS,\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport type { OperationMetadataPayload } from '@open-mercato/shared/lib/commands/operationMetadata'\n\nexport type OperationEntry = OperationMetadataPayload & {\n receivedAt: number\n bulkUndoTokens?: string[]\n bulkCount?: number\n}\n\nexport type UndoneEntry = OperationEntry & {\n undoneAt: number\n}\n\ntype OperationStoreState = {\n stack: OperationEntry[]\n undone: UndoneEntry[]\n}\n\nconst DEFAULT_STATE: OperationStoreState = { stack: [], undone: [] }\n\nconst STORAGE_KEY = 'om:last-operations:v1'\nconst STACK_LIMIT = 20\nconst LAST_OPERATION_TTL_MS = 60_000\nconst STACK_RETENTION_MS = 10 * 60_000\n\nlet internalState: OperationStoreState = DEFAULT_STATE\n\nif (typeof window !== 'undefined') {\n internalState = loadState()\n}\n\nconst emitter = new EventTarget()\n\nfunction now() {\n return typeof performance !== 'undefined' && performance.now\n ? Math.round(performance.timeOrigin + performance.now())\n : Date.now()\n}\n\nfunction loadState(): OperationStoreState {\n try {\n const raw = window.localStorage.getItem(STORAGE_KEY)\n if (!raw) return DEFAULT_STATE\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') return DEFAULT_STATE\n const stack = Array.isArray(parsed.stack) ? parsed.stack.filter(isValidEntry).map(hydrateEntry) : []\n const undone = Array.isArray(parsed.undone)\n ? parsed.undone.filter(isValidEntry).map((raw: unknown) => {\n const hydrated = hydrateEntry(raw)\n const candidate = raw as { undoneAt?: unknown }\n const undoneAt = typeof candidate.undoneAt === 'number' ? candidate.undoneAt : now()\n return { ...hydrated, undoneAt }\n })\n : []\n return pruneState({ stack, undone })\n } catch {\n return DEFAULT_STATE\n }\n}\n\nfunction isValidEntry(entry: unknown): entry is OperationEntry {\n if (entry == null || typeof entry !== 'object') return false\n const candidate = entry as Record<string, unknown>\n return (\n typeof candidate.id === 'string'\n && typeof candidate.undoToken === 'string'\n && typeof candidate.commandId === 'string'\n && typeof candidate.receivedAt === 'number'\n && typeof candidate.executedAt === 'string'\n )\n}\n\nfunction hydrateEntry(entry: unknown): OperationEntry {\n const source = entry as Partial<OperationEntry> & Record<string, unknown>\n const bulkTokens = Array.isArray(source.bulkUndoTokens)\n ? source.bulkUndoTokens.filter((t): t is string => typeof t === 'string' && t.length > 0)\n : undefined\n const bulkCount = typeof source.bulkCount === 'number' && Number.isFinite(source.bulkCount)\n ? source.bulkCount\n : undefined\n return {\n id: String(source.id),\n undoToken: String(source.undoToken),\n commandId: String(source.commandId),\n actionLabel: typeof source.actionLabel === 'string' ? source.actionLabel : null,\n resourceKind: typeof source.resourceKind === 'string' ? source.resourceKind : null,\n resourceId: typeof source.resourceId === 'string' ? source.resourceId : null,\n executedAt: typeof source.executedAt === 'string' ? source.executedAt : new Date((source.receivedAt as number | undefined) || now()).toISOString(),\n receivedAt: typeof source.receivedAt === 'number' ? source.receivedAt : now(),\n ...(bulkTokens && bulkTokens.length > 0 ? { bulkUndoTokens: bulkTokens } : {}),\n ...(bulkCount && bulkCount > 0 ? { bulkCount } : {}),\n }\n}\n\nfunction persist(state: OperationStoreState) {\n if (typeof window === 'undefined') return\n try {\n window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state))\n } catch {\n // ignore storage quota errors\n }\n}\n\nfunction pruneState(state: OperationStoreState): OperationStoreState {\n const timestamp = now()\n const stack = state.stack\n .filter((entry, index, arr) => {\n // Deduplicate by id/undoToken keeping latest\n const duplicateIndex = arr.findIndex((candidate) => candidate.id === entry.id || candidate.undoToken === entry.undoToken)\n if (duplicateIndex !== index) return false\n return timestamp - entry.receivedAt <= STACK_RETENTION_MS\n })\n .sort((a, b) => a.receivedAt - b.receivedAt)\n .slice(-STACK_LIMIT)\n const undone = state.undone\n .filter((entry) => timestamp - entry.undoneAt <= STACK_RETENTION_MS)\n .sort((a, b) => a.undoneAt - b.undoneAt)\n .slice(-STACK_LIMIT)\n const next = { stack, undone }\n return next\n}\n\nfunction emit() {\n emitter.dispatchEvent(new Event('change'))\n}\n\nfunction updateState(updater: (prev: OperationStoreState) => OperationStoreState) {\n const next = pruneState(updater(internalState))\n internalState = next\n persist(next)\n emit()\n}\n\nfunction subscribe(listener: () => void) {\n const wrapped = () => listener()\n emitter.addEventListener('change', wrapped)\n return () => emitter.removeEventListener('change', wrapped)\n}\n\nfunction getClientSnapshot(): OperationStoreState {\n internalState = pruneState(internalState)\n return internalState\n}\n\nexport function useOperationStore<T>(selector: (state: OperationStoreState) => T): T {\n return React.useSyncExternalStore(\n subscribe,\n () => selector(getClientSnapshot()),\n () => selector(DEFAULT_STATE),\n )\n}\n\nexport function pushOperation(meta: OperationMetadataPayload) {\n if (typeof window === 'undefined') return\n updateState((prev) => {\n const entry: OperationEntry = {\n ...meta,\n receivedAt: now(),\n }\n const stack = prev.stack.filter((item) => item.id !== entry.id && item.undoToken !== entry.undoToken)\n stack.push(entry)\n return { stack, undone: [] }\n })\n}\n\nexport function markUndoSuccess(undoTokens: string | string[]) {\n if (typeof window === 'undefined') return\n const tokenSet = new Set(Array.isArray(undoTokens) ? undoTokens : [undoTokens])\n if (tokenSet.size === 0) return\n const removed: OperationEntry[] = []\n updateState((prev) => {\n const nextStack: OperationEntry[] = []\n for (const entry of prev.stack) {\n const bulk = entry.bulkUndoTokens && entry.bulkUndoTokens.length > 0 ? entry.bulkUndoTokens : null\n if (!bulk) {\n if (tokenSet.has(entry.undoToken)) removed.push(entry)\n else nextStack.push(entry)\n continue\n }\n const consumed: string[] = []\n const remaining: string[] = []\n for (const token of bulk) {\n if (tokenSet.has(token)) consumed.push(token)\n else remaining.push(token)\n }\n if (consumed.length === 0) {\n nextStack.push(entry)\n } else if (remaining.length === 0) {\n removed.push(entry)\n } else {\n removed.push({ ...entry, bulkUndoTokens: consumed, bulkCount: consumed.length })\n nextStack.push({ ...entry, bulkUndoTokens: remaining, bulkCount: remaining.length })\n }\n }\n const undone = removed.length\n ? [...prev.undone, ...removed.map((entry) => ({ ...entry, undoneAt: now() }))]\n : prev.undone\n return { stack: nextStack, undone }\n })\n}\n\nexport type CoalesceOptions = {\n commandId?: string\n actionLabel?: string | null\n resourceKind?: string | null\n}\n\nfunction generateBulkId(seed: string): string {\n const cryptoRef = typeof globalThis !== 'undefined' ? (globalThis as { crypto?: { randomUUID?: () => string } }).crypto : undefined\n if (cryptoRef && typeof cryptoRef.randomUUID === 'function') {\n return `bulk:${cryptoRef.randomUUID()}`\n }\n return `bulk:${seed}:${now()}`\n}\n\nexport function coalesceLastOperations(count: number, options: CoalesceOptions = {}): void {\n if (typeof window === 'undefined' || count <= 1) return\n updateState((prev) => {\n if (prev.stack.length < count) return prev\n const tail = prev.stack.slice(-count)\n if (options.commandId && !tail.every((entry) => entry.commandId === options.commandId)) {\n return prev\n }\n const head = prev.stack.slice(0, prev.stack.length - count)\n const last = tail[tail.length - 1]\n const tokens = tail.map((entry) => entry.undoToken)\n const bulkId = generateBulkId(last.id)\n const synthetic: OperationEntry = {\n ...last,\n id: bulkId,\n undoToken: bulkId,\n actionLabel: options.actionLabel ?? last.actionLabel,\n resourceKind: options.resourceKind ?? last.resourceKind,\n resourceId: null,\n bulkUndoTokens: tokens,\n bulkCount: tail.length,\n receivedAt: now(),\n }\n return { stack: [...head, synthetic], undone: prev.undone }\n })\n}\n\nexport function markRedoConsumed(logId: string) {\n if (typeof window === 'undefined') return\n updateState((prev) => ({\n stack: prev.stack,\n undone: prev.undone.filter((entry) => entry.id !== logId),\n }))\n}\n\nexport function getLastOperation(): OperationEntry | null {\n const state = getClientSnapshot()\n if (!state.stack.length) return null\n const last = state.stack[state.stack.length - 1]\n const lastExecuted = Date.parse(last.executedAt)\n const cutoff = now() - LAST_OPERATION_TTL_MS\n if (Number.isFinite(lastExecuted) && lastExecuted < cutoff) return null\n if (!Number.isFinite(lastExecuted) && last.receivedAt < cutoff) return null\n return last\n}\n\nexport function useLastOperation(): OperationEntry | null {\n return useOperationStore(getLastOperationFromState)\n}\n\nfunction getLastOperationFromState(state: OperationStoreState): OperationEntry | null {\n if (!state.stack.length) return null\n const last = state.stack[state.stack.length - 1]\n const timestamp = now()\n const executedAt = Date.parse(last.executedAt)\n const cutoff = timestamp - LAST_OPERATION_TTL_MS\n if (Number.isFinite(executedAt)) {\n return executedAt >= cutoff ? last : null\n }\n return last.receivedAt >= cutoff ? last : null\n}\n\nexport function useRedoCandidate(): UndoneEntry | null {\n return useOperationStore((state) => (state.undone.length ? state.undone[state.undone.length - 1] : null))\n}\n\nexport function hasRedoCandidate(logId: string): boolean {\n const state = getClientSnapshot()\n if (!state.undone.length) return false\n const top = state.undone[state.undone.length - 1]\n return top.id === logId\n}\n\nexport function clearAllOperations() {\n if (typeof window === 'undefined') return\n internalState = DEFAULT_STATE\n persist(internalState)\n emit()\n}\n\nexport const operationStackConstants = {\n LAST_OPERATION_TTL_MS,\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["raw"]
7
7
  }
@@ -0,0 +1,46 @@
1
+ "use client";
2
+ async function runBulkDelete(rows, deleteOne, options) {
3
+ const fallback = options?.fallbackErrorMessage ?? "Delete failed";
4
+ const logTag = options?.logTag;
5
+ const succeeded = [];
6
+ const failures = [];
7
+ for (const row of rows) {
8
+ try {
9
+ await deleteOne(row);
10
+ succeeded.push(row);
11
+ } catch (err) {
12
+ const message = err instanceof Error && err.message.trim().length > 0 ? err.message : fallback;
13
+ const rawCode = err && typeof err === "object" ? err.code : void 0;
14
+ const code = typeof rawCode === "string" && rawCode.length > 0 ? rawCode : null;
15
+ failures.push({ id: row.id, code, message });
16
+ if (logTag && typeof console !== "undefined" && typeof console.warn === "function") {
17
+ console.warn(`[${logTag}] bulk delete failed`, { id: row.id, code, message, err });
18
+ }
19
+ }
20
+ }
21
+ return { succeeded, failures };
22
+ }
23
+ function groupBulkDeleteFailures(failures) {
24
+ const buckets = /* @__PURE__ */ new Map();
25
+ for (const failure of failures) {
26
+ const key = failure.code || failure.message || "unknown";
27
+ const existing = buckets.get(key);
28
+ if (existing) {
29
+ existing.count += 1;
30
+ existing.ids.push(failure.id);
31
+ continue;
32
+ }
33
+ buckets.set(key, {
34
+ key,
35
+ count: 1,
36
+ sampleMessage: failure.message,
37
+ ids: [failure.id]
38
+ });
39
+ }
40
+ return Array.from(buckets.values());
41
+ }
42
+ export {
43
+ groupBulkDeleteFailures,
44
+ runBulkDelete
45
+ };
46
+ //# sourceMappingURL=bulkDelete.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/backend/utils/bulkDelete.ts"],
4
+ "sourcesContent": ["\"use client\"\n\nexport type BulkDeleteFailure = {\n id: string\n code: string | null\n message: string\n}\n\nexport type BulkDeleteOutcome<T extends { id: string }> = {\n succeeded: T[]\n failures: BulkDeleteFailure[]\n}\n\nexport type BulkDeleteOptions = {\n fallbackErrorMessage?: string\n logTag?: string\n}\n\nexport async function runBulkDelete<T extends { id: string }>(\n rows: T[],\n deleteOne: (row: T) => Promise<void>,\n options?: BulkDeleteOptions,\n): Promise<BulkDeleteOutcome<T>> {\n const fallback = options?.fallbackErrorMessage ?? 'Delete failed'\n const logTag = options?.logTag\n const succeeded: T[] = []\n const failures: BulkDeleteFailure[] = []\n for (const row of rows) {\n try {\n await deleteOne(row)\n succeeded.push(row)\n } catch (err) {\n const message =\n err instanceof Error && err.message.trim().length > 0 ? err.message : fallback\n const rawCode =\n err && typeof err === 'object' ? (err as { code?: unknown }).code : undefined\n const code = typeof rawCode === 'string' && rawCode.length > 0 ? rawCode : null\n failures.push({ id: row.id, code, message })\n if (logTag && typeof console !== 'undefined' && typeof console.warn === 'function') {\n console.warn(`[${logTag}] bulk delete failed`, { id: row.id, code, message, err })\n }\n }\n }\n return { succeeded, failures }\n}\n\nexport type BulkDeleteFailureGroup = {\n key: string\n count: number\n sampleMessage: string\n ids: string[]\n}\n\n// Keyed by `code` when present so semantically identical failures collapse into\n// one toast even if their message text varies (e.g. row-specific dependency\n// counts). Falls back to the raw message when the server did not surface a code.\nexport function groupBulkDeleteFailures(\n failures: BulkDeleteFailure[],\n): BulkDeleteFailureGroup[] {\n const buckets = new Map<string, BulkDeleteFailureGroup>()\n for (const failure of failures) {\n const key = failure.code || failure.message || 'unknown'\n const existing = buckets.get(key)\n if (existing) {\n existing.count += 1\n existing.ids.push(failure.id)\n continue\n }\n buckets.set(key, {\n key,\n count: 1,\n sampleMessage: failure.message,\n ids: [failure.id],\n })\n }\n return Array.from(buckets.values())\n}\n"],
5
+ "mappings": ";AAkBA,eAAsB,cACpB,MACA,WACA,SAC+B;AAC/B,QAAM,WAAW,SAAS,wBAAwB;AAClD,QAAM,SAAS,SAAS;AACxB,QAAM,YAAiB,CAAC;AACxB,QAAM,WAAgC,CAAC;AACvC,aAAW,OAAO,MAAM;AACtB,QAAI;AACF,YAAM,UAAU,GAAG;AACnB,gBAAU,KAAK,GAAG;AAAA,IACpB,SAAS,KAAK;AACZ,YAAM,UACJ,eAAe,SAAS,IAAI,QAAQ,KAAK,EAAE,SAAS,IAAI,IAAI,UAAU;AACxE,YAAM,UACJ,OAAO,OAAO,QAAQ,WAAY,IAA2B,OAAO;AACtE,YAAM,OAAO,OAAO,YAAY,YAAY,QAAQ,SAAS,IAAI,UAAU;AAC3E,eAAS,KAAK,EAAE,IAAI,IAAI,IAAI,MAAM,QAAQ,CAAC;AAC3C,UAAI,UAAU,OAAO,YAAY,eAAe,OAAO,QAAQ,SAAS,YAAY;AAClF,gBAAQ,KAAK,IAAI,MAAM,wBAAwB,EAAE,IAAI,IAAI,IAAI,MAAM,SAAS,IAAI,CAAC;AAAA,MACnF;AAAA,IACF;AAAA,EACF;AACA,SAAO,EAAE,WAAW,SAAS;AAC/B;AAYO,SAAS,wBACd,UAC0B;AAC1B,QAAM,UAAU,oBAAI,IAAoC;AACxD,aAAW,WAAW,UAAU;AAC9B,UAAM,MAAM,QAAQ,QAAQ,QAAQ,WAAW;AAC/C,UAAM,WAAW,QAAQ,IAAI,GAAG;AAChC,QAAI,UAAU;AACZ,eAAS,SAAS;AAClB,eAAS,IAAI,KAAK,QAAQ,EAAE;AAC5B;AAAA,IACF;AACA,YAAQ,IAAI,KAAK;AAAA,MACf;AAAA,MACA,OAAO;AAAA,MACP,eAAe,QAAQ;AAAA,MACvB,KAAK,CAAC,QAAQ,EAAE;AAAA,IAClB,CAAC;AAAA,EACH;AACA,SAAO,MAAM,KAAK,QAAQ,OAAO,CAAC;AACpC;",
6
+ "names": []
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.6.1-develop.3227.1.2b7b8ab258",
3
+ "version": "0.6.1-develop.3246.1.dbef9d7392",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -140,12 +140,12 @@
140
140
  "remark-gfm": "^4.0.1"
141
141
  },
142
142
  "peerDependencies": {
143
- "@open-mercato/shared": "0.6.1-develop.3227.1.2b7b8ab258",
143
+ "@open-mercato/shared": "0.6.1-develop.3246.1.dbef9d7392",
144
144
  "react": ">=18.0.0",
145
145
  "react-dom": ">=18.0.0"
146
146
  },
147
147
  "devDependencies": {
148
- "@open-mercato/shared": "0.6.1-develop.3227.1.2b7b8ab258",
148
+ "@open-mercato/shared": "0.6.1-develop.3246.1.dbef9d7392",
149
149
  "@testing-library/dom": "^10.4.1",
150
150
  "@testing-library/jest-dom": "^6.9.1",
151
151
  "@testing-library/react": "^16.3.1",
@@ -24,20 +24,27 @@ export function LastOperationBanner() {
24
24
  async function handleUndo() {
25
25
  const undoToken = operation?.undoToken
26
26
  if (!undoToken || isPending) return
27
+ const tokens = operation.bulkUndoTokens && operation.bulkUndoTokens.length > 0
28
+ ? operation.bulkUndoTokens
29
+ : [undoToken]
27
30
  setPendingToken(undoToken)
31
+ const completed: string[] = []
28
32
  try {
29
- const call = await apiCall<Record<string, unknown>>('/api/audit_logs/audit-logs/actions/undo', {
30
- method: 'POST',
31
- headers: { 'Content-Type': 'application/json' },
32
- body: JSON.stringify({ undoToken }),
33
- })
34
- if (!call.ok) {
35
- const message =
36
- (call.result && typeof call.result.error === 'string' && call.result.error) ||
37
- ''
38
- throw new Error(message || t('audit_logs.banner.undo_failed', 'Failed to undo'))
33
+ for (const token of tokens.slice().reverse()) {
34
+ const call = await apiCall<Record<string, unknown>>('/api/audit_logs/audit-logs/actions/undo', {
35
+ method: 'POST',
36
+ headers: { 'Content-Type': 'application/json' },
37
+ body: JSON.stringify({ undoToken: token }),
38
+ })
39
+ if (!call.ok) {
40
+ const message =
41
+ (call.result && typeof call.result.error === 'string' && call.result.error) ||
42
+ ''
43
+ throw new Error(message || t('audit_logs.banner.undo_failed', 'Failed to undo'))
44
+ }
45
+ completed.push(token)
39
46
  }
40
- markUndoSuccess(undoToken)
47
+ markUndoSuccess(tokens)
41
48
  flash(t('audit_logs.banner.undo_success'), 'success')
42
49
  router.refresh()
43
50
  if (typeof window !== 'undefined') {
@@ -53,6 +60,7 @@ export function LastOperationBanner() {
53
60
  }
54
61
  }
55
62
  } catch (err) {
63
+ if (completed.length > 0) markUndoSuccess(completed)
56
64
  const message = err instanceof Error && err.message ? err.message : t('audit_logs.banner.undo_error')
57
65
  flash(message, 'error')
58
66
  } finally {
@@ -78,10 +78,55 @@ describe('LastOperationBanner', () => {
78
78
  method: 'POST',
79
79
  }))
80
80
  })
81
- expect(markUndoSuccess).toHaveBeenCalledWith('token-123')
81
+ expect(markUndoSuccess).toHaveBeenCalledWith(['token-123'])
82
82
  expect(flash).toHaveBeenCalledWith('Undo completed', 'success')
83
83
  })
84
84
 
85
+ it('iterates bulkUndoTokens in reverse and reports completed tokens to the store on full success', async () => {
86
+ ;(useLastOperation as jest.Mock).mockReturnValue({
87
+ ...mockOperation,
88
+ undoToken: 'bulk:abc',
89
+ bulkUndoTokens: ['tk-1', 'tk-2', 'tk-3'],
90
+ bulkCount: 3,
91
+ })
92
+ ;(apiCall as jest.Mock).mockResolvedValue({
93
+ ok: true,
94
+ status: 200,
95
+ result: {},
96
+ response: createMockResponse(200),
97
+ })
98
+
99
+ renderWithProviders(<LastOperationBanner />, { dict })
100
+ fireEvent.click(screen.getByRole('button', { name: /undo/i }))
101
+
102
+ await waitFor(() => {
103
+ expect(apiCall).toHaveBeenCalledTimes(3)
104
+ })
105
+ const tokenBodies = (apiCall as jest.Mock).mock.calls.map((call) => JSON.parse(call[1].body))
106
+ expect(tokenBodies.map((body: { undoToken: string }) => body.undoToken)).toEqual(['tk-3', 'tk-2', 'tk-1'])
107
+ expect(markUndoSuccess).toHaveBeenCalledWith(['tk-1', 'tk-2', 'tk-3'])
108
+ })
109
+
110
+ it('on partial bulk failure flashes the error and reports only the completed tokens', async () => {
111
+ ;(useLastOperation as jest.Mock).mockReturnValue({
112
+ ...mockOperation,
113
+ undoToken: 'bulk:abc',
114
+ bulkUndoTokens: ['tk-1', 'tk-2', 'tk-3'],
115
+ bulkCount: 3,
116
+ })
117
+ ;(apiCall as jest.Mock)
118
+ .mockResolvedValueOnce({ ok: true, status: 200, result: {}, response: createMockResponse(200) })
119
+ .mockResolvedValueOnce({ ok: false, status: 500, result: { error: 'boom' }, response: createMockResponse(500) })
120
+
121
+ renderWithProviders(<LastOperationBanner />, { dict })
122
+ fireEvent.click(screen.getByRole('button', { name: /undo/i }))
123
+
124
+ await waitFor(() => {
125
+ expect(flash).toHaveBeenCalledWith('boom', 'error')
126
+ })
127
+ expect(markUndoSuccess).toHaveBeenCalledWith(['tk-3'])
128
+ })
129
+
85
130
  it('surfaces undo errors from the API', async () => {
86
131
  ;(apiCall as jest.Mock).mockResolvedValue({
87
132
  ok: false,
@@ -0,0 +1,142 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import {
6
+ coalesceLastOperations,
7
+ markUndoSuccess,
8
+ pushOperation,
9
+ clearAllOperations,
10
+ } from '../store'
11
+
12
+ function makeMeta(id: string, undoToken: string, commandId = 'customers.companies.delete') {
13
+ return {
14
+ id,
15
+ undoToken,
16
+ commandId,
17
+ actionLabel: 'Delete company',
18
+ resourceKind: 'customers.company',
19
+ resourceId: id,
20
+ executedAt: new Date().toISOString(),
21
+ }
22
+ }
23
+
24
+ describe('operations store — bulk coalesce', () => {
25
+ beforeEach(() => {
26
+ clearAllOperations()
27
+ })
28
+
29
+ it('replaces the last N entries with a single synthetic bulk entry', () => {
30
+ pushOperation(makeMeta('op-1', 'tk-1'))
31
+ pushOperation(makeMeta('op-2', 'tk-2'))
32
+ pushOperation(makeMeta('op-3', 'tk-3'))
33
+
34
+ coalesceLastOperations(3, {
35
+ commandId: 'customers.companies.delete',
36
+ actionLabel: 'Delete 3 companies',
37
+ })
38
+
39
+ const state = JSON.parse(window.localStorage.getItem('om:last-operations:v1')!)
40
+ expect(state.stack).toHaveLength(1)
41
+ const synthetic = state.stack[0]
42
+ expect(synthetic.bulkUndoTokens).toEqual(['tk-1', 'tk-2', 'tk-3'])
43
+ expect(synthetic.bulkCount).toBe(3)
44
+ expect(synthetic.actionLabel).toBe('Delete 3 companies')
45
+ })
46
+
47
+ it('is a no-op when count is 1 or less', () => {
48
+ pushOperation(makeMeta('op-1', 'tk-1'))
49
+ coalesceLastOperations(1, { commandId: 'customers.companies.delete' })
50
+ const state = JSON.parse(window.localStorage.getItem('om:last-operations:v1')!)
51
+ expect(state.stack).toHaveLength(1)
52
+ expect(state.stack[0].bulkUndoTokens).toBeUndefined()
53
+ })
54
+
55
+ it('refuses to coalesce when commandIds in the tail do not match', () => {
56
+ pushOperation(makeMeta('op-1', 'tk-1', 'customers.companies.delete'))
57
+ pushOperation(makeMeta('op-2', 'tk-2', 'customers.people.delete'))
58
+
59
+ coalesceLastOperations(2, { commandId: 'customers.companies.delete' })
60
+ const state = JSON.parse(window.localStorage.getItem('om:last-operations:v1')!)
61
+ expect(state.stack).toHaveLength(2)
62
+ expect(state.stack[1].bulkUndoTokens).toBeUndefined()
63
+ })
64
+
65
+ it('preserves earlier entries that fall outside the coalesce window', () => {
66
+ pushOperation(makeMeta('earlier', 'tk-earlier', 'customers.deals.update'))
67
+ pushOperation(makeMeta('op-1', 'tk-1'))
68
+ pushOperation(makeMeta('op-2', 'tk-2'))
69
+
70
+ coalesceLastOperations(2, {
71
+ commandId: 'customers.companies.delete',
72
+ actionLabel: 'Delete 2 companies',
73
+ })
74
+
75
+ const state = JSON.parse(window.localStorage.getItem('om:last-operations:v1')!)
76
+ expect(state.stack).toHaveLength(2)
77
+ expect(state.stack[0].id).toBe('earlier')
78
+ expect(state.stack[1].bulkCount).toBe(2)
79
+ })
80
+ })
81
+
82
+ describe('markUndoSuccess — bulk-aware', () => {
83
+ beforeEach(() => {
84
+ clearAllOperations()
85
+ })
86
+
87
+ it('removes a bulk entry when ALL of its bulkUndoTokens match', () => {
88
+ pushOperation(makeMeta('op-1', 'tk-1'))
89
+ pushOperation(makeMeta('op-2', 'tk-2'))
90
+ coalesceLastOperations(2, {
91
+ commandId: 'customers.companies.delete',
92
+ actionLabel: 'Delete 2 companies',
93
+ })
94
+
95
+ markUndoSuccess(['tk-1', 'tk-2'])
96
+ const state = JSON.parse(window.localStorage.getItem('om:last-operations:v1')!)
97
+ expect(state.stack).toHaveLength(0)
98
+ expect(state.undone).toHaveLength(1)
99
+ expect(state.undone[0].bulkUndoTokens).toEqual(['tk-1', 'tk-2'])
100
+ })
101
+
102
+ it('splits a bulk entry on partial undo, keeping the unconsumed tokens on the stack', () => {
103
+ pushOperation(makeMeta('op-1', 'tk-1'))
104
+ pushOperation(makeMeta('op-2', 'tk-2'))
105
+ pushOperation(makeMeta('op-3', 'tk-3'))
106
+ coalesceLastOperations(3, {
107
+ commandId: 'customers.companies.delete',
108
+ actionLabel: 'Delete 3 companies',
109
+ })
110
+
111
+ markUndoSuccess(['tk-2', 'tk-3'])
112
+ const state = JSON.parse(window.localStorage.getItem('om:last-operations:v1')!)
113
+ expect(state.stack).toHaveLength(1)
114
+ expect(state.stack[0].bulkUndoTokens).toEqual(['tk-1'])
115
+ expect(state.stack[0].bulkCount).toBe(1)
116
+ expect(state.undone).toHaveLength(1)
117
+ expect(state.undone[0].bulkUndoTokens).toEqual(['tk-2', 'tk-3'])
118
+ expect(state.undone[0].bulkCount).toBe(2)
119
+ })
120
+
121
+ it('coalesce assigns a synthetic id distinct from the source operations', () => {
122
+ pushOperation(makeMeta('op-1', 'tk-1'))
123
+ pushOperation(makeMeta('op-2', 'tk-2'))
124
+ coalesceLastOperations(2, {
125
+ commandId: 'customers.companies.delete',
126
+ actionLabel: 'Delete 2 companies',
127
+ })
128
+
129
+ const state = JSON.parse(window.localStorage.getItem('om:last-operations:v1')!)
130
+ expect(state.stack[0].id).not.toBe('op-1')
131
+ expect(state.stack[0].id).not.toBe('op-2')
132
+ expect(state.stack[0].id).toMatch(/^bulk:/)
133
+ expect(state.stack[0].undoToken).toMatch(/^bulk:/)
134
+ })
135
+
136
+ it('accepts a single string for legacy callers', () => {
137
+ pushOperation(makeMeta('op-solo', 'tk-solo'))
138
+ markUndoSuccess('tk-solo')
139
+ const state = JSON.parse(window.localStorage.getItem('om:last-operations:v1')!)
140
+ expect(state.stack).toHaveLength(0)
141
+ })
142
+ })
@@ -4,6 +4,8 @@ import type { OperationMetadataPayload } from '@open-mercato/shared/lib/commands
4
4
 
5
5
  export type OperationEntry = OperationMetadataPayload & {
6
6
  receivedAt: number
7
+ bulkUndoTokens?: string[]
8
+ bulkCount?: number
7
9
  }
8
10
 
9
11
  export type UndoneEntry = OperationEntry & {
@@ -71,6 +73,12 @@ function isValidEntry(entry: unknown): entry is OperationEntry {
71
73
 
72
74
  function hydrateEntry(entry: unknown): OperationEntry {
73
75
  const source = entry as Partial<OperationEntry> & Record<string, unknown>
76
+ const bulkTokens = Array.isArray(source.bulkUndoTokens)
77
+ ? source.bulkUndoTokens.filter((t): t is string => typeof t === 'string' && t.length > 0)
78
+ : undefined
79
+ const bulkCount = typeof source.bulkCount === 'number' && Number.isFinite(source.bulkCount)
80
+ ? source.bulkCount
81
+ : undefined
74
82
  return {
75
83
  id: String(source.id),
76
84
  undoToken: String(source.undoToken),
@@ -80,6 +88,8 @@ function hydrateEntry(entry: unknown): OperationEntry {
80
88
  resourceId: typeof source.resourceId === 'string' ? source.resourceId : null,
81
89
  executedAt: typeof source.executedAt === 'string' ? source.executedAt : new Date((source.receivedAt as number | undefined) || now()).toISOString(),
82
90
  receivedAt: typeof source.receivedAt === 'number' ? source.receivedAt : now(),
91
+ ...(bulkTokens && bulkTokens.length > 0 ? { bulkUndoTokens: bulkTokens } : {}),
92
+ ...(bulkCount && bulkCount > 0 ? { bulkCount } : {}),
83
93
  }
84
94
  }
85
95
 
@@ -154,21 +164,80 @@ export function pushOperation(meta: OperationMetadataPayload) {
154
164
  })
155
165
  }
156
166
 
157
- export function markUndoSuccess(undoToken: string) {
167
+ export function markUndoSuccess(undoTokens: string | string[]) {
158
168
  if (typeof window === 'undefined') return
169
+ const tokenSet = new Set(Array.isArray(undoTokens) ? undoTokens : [undoTokens])
170
+ if (tokenSet.size === 0) return
159
171
  const removed: OperationEntry[] = []
160
172
  updateState((prev) => {
161
- const stack = prev.stack.filter((entry) => {
162
- if (entry.undoToken === undoToken) {
173
+ const nextStack: OperationEntry[] = []
174
+ for (const entry of prev.stack) {
175
+ const bulk = entry.bulkUndoTokens && entry.bulkUndoTokens.length > 0 ? entry.bulkUndoTokens : null
176
+ if (!bulk) {
177
+ if (tokenSet.has(entry.undoToken)) removed.push(entry)
178
+ else nextStack.push(entry)
179
+ continue
180
+ }
181
+ const consumed: string[] = []
182
+ const remaining: string[] = []
183
+ for (const token of bulk) {
184
+ if (tokenSet.has(token)) consumed.push(token)
185
+ else remaining.push(token)
186
+ }
187
+ if (consumed.length === 0) {
188
+ nextStack.push(entry)
189
+ } else if (remaining.length === 0) {
163
190
  removed.push(entry)
164
- return false
191
+ } else {
192
+ removed.push({ ...entry, bulkUndoTokens: consumed, bulkCount: consumed.length })
193
+ nextStack.push({ ...entry, bulkUndoTokens: remaining, bulkCount: remaining.length })
165
194
  }
166
- return true
167
- })
195
+ }
168
196
  const undone = removed.length
169
197
  ? [...prev.undone, ...removed.map((entry) => ({ ...entry, undoneAt: now() }))]
170
198
  : prev.undone
171
- return { stack, undone }
199
+ return { stack: nextStack, undone }
200
+ })
201
+ }
202
+
203
+ export type CoalesceOptions = {
204
+ commandId?: string
205
+ actionLabel?: string | null
206
+ resourceKind?: string | null
207
+ }
208
+
209
+ function generateBulkId(seed: string): string {
210
+ const cryptoRef = typeof globalThis !== 'undefined' ? (globalThis as { crypto?: { randomUUID?: () => string } }).crypto : undefined
211
+ if (cryptoRef && typeof cryptoRef.randomUUID === 'function') {
212
+ return `bulk:${cryptoRef.randomUUID()}`
213
+ }
214
+ return `bulk:${seed}:${now()}`
215
+ }
216
+
217
+ export function coalesceLastOperations(count: number, options: CoalesceOptions = {}): void {
218
+ if (typeof window === 'undefined' || count <= 1) return
219
+ updateState((prev) => {
220
+ if (prev.stack.length < count) return prev
221
+ const tail = prev.stack.slice(-count)
222
+ if (options.commandId && !tail.every((entry) => entry.commandId === options.commandId)) {
223
+ return prev
224
+ }
225
+ const head = prev.stack.slice(0, prev.stack.length - count)
226
+ const last = tail[tail.length - 1]
227
+ const tokens = tail.map((entry) => entry.undoToken)
228
+ const bulkId = generateBulkId(last.id)
229
+ const synthetic: OperationEntry = {
230
+ ...last,
231
+ id: bulkId,
232
+ undoToken: bulkId,
233
+ actionLabel: options.actionLabel ?? last.actionLabel,
234
+ resourceKind: options.resourceKind ?? last.resourceKind,
235
+ resourceId: null,
236
+ bulkUndoTokens: tokens,
237
+ bulkCount: tail.length,
238
+ receivedAt: now(),
239
+ }
240
+ return { stack: [...head, synthetic], undone: prev.undone }
172
241
  })
173
242
  }
174
243
 
@@ -0,0 +1,103 @@
1
+ import { groupBulkDeleteFailures, runBulkDelete, type BulkDeleteFailure } from '../bulkDelete'
2
+
3
+ describe('runBulkDelete', () => {
4
+ it('captures error message and code from thrown error', async () => {
5
+ const dependencyErr = Object.assign(new Error('Cannot delete company: linked deals (2).'), {
6
+ code: 'COMPANY_HAS_DEPENDENTS',
7
+ status: 422,
8
+ })
9
+ const rows = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]
10
+ const deleteOne = jest.fn().mockImplementation(async (row: { id: string }) => {
11
+ if (row.id === 'b') throw dependencyErr
12
+ })
13
+
14
+ const { succeeded, failures } = await runBulkDelete(rows, deleteOne)
15
+
16
+ expect(succeeded).toEqual([{ id: 'a' }, { id: 'c' }])
17
+ expect(failures).toEqual([
18
+ { id: 'b', code: 'COMPANY_HAS_DEPENDENTS', message: 'Cannot delete company: linked deals (2).' },
19
+ ])
20
+ })
21
+
22
+ it('falls back to fallbackErrorMessage when the error has no message', async () => {
23
+ const empty = Object.assign(new Error(''), {})
24
+ const deleteOne = jest.fn().mockRejectedValue(empty)
25
+
26
+ const { failures } = await runBulkDelete([{ id: 'x' }], deleteOne, {
27
+ fallbackErrorMessage: 'Failed to delete record.',
28
+ })
29
+
30
+ expect(failures[0]).toEqual({ id: 'x', code: null, message: 'Failed to delete record.' })
31
+ })
32
+
33
+ it('treats non-string code as null', async () => {
34
+ const err = Object.assign(new Error('boom'), { code: 500 })
35
+ const deleteOne = jest.fn().mockRejectedValue(err)
36
+
37
+ const { failures } = await runBulkDelete([{ id: 'x' }], deleteOne)
38
+
39
+ expect(failures[0].code).toBeNull()
40
+ })
41
+
42
+ it('logs failures via console.warn when logTag is provided', async () => {
43
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
44
+ try {
45
+ const err = Object.assign(new Error('linked deals (2).'), { code: 'COMPANY_HAS_DEPENDENTS' })
46
+ const deleteOne = jest.fn().mockRejectedValue(err)
47
+
48
+ await runBulkDelete([{ id: 'x' }], deleteOne, { logTag: 'customers.companies.list' })
49
+
50
+ expect(warnSpy).toHaveBeenCalledTimes(1)
51
+ const [tag, payload] = warnSpy.mock.calls[0]
52
+ expect(tag).toBe('[customers.companies.list] bulk delete failed')
53
+ expect(payload).toMatchObject({ id: 'x', code: 'COMPANY_HAS_DEPENDENTS' })
54
+ } finally {
55
+ warnSpy.mockRestore()
56
+ }
57
+ })
58
+
59
+ it('does not log when logTag is omitted', async () => {
60
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
61
+ try {
62
+ const deleteOne = jest.fn().mockRejectedValue(new Error('boom'))
63
+ await runBulkDelete([{ id: 'x' }], deleteOne)
64
+ expect(warnSpy).not.toHaveBeenCalled()
65
+ } finally {
66
+ warnSpy.mockRestore()
67
+ }
68
+ })
69
+ })
70
+
71
+ describe('groupBulkDeleteFailures', () => {
72
+ it('groups failures by error code and counts occurrences', () => {
73
+ const failures: BulkDeleteFailure[] = [
74
+ { id: '1', code: 'HAS_DEPENDENTS', message: 'Cannot delete (a)' },
75
+ { id: '2', code: 'HAS_DEPENDENTS', message: 'Cannot delete (b)' },
76
+ { id: '3', code: 'OTHER_ERROR', message: 'Different' },
77
+ ]
78
+
79
+ const groups = groupBulkDeleteFailures(failures)
80
+
81
+ expect(groups).toHaveLength(2)
82
+ const dependents = groups.find((g) => g.key === 'HAS_DEPENDENTS')!
83
+ expect(dependents.count).toBe(2)
84
+ expect(dependents.ids).toEqual(['1', '2'])
85
+ expect(dependents.sampleMessage).toBe('Cannot delete (a)')
86
+ })
87
+
88
+ it('falls back to message when no code is present', () => {
89
+ const failures: BulkDeleteFailure[] = [
90
+ { id: '1', code: null, message: 'Same reason' },
91
+ { id: '2', code: null, message: 'Same reason' },
92
+ { id: '3', code: null, message: 'Different reason' },
93
+ ]
94
+
95
+ const groups = groupBulkDeleteFailures(failures)
96
+
97
+ expect(groups.map((g) => g.count).sort()).toEqual([1, 2])
98
+ })
99
+
100
+ it('returns empty array when no failures', () => {
101
+ expect(groupBulkDeleteFailures([])).toEqual([])
102
+ })
103
+ })
@@ -0,0 +1,77 @@
1
+ "use client"
2
+
3
+ export type BulkDeleteFailure = {
4
+ id: string
5
+ code: string | null
6
+ message: string
7
+ }
8
+
9
+ export type BulkDeleteOutcome<T extends { id: string }> = {
10
+ succeeded: T[]
11
+ failures: BulkDeleteFailure[]
12
+ }
13
+
14
+ export type BulkDeleteOptions = {
15
+ fallbackErrorMessage?: string
16
+ logTag?: string
17
+ }
18
+
19
+ export async function runBulkDelete<T extends { id: string }>(
20
+ rows: T[],
21
+ deleteOne: (row: T) => Promise<void>,
22
+ options?: BulkDeleteOptions,
23
+ ): Promise<BulkDeleteOutcome<T>> {
24
+ const fallback = options?.fallbackErrorMessage ?? 'Delete failed'
25
+ const logTag = options?.logTag
26
+ const succeeded: T[] = []
27
+ const failures: BulkDeleteFailure[] = []
28
+ for (const row of rows) {
29
+ try {
30
+ await deleteOne(row)
31
+ succeeded.push(row)
32
+ } catch (err) {
33
+ const message =
34
+ err instanceof Error && err.message.trim().length > 0 ? err.message : fallback
35
+ const rawCode =
36
+ err && typeof err === 'object' ? (err as { code?: unknown }).code : undefined
37
+ const code = typeof rawCode === 'string' && rawCode.length > 0 ? rawCode : null
38
+ failures.push({ id: row.id, code, message })
39
+ if (logTag && typeof console !== 'undefined' && typeof console.warn === 'function') {
40
+ console.warn(`[${logTag}] bulk delete failed`, { id: row.id, code, message, err })
41
+ }
42
+ }
43
+ }
44
+ return { succeeded, failures }
45
+ }
46
+
47
+ export type BulkDeleteFailureGroup = {
48
+ key: string
49
+ count: number
50
+ sampleMessage: string
51
+ ids: string[]
52
+ }
53
+
54
+ // Keyed by `code` when present so semantically identical failures collapse into
55
+ // one toast even if their message text varies (e.g. row-specific dependency
56
+ // counts). Falls back to the raw message when the server did not surface a code.
57
+ export function groupBulkDeleteFailures(
58
+ failures: BulkDeleteFailure[],
59
+ ): BulkDeleteFailureGroup[] {
60
+ const buckets = new Map<string, BulkDeleteFailureGroup>()
61
+ for (const failure of failures) {
62
+ const key = failure.code || failure.message || 'unknown'
63
+ const existing = buckets.get(key)
64
+ if (existing) {
65
+ existing.count += 1
66
+ existing.ids.push(failure.id)
67
+ continue
68
+ }
69
+ buckets.set(key, {
70
+ key,
71
+ count: 1,
72
+ sampleMessage: failure.message,
73
+ ids: [failure.id],
74
+ })
75
+ }
76
+ return Array.from(buckets.values())
77
+ }