@flowdular/sdk 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/.ai/platform-capabilities.md +2 -0
  2. package/.ai/references/catalog/migrations/0005_catalog_list_indexes.down.sql +3 -0
  3. package/.ai/references/catalog/migrations/0005_catalog_list_indexes.up.sql +11 -0
  4. package/.ai/references/catalog/module.json +11 -1
  5. package/.ai/references/catalog/package.json +2 -2
  6. package/.ai/references/catalog/spec/module.yaml +25 -4
  7. package/.ai/references/catalog/src/agent/tools.ts +19 -10
  8. package/.ai/references/catalog/src/api/endpoints.ts +150 -10
  9. package/.ai/references/catalog/src/api/list-cursor.ts +83 -0
  10. package/.ai/references/catalog/src/client/CatalogView.tsrx +505 -159
  11. package/.ai/references/catalog/src/client/api.ts +124 -36
  12. package/.ai/references/catalog/src/client/contribution.tsrx +5 -0
  13. package/.ai/references/catalog/src/client/state.ts +169 -3
  14. package/.ai/references/catalog/src/domain/lists.ts +7 -0
  15. package/.ai/references/catalog/src/domain/types.ts +20 -0
  16. package/.ai/references/catalog/src/platform.ts +20 -0
  17. package/.ai/references/catalog/src/services/catalog-service.ts +143 -8
  18. package/.ai/references/catalog/src/services/database-repository.ts +104 -17
  19. package/.ai/references/catalog/src/services/item-export.ts +81 -0
  20. package/.ai/references/catalog/src/services/migration.ts +27 -1
  21. package/.ai/references/catalog/src/services/repository.ts +31 -2
  22. package/.ai/references/catalog/tests/agent-tools.test.ts +6 -5
  23. package/.ai/references/catalog/tests/client-state.test.ts +124 -0
  24. package/.ai/references/catalog/tests/endpoints.test.ts +269 -0
  25. package/.ai/references/catalog/tests/export.test.ts +134 -0
  26. package/.ai/references/catalog/tests/idempotency.test.ts +15 -14
  27. package/.ai/references/catalog/tests/list.test.ts +217 -0
  28. package/.ai/references/catalog/tests/migrations.test.ts +58 -2
  29. package/.ai/references/catalog/tests/module.test.ts +2 -1
  30. package/.ai/references/catalog/tests/support/database.ts +14 -0
  31. package/.ai/references/catalog/translations/en.json +35 -4
  32. package/.ai/references/catalog/translations/pl.json +35 -4
  33. package/.ai/references/catalog.provenance.json +34 -26
  34. package/README.md +1 -1
  35. package/assets/flowdular-banner.webp +0 -0
  36. package/modules/access/module.json +1 -1
  37. package/modules/access/spec/module.yaml +1 -1
  38. package/modules/agents/module.json +1 -1
  39. package/modules/agents/spec/module.yaml +1 -1
  40. package/modules/approvals/module.json +1 -1
  41. package/modules/approvals/spec/module.yaml +1 -1
  42. package/modules/audit/module.json +1 -1
  43. package/modules/audit/spec/module.yaml +1 -1
  44. package/modules/auth/module.json +2 -2
  45. package/modules/auth/package.json +1 -1
  46. package/modules/auth/spec/module.yaml +2 -2
  47. package/modules/auth/src/middleware/authentication.ts +5 -1
  48. package/modules/auth/src/services/auth-service.ts +26 -0
  49. package/modules/auth/src/services/database-repository.ts +9 -0
  50. package/modules/auth/src/services/repository.ts +2 -0
  51. package/modules/automations/module.json +1 -1
  52. package/modules/automations/spec/module.yaml +1 -1
  53. package/modules/connectors/module.json +1 -1
  54. package/modules/connectors/spec/module.yaml +1 -1
  55. package/modules/directory/module.json +1 -1
  56. package/modules/directory/spec/module.yaml +1 -1
  57. package/modules/documents/module.json +1 -1
  58. package/modules/documents/spec/module.yaml +1 -1
  59. package/modules/exports/module.json +1 -1
  60. package/modules/exports/spec/module.yaml +1 -1
  61. package/modules/import/module.json +1 -1
  62. package/modules/import/spec/module.yaml +1 -1
  63. package/modules/metering/module.json +1 -1
  64. package/modules/metering/spec/module.yaml +1 -1
  65. package/modules/notifications/module.json +1 -1
  66. package/modules/notifications/spec/module.yaml +1 -1
  67. package/modules/reports/module.json +1 -1
  68. package/modules/reports/spec/module.yaml +1 -1
  69. package/modules/sandbox/module.json +1 -1
  70. package/modules/sandbox/spec/module.yaml +1 -1
  71. package/modules/search/module.json +1 -1
  72. package/modules/search/spec/module.yaml +1 -1
  73. package/modules/system/migrations/0001_system_module_activations.down.sql +2 -0
  74. package/modules/system/migrations/0001_system_module_activations.up.sql +17 -0
  75. package/modules/system/module.json +3 -2
  76. package/modules/system/package.json +2 -2
  77. package/modules/system/spec/module.yaml +98 -1
  78. package/modules/system/src/client/ModulesView.tsrx +101 -8
  79. package/modules/system/src/client/api.ts +52 -11
  80. package/modules/system/src/client/module-columns.tsrx +32 -6
  81. package/modules/system/src/client/state.ts +26 -0
  82. package/modules/system/src/domain/modules.ts +93 -0
  83. package/modules/system/src/index.ts +13 -0
  84. package/modules/system/src/platform.ts +41 -1
  85. package/modules/system/src/server/capability.ts +6 -0
  86. package/modules/system/src/server/endpoints.ts +125 -5
  87. package/modules/system/src/server/index.ts +26 -0
  88. package/modules/system/src/server/module-catalog.ts +19 -0
  89. package/modules/system/src/server/runtime.ts +102 -0
  90. package/modules/system/src/services/database-repository.ts +91 -0
  91. package/modules/system/src/services/migration.ts +39 -0
  92. package/modules/system/src/services/module-activation-service.ts +245 -0
  93. package/modules/system/src/services/repository.ts +17 -0
  94. package/modules/system/translations/en.json +17 -1
  95. package/modules/system/translations/pl.json +17 -1
  96. package/modules/users/module.json +2 -2
  97. package/modules/users/package.json +1 -1
  98. package/modules/users/spec/module.yaml +6 -9
  99. package/modules/users/src/client/UsersView.tsrx +27 -13
  100. package/modules/users/src/client/member-columns.tsrx +13 -4
  101. package/modules/users/src/client/state.ts +16 -0
  102. package/modules/users/src/services/users-service.ts +3 -0
  103. package/modules/users/translations/en.json +2 -1
  104. package/modules/users/translations/pl.json +2 -1
  105. package/modules/workflows/module.json +2 -2
  106. package/modules/workflows/package.json +1 -1
  107. package/modules/workflows/spec/module.yaml +2 -2
  108. package/modules/workflows/src/services/database-repository.ts +93 -53
  109. package/package.json +1 -1
  110. package/packages/client/package.json +1 -0
  111. package/packages/client/src/ApplicationShell.tsrx +46 -5
  112. package/packages/client/src/index.ts +5 -0
  113. package/packages/client/src/shell/modules.ts +49 -0
  114. package/packages/client/src/shell/types.ts +3 -0
  115. package/packages/contracts/src/index.ts +11 -1
  116. package/packages/server/src/endpoint.ts +17 -1
  117. package/packages/server/src/index.ts +10 -0
  118. package/packages/server/src/module-activation.ts +69 -0
  119. package/assets/flowdular-banner.png +0 -0
@@ -4,41 +4,82 @@ import {
4
4
  Button,
5
5
  ConfirmDialog,
6
6
  Drawer,
7
+ EmptyState,
7
8
  Filters,
8
9
  Icon,
9
10
  Kpi,
10
11
  PageHeader,
12
+ Pagination,
11
13
  SearchField,
14
+ Select,
12
15
  TableCard,
13
16
  Tag,
17
+ ToastHost,
18
+ toasts,
14
19
  type TableColumn,
20
+ type TableSort,
15
21
  } from '@flowdular/sdk/ui';
16
22
  import { activeLocale, t } from '@flowdular/sdk/client';
17
23
  import { useValue } from 'segment-state';
18
- import type { CatalogItem, CatalogItemKind } from '../domain/types.ts';
24
+ import type {
25
+ CatalogBulkOutcome,
26
+ CatalogItem,
27
+ CatalogItemKind,
28
+ } from '../domain/types.ts';
29
+ import { CATALOG_ITEMS_EXPORT_LIST_ID } from '../domain/lists.ts';
19
30
  import {
31
+ ApiError,
20
32
  archiveCatalogItem,
33
+ archiveCatalogItems,
21
34
  createCatalogItem,
22
35
  deleteCatalogItem,
23
36
  loadCatalogItemHistory,
24
37
  loadCatalogItems,
25
38
  restoreCatalogItem,
39
+ restoreCatalogItems,
40
+ startListExport,
26
41
  updateCatalogItem,
27
42
  } from './api.ts';
28
- import { createCatalogClientState } from './state.ts';
43
+ import {
44
+ bulkOutcomeCounts,
45
+ bulkTargets,
46
+ catalogSort,
47
+ createCatalogClientState,
48
+ DEFAULT_CATALOG_SORTING,
49
+ DEFAULT_STATUS_FILTER,
50
+ loadedListing,
51
+ pageCursor,
52
+ replaceItem,
53
+ resetPageCursors,
54
+ searchPending,
55
+ type CatalogKindFilter,
56
+ type CatalogStatusFilter,
57
+ type Listing,
58
+ } from './state.ts';
29
59
  import { CatalogItemForm } from './CatalogItemForm.tsrx';
30
60
  import { CatalogHistoryDrawer } from './CatalogHistoryDrawer.tsrx';
31
61
 
32
62
  export interface CatalogViewProps {
33
63
  readonly csrfToken: string;
34
64
  readonly canManage: boolean;
65
+ /** Whether the principal may start an export job; exports.core owns it. */
66
+ readonly canExport: boolean;
35
67
  }
36
68
 
69
+ const PAGE_SIZES = [25, 50, 100, 200];
70
+ const SEARCH_DEBOUNCE_MS = 300;
71
+ /** The widget reads one page at the ceiling and says "more" past it. */
72
+ const WIDGET_PAGE = 200;
73
+
37
74
  function formValue(form: FormData, key: string): string {
38
75
  const value = form.get(key);
39
76
  return typeof value === 'string' ? value : '';
40
77
  }
41
78
 
79
+ function message(error: unknown, fallback: string): string {
80
+ return error instanceof Error ? error.message : fallback;
81
+ }
82
+
42
83
  function price(minor: number, currency: string): string {
43
84
  try {
44
85
  return new Intl.NumberFormat(activeLocale(), {
@@ -50,21 +91,36 @@ function price(minor: number, currency: string): string {
50
91
  }
51
92
  }
52
93
 
94
+ function updatedAt(value: number): string {
95
+ return new Intl.DateTimeFormat(activeLocale(), {
96
+ dateStyle: 'medium',
97
+ timeStyle: 'short',
98
+ }).format(new Date(value));
99
+ }
100
+
53
101
  function columns(): readonly TableColumn<CatalogItem>[] {
54
102
  return [
55
103
  {
56
- key: 'item',
57
- header: t('catalog.table.column.item'),
58
- width: '36%',
104
+ key: 'name',
105
+ header: t('catalog.table.column.name'),
106
+ width: '28%',
107
+ value: (item) => item.name,
59
108
  cell: (item) => <span class="ui-cell">
60
109
  <b>{item.name}</b>
61
- <small class="ui-mono">{item.sku + ' · ' + item.unit}</small>
110
+ <small>{item.unit}</small>
62
111
  </span>,
63
112
  },
113
+ {
114
+ key: 'sku',
115
+ header: t('catalog.table.column.sku'),
116
+ width: '16%',
117
+ value: (item) => item.sku,
118
+ cell: (item) => <span class="ui-mono">{item.sku}</span>,
119
+ },
64
120
  {
65
121
  key: 'kind',
66
122
  header: t('catalog.table.column.kind'),
67
- width: '16%',
123
+ width: '12%',
68
124
  cell: (item) =>
69
125
  <Tag tone={item.kind === 'product' ? 'info' : 'neutral'}>{t(
70
126
  'catalog.kind.' + item.kind,
@@ -73,14 +129,21 @@ function columns(): readonly TableColumn<CatalogItem>[] {
73
129
  {
74
130
  key: 'price',
75
131
  header: t('catalog.table.column.price'),
76
- width: '22%',
132
+ width: '14%',
77
133
  numeric: true,
78
134
  cell: (item) => price(item.basePriceMinor, item.currency),
79
135
  },
136
+ {
137
+ key: 'updatedAt',
138
+ header: t('catalog.table.column.updated'),
139
+ width: '18%',
140
+ value: (item) => item.updatedAt,
141
+ cell: (item) => updatedAt(item.updatedAt),
142
+ },
80
143
  {
81
144
  key: 'status',
82
145
  header: t('catalog.table.column.status'),
83
- width: '16%',
146
+ width: '12%',
84
147
  cell: (item) =>
85
148
  <Tag tone={item.status === 'active' ? 'success' : 'neutral'} dot>{t(
86
149
  'catalog.status.' + item.status,
@@ -91,13 +154,22 @@ function columns(): readonly TableColumn<CatalogItem>[] {
91
154
 
92
155
  export function CatalogView(props: CatalogViewProps) @{
93
156
  const catalogState = useMemo(() => createCatalogClientState(), []);
157
+ /* Answers arrive in any order; only the latest request may write the page. */
158
+ const requests = useMemo(() => ({ sequence: 0 }), []);
94
159
  const [items] = useValue(catalogState.state.items);
95
160
  const [status, setStatus] = useValue(catalogState.state.status);
96
161
  const [error, setError] = useValue(catalogState.state.error);
97
162
  const [query, setQuery] = useValue(catalogState.state.query);
98
- const [includeArchived, setIncludeArchived] = useValue(
99
- catalogState.state.includeArchived,
163
+ const [appliedQuery] = useValue(catalogState.state.appliedQuery);
164
+ const [kindFilter, setKindFilter] = useValue(catalogState.state.kindFilter);
165
+ const [statusFilter, setStatusFilter] = useValue(
166
+ catalogState.state.statusFilter,
100
167
  );
168
+ const [sorting, setSorting] = useValue(catalogState.state.sorting);
169
+ const [pageIndex] = useValue(catalogState.state.pageIndex);
170
+ const [pageSize, setPageSize] = useValue(catalogState.state.pageSize);
171
+ const [cursors] = useValue(catalogState.state.cursors);
172
+ const [nextCursor] = useValue(catalogState.state.nextCursor);
101
173
  const [filtersOpen, setFiltersOpen] = useValue(
102
174
  catalogState.state.filtersOpen,
103
175
  );
@@ -124,40 +196,145 @@ export function CatalogView(props: CatalogViewProps) @{
124
196
  const [historyError, setHistoryError] = useValue(
125
197
  catalogState.state.historyError,
126
198
  );
127
- const locale = activeLocale();
128
- const term = query.trim().toLocaleLowerCase(locale);
129
- const visible = items.filter(
130
- (item) =>
131
- (includeArchived || item.status === 'active') &&
132
- (term === '' || item.name.toLocaleLowerCase(locale).includes(term) ||
133
- item.sku.toLocaleLowerCase(locale).includes(term)),
199
+ const [selectedIds, setSelectedIds] = useValue(
200
+ catalogState.state.selectedIds,
201
+ );
202
+ const [confirmArchiveMany, setConfirmArchiveMany] = useValue(
203
+ catalogState.state.confirmArchiveMany,
204
+ );
205
+ const [exportStarted, setExportStarted] = useValue(
206
+ catalogState.state.exportStarted,
134
207
  );
135
- const filtered = term !== '' || includeArchived;
208
+ const term = query.trim();
209
+ const hasMore = nextCursor !== null;
210
+ const archivable = bulkTargets(items, selectedIds, 'active');
211
+ const restorable = bulkTargets(items, selectedIds, 'archived');
212
+ const filterCount =
213
+ (kindFilter === '' ? 0 : 1) +
214
+ (statusFilter === DEFAULT_STATUS_FILTER ? 0 : 1);
215
+ const listing = (): Listing => ({
216
+ pageIndex,
217
+ pageSize,
218
+ sorting,
219
+ query: appliedQuery,
220
+ kind: kindFilter,
221
+ status: statusFilter,
222
+ cursors,
223
+ });
224
+ /* The search timer fires after later renders; it reads the listing those
225
+ renders left here rather than the one the keystroke saw. */
226
+ const latest = useMemo(() => ({ listing: listing() }), []);
227
+ latest.listing = listing();
228
+ /* A changed order, filter or page size is a new listing, whose cursors
229
+ name positions in the old one. */
230
+ const firstPage = (changes: Partial<Listing>): Listing => ({
231
+ ...listing(),
232
+ ...changes,
233
+ pageIndex: 0,
234
+ cursors: resetPageCursors(),
235
+ });
236
+
237
+ const failLoad = (loadError: unknown) => {
238
+ setError(message(loadError, t('catalog.error.load')));
239
+ setStatus(
240
+ loadError instanceof ApiError && loadError.status === 403
241
+ ? 'denied'
242
+ : 'error',
243
+ );
244
+ };
136
245
 
137
- const refresh = async () => {
246
+ /* The server sorts, narrows and cuts the page; the listing travels as an
247
+ argument because the click that changes it and the load it starts happen
248
+ in one render, where the state hooks still read the old values. */
249
+ const load = async (next: Listing) => {
250
+ const cursor = pageCursor(next.cursors, next.pageIndex);
251
+ if (cursor === undefined) return;
252
+ const sequence = requests.sequence + 1;
253
+ requests.sequence = sequence;
138
254
  setStatus('loading');
139
255
  setError('');
140
256
  try {
141
- const records = await loadCatalogItems();
142
- catalogState.store.act(
143
- (transaction) => transaction.set(catalogState.state.items, records),
144
- 'catalog/loaded',
145
- );
257
+ const { sort, direction } = catalogSort(next.sorting);
258
+ const page = await loadCatalogItems({
259
+ sort,
260
+ direction,
261
+ limit: next.pageSize,
262
+ query: next.query,
263
+ kind: next.kind,
264
+ status: next.status,
265
+ cursor,
266
+ });
267
+ if (sequence !== requests.sequence) return;
268
+ const loaded = loadedListing(next, page);
269
+ catalogState.store.act((transaction) => {
270
+ transaction.set(catalogState.state.items, loaded.items);
271
+ transaction.set(catalogState.state.appliedQuery, loaded.appliedQuery);
272
+ transaction.set(catalogState.state.pageIndex, loaded.pageIndex);
273
+ transaction.set(catalogState.state.cursors, loaded.cursors);
274
+ transaction.set(catalogState.state.nextCursor, loaded.nextCursor);
275
+ transaction.set(catalogState.state.selectedIds, loaded.selectedIds);
276
+ transaction.set(catalogState.state.status, 'idle');
277
+ }, 'catalog/loaded');
146
278
  } catch (loadError) {
147
- setError(
148
- loadError instanceof Error
149
- ? loadError.message
150
- : t('catalog.error.load'),
151
- );
152
- } finally {
153
- setStatus('idle');
279
+ if (sequence === requests.sequence) failLoad(loadError);
154
280
  }
155
281
  };
156
282
 
283
+ const refresh = () => {
284
+ setExportStarted(false);
285
+ return load(firstPage({}));
286
+ };
287
+
288
+ const openPage = (index: number) => {
289
+ if (index !== pageIndex) void load({ ...listing(), pageIndex: index });
290
+ };
291
+
292
+ const changeSorting = (sorts: readonly TableSort[]) => {
293
+ const next =
294
+ sorts.length === 0 ? DEFAULT_CATALOG_SORTING : sorts;
295
+ setSorting(next);
296
+ void load(firstPage({ sorting: next }));
297
+ };
298
+
299
+ const changeKindFilter = (value: string) => {
300
+ const next: CatalogKindFilter =
301
+ value === 'product' || value === 'service' ? value : '';
302
+ setKindFilter(next);
303
+ void load(firstPage({ kind: next }));
304
+ };
305
+
306
+ const changeStatusFilter = (value: string) => {
307
+ const next: CatalogStatusFilter =
308
+ value === 'active' || value === 'archived' ? value : '';
309
+ setStatusFilter(next);
310
+ void load(firstPage({ status: next }));
311
+ };
312
+
313
+ const changePageSize = (size: number) => {
314
+ setPageSize(size);
315
+ void load(firstPage({ pageSize: size }));
316
+ };
317
+
157
318
  useEffect(() => {
158
319
  void refresh();
159
320
  }, []);
160
321
 
322
+ /* The term is the server's filter, so typing it starts a new listing rather
323
+ than narrowing the page on screen: one listing per pause in the typing. */
324
+ useEffect(() => {
325
+ if (!searchPending(query, appliedQuery)) return;
326
+ const timer = setTimeout(
327
+ () => void load({
328
+ ...latest.listing,
329
+ query: term,
330
+ pageIndex: 0,
331
+ cursors: resetPageCursors(),
332
+ }),
333
+ SEARCH_DEBOUNCE_MS,
334
+ );
335
+ return () => clearTimeout(timer);
336
+ }, [query]);
337
+
161
338
  const openForm = (item: CatalogItem | null) => {
162
339
  setEditingItem(item);
163
340
  setError('');
@@ -175,9 +352,9 @@ export function CatalogView(props: CatalogViewProps) @{
175
352
  catalogState.store.act(
176
353
  (transaction) => transaction.set(
177
354
  catalogState.state.items,
178
- items.map((current) => current.id === item.id ? item : current),
355
+ replaceItem(items, item),
179
356
  ),
180
- 'catalog/lifecycle',
357
+ 'catalog/item-updated',
181
358
  );
182
359
  setEditingItem(item);
183
360
  };
@@ -196,34 +373,32 @@ export function CatalogView(props: CatalogViewProps) @{
196
373
  basePriceMinor: Number(formValue(form, 'basePriceMinor')),
197
374
  currency: formValue(form, 'currency'),
198
375
  };
199
- const item = editingItem
200
- ? await updateCatalogItem(
376
+ if (editingItem) {
377
+ replace(
378
+ await updateCatalogItem(
201
379
  { id: editingItem.id, ...mutable },
202
380
  props.csrfToken,
203
- )
204
- : await createCatalogItem(
205
- { sku: formValue(form, 'sku'), ...mutable },
206
- props.csrfToken,
207
- );
208
- const next =
209
- editingItem
210
- ? items.map((current) => current.id === item.id ? item : current)
211
- : [...items, item];
212
- catalogState.store.act(
213
- (transaction) => transaction.set(
214
- catalogState.state.items,
215
- [...next].sort((left, right) => left.sku.localeCompare(right.sku)),
216
- ),
217
- editingItem ? 'catalog/updated' : 'catalog/created',
218
- );
219
- closeForm();
381
+ ),
382
+ );
383
+ closeForm();
384
+ setStatus('idle');
385
+ } else {
386
+ await createCatalogItem(
387
+ { sku: formValue(form, 'sku'), ...mutable },
388
+ props.csrfToken,
389
+ );
390
+ closeForm();
391
+ /* The new row belongs wherever the order puts it; the first page
392
+ of the listing shows it or the page it is on does. */
393
+ await load(firstPage({}));
394
+ }
220
395
  } catch (saveError) {
221
396
  setError(
222
- saveError instanceof Error
223
- ? saveError.message
224
- : t(editingItem ? 'catalog.error.update' : 'catalog.error.create'),
397
+ message(
398
+ saveError,
399
+ t(editingItem ? 'catalog.error.update' : 'catalog.error.create'),
400
+ ),
225
401
  );
226
- } finally {
227
402
  setStatus('idle');
228
403
  }
229
404
  };
@@ -238,13 +413,11 @@ export function CatalogView(props: CatalogViewProps) @{
238
413
  ? await archiveCatalogItem(editingItem.id, props.csrfToken)
239
414
  : await restoreCatalogItem(editingItem.id, props.csrfToken);
240
415
  replace(item);
416
+ setStatus('idle');
417
+ /* Under a status filter the row no longer belongs on this page. */
418
+ if (statusFilter !== '') await load(listing());
241
419
  } catch (lifecycleError) {
242
- setError(
243
- lifecycleError instanceof Error
244
- ? lifecycleError.message
245
- : t('catalog.error.lifecycle'),
246
- );
247
- } finally {
420
+ setError(message(lifecycleError, t('catalog.error.lifecycle')));
248
421
  setStatus('idle');
249
422
  }
250
423
  };
@@ -255,22 +428,68 @@ export function CatalogView(props: CatalogViewProps) @{
255
428
  setError('');
256
429
  try {
257
430
  await deleteCatalogItem(editingItem.id, props.csrfToken);
258
- catalogState.store.act(
259
- (transaction) => transaction.set(
260
- catalogState.state.items,
261
- items.filter((item) => item.id !== editingItem.id),
262
- ),
263
- 'catalog/deleted',
264
- );
265
431
  setDeleteConfirmOpen(false);
266
432
  closeForm();
433
+ await load(listing());
267
434
  } catch (deleteError) {
268
- setError(
269
- deleteError instanceof Error
270
- ? deleteError.message
271
- : t('catalog.error.delete'),
272
- );
273
- } finally {
435
+ setError(message(deleteError, t('catalog.error.delete')));
436
+ setStatus('idle');
437
+ }
438
+ };
439
+
440
+ /* Both bulk actions go the same way: the qualifying selected ids on screen,
441
+ one outcome per id, then the listing is read again and the counts are
442
+ told. A refused or missing id is a count, never a failure of the call. */
443
+ const applyMany = async (
444
+ ids: readonly string[],
445
+ run: (ids: readonly string[]) => Promise<readonly CatalogBulkOutcome[]>,
446
+ done: string,
447
+ fallback: string,
448
+ ) => {
449
+ if (ids.length === 0) return;
450
+ setStatus('submitting');
451
+ setError('');
452
+ try {
453
+ const outcomes = await run(ids);
454
+ await load(listing());
455
+ toasts.success(t(done, bulkOutcomeCounts(outcomes)));
456
+ } catch (bulkError) {
457
+ setError(message(bulkError, t(fallback)));
458
+ setStatus('idle');
459
+ }
460
+ };
461
+
462
+ const archiveMany = () => {
463
+ setConfirmArchiveMany(false);
464
+ void applyMany(
465
+ archivable,
466
+ (ids) => archiveCatalogItems(ids, props.csrfToken),
467
+ 'catalog.notice.archivedMany',
468
+ 'catalog.error.archiveMany',
469
+ );
470
+ };
471
+
472
+ const restoreMany = () => {
473
+ void applyMany(
474
+ restorable,
475
+ (ids) => restoreCatalogItems(ids, props.csrfToken),
476
+ 'catalog.notice.restoredMany',
477
+ 'catalog.error.restoreMany',
478
+ );
479
+ };
480
+
481
+ /* exports.core answers as soon as the job is recorded, not when the file is
482
+ written, so the screen reports that it started and points at the screen
483
+ that hands it over. */
484
+ const startExport = async () => {
485
+ setStatus('submitting');
486
+ setError('');
487
+ try {
488
+ await startListExport(CATALOG_ITEMS_EXPORT_LIST_ID, props.csrfToken);
489
+ setExportStarted(true);
490
+ setStatus('idle');
491
+ } catch (exportError) {
492
+ setError(message(exportError, t('catalog.export.error')));
274
493
  setStatus('idle');
275
494
  }
276
495
  };
@@ -294,11 +513,7 @@ export function CatalogView(props: CatalogViewProps) @{
294
513
  'catalog/history-loaded',
295
514
  );
296
515
  } catch (historyFailure) {
297
- setHistoryError(
298
- historyFailure instanceof Error
299
- ? historyFailure.message
300
- : t('catalog.error.history'),
301
- );
516
+ setHistoryError(message(historyFailure, t('catalog.error.history')));
302
517
  } finally {
303
518
  setHistoryLoading(false);
304
519
  }
@@ -314,85 +529,189 @@ export function CatalogView(props: CatalogViewProps) @{
314
529
  <Icon name="refresh" size={14} />
315
530
  {t('catalog.page.action.refresh')}
316
531
  </Button>
532
+ @if (props.canExport) {
533
+ <Button
534
+ size="sm"
535
+ disabled={status === 'denied' || status === 'submitting'}
536
+ onClick={() => void startExport()}
537
+ >
538
+ <Icon name="download" size={14} />
539
+ {t('catalog.export.action')}
540
+ </Button>
541
+ }
317
542
  @if (props.canManage) {
318
- <Button size="sm" variant="primary" onClick={() => openForm(null)}>
543
+ <Button
544
+ size="sm"
545
+ variant="primary"
546
+ disabled={status === 'denied'}
547
+ onClick={() => openForm(null)}
548
+ >
319
549
  <Icon name="plus" size={14} />
320
550
  {t('catalog.page.action.new')}
321
551
  </Button>
322
552
  }
323
553
  </PageHeader>
324
- @if (error && !formOpen) {
325
- <Alert>{error}</Alert>
554
+ @if (status === 'denied') {
555
+ <section class="ui-card">
556
+ <EmptyState icon="shield" title={t('catalog.denied.title')}>{t(
557
+ 'catalog.denied.description',
558
+ )}</EmptyState>
559
+ </section>
560
+ } @else {
561
+ <>
562
+ @if (error && !formOpen) {
563
+ <Alert>{error}</Alert>
564
+ }
565
+ @if (exportStarted) {
566
+ <Alert tone="info">
567
+ {t('catalog.export.started')}
568
+ {' '}
569
+ <a href="/exports">{t('catalog.export.link')}</a>
570
+ </Alert>
571
+ }
572
+ <TableCard
573
+ title={t('catalog.table.title')}
574
+ count={t('catalog.table.count.page', { count: items.length })}
575
+ search={<SearchField
576
+ value={query}
577
+ placeholder={t('catalog.table.search.placeholder')}
578
+ label={t('catalog.table.search.label')}
579
+ onInput={setQuery}
580
+ />}
581
+ filters={<Filters
582
+ open={filtersOpen}
583
+ onToggle={() => setFiltersOpen(!filtersOpen)}
584
+ activeCount={filterCount}
585
+ label={t('catalog.filters.label')}
586
+ >
587
+ <Select
588
+ id="catalog-kind-filter"
589
+ label={t('catalog.filters.kind')}
590
+ value={kindFilter}
591
+ options={[
592
+ { value: '', label: t('catalog.filters.allKinds') },
593
+ { value: 'product', label: t('catalog.kind.product') },
594
+ { value: 'service', label: t('catalog.kind.service') },
595
+ ]}
596
+ onChange={changeKindFilter}
597
+ />
598
+ <Select
599
+ id="catalog-status-filter"
600
+ label={t('catalog.filters.status')}
601
+ value={statusFilter}
602
+ options={[
603
+ { value: '', label: t('catalog.filters.allStatuses') },
604
+ { value: 'active', label: t('catalog.status.active') },
605
+ { value: 'archived', label: t('catalog.status.archived') },
606
+ ]}
607
+ onChange={changeStatusFilter}
608
+ />
609
+ </Filters>}
610
+ caption={t('catalog.table.caption')}
611
+ mode="server"
612
+ columns={columns()}
613
+ rows={items}
614
+ rowKey={(item) => item.id}
615
+ sorting
616
+ sortingState={sorting}
617
+ onSortingChange={changeSorting}
618
+ pagination={{
619
+ pageIndex,
620
+ pageSize,
621
+ onPageChange: openPage,
622
+ hasMore,
623
+ }}
624
+ after={<Pagination
625
+ pageIndex={pageIndex}
626
+ pageSize={pageSize}
627
+ hasMore={hasMore}
628
+ pageSizes={PAGE_SIZES}
629
+ onPageChange={openPage}
630
+ onPageSizeChange={changePageSize}
631
+ label={t('catalog.pagination.label')}
632
+ previousLabel={t('catalog.pagination.previous')}
633
+ nextLabel={t('catalog.pagination.next')}
634
+ pageSizeLabel={t('catalog.pagination.size')}
635
+ summary={(page) => t('catalog.pagination.summary', {
636
+ page: page.pageIndex + 1,
637
+ from: page.from,
638
+ })}
639
+ />}
640
+ status={status === 'loading' ? 'loading' : 'idle'}
641
+ loadingLabel={t('catalog.table.loading')}
642
+ filtered={appliedQuery !== '' || filterCount > 0}
643
+ empty={{
644
+ icon: 'catalog',
645
+ title: t('catalog.table.empty.title'),
646
+ hint: t('catalog.table.empty.hint'),
647
+ }}
648
+ emptyFiltered={{
649
+ icon: 'catalog',
650
+ title: t('catalog.table.emptyFiltered.title'),
651
+ hint: t('catalog.table.emptyFiltered.hint'),
652
+ }}
653
+ selection={props.canManage
654
+ ? {
655
+ selectedKeys: selectedIds,
656
+ onSelectionChange: setSelectedIds,
657
+ label: t('catalog.selection.label'),
658
+ rowLabel: (item) => t('catalog.selection.rowLabel', {
659
+ name: item.name,
660
+ }),
661
+ clearLabel: t('catalog.selection.clear'),
662
+ }
663
+ : undefined}
664
+ bulkActions={props.canManage
665
+ ? [
666
+ {
667
+ id: 'archive-selected',
668
+ label: t('catalog.action.archiveSelected'),
669
+ icon: 'x',
670
+ tone: 'danger',
671
+ disabled: archivable.length === 0 || status === 'submitting',
672
+ reason: archivable.length === 0
673
+ ? t('catalog.action.archiveRefused')
674
+ : undefined,
675
+ onSelect: () => setConfirmArchiveMany(true),
676
+ },
677
+ {
678
+ id: 'restore-selected',
679
+ label: t('catalog.action.restoreSelected'),
680
+ icon: 'refresh',
681
+ disabled: restorable.length === 0 || status === 'submitting',
682
+ reason: restorable.length === 0
683
+ ? t('catalog.action.restoreRefused')
684
+ : undefined,
685
+ onSelect: restoreMany,
686
+ },
687
+ ]
688
+ : undefined}
689
+ selectionSummary={(count) => t('catalog.selection.summary', {
690
+ count,
691
+ })}
692
+ actionsLabel={t('catalog.table.column.actions')}
693
+ actionsWidth="280px"
694
+ actions={(item) => [
695
+ {
696
+ id: 'history',
697
+ label: t('catalog.action.history'),
698
+ icon: 'activity',
699
+ onSelect: () => void openHistory(item),
700
+ },
701
+ ...(props.canManage
702
+ ? [
703
+ {
704
+ id: 'edit',
705
+ label: t('catalog.action.edit'),
706
+ icon: 'file-text',
707
+ onSelect: () => openForm(item),
708
+ },
709
+ ]
710
+ : []),
711
+ ]}
712
+ />
713
+ </>
326
714
  }
327
- <TableCard
328
- title={t('catalog.table.title')}
329
- count={t(
330
- items.length === 1
331
- ? 'catalog.table.count.one'
332
- : 'catalog.table.count.other',
333
- { count: items.length },
334
- )}
335
- search={<SearchField
336
- value={query}
337
- placeholder={t('catalog.table.search.placeholder')}
338
- label={t('catalog.table.search.label')}
339
- onInput={setQuery}
340
- />}
341
- filters={<Filters
342
- open={filtersOpen}
343
- onToggle={() => setFiltersOpen(!filtersOpen)}
344
- activeCount={includeArchived ? 1 : 0}
345
- label={t('catalog.filters.label')}
346
- >
347
- <label class="ui-checkbox">
348
- <input
349
- type="checkbox"
350
- checked={includeArchived}
351
- onChange={(event: Event) => setIncludeArchived(
352
- (event.currentTarget as HTMLInputElement).checked,
353
- )}
354
- />
355
- <span>{t('catalog.filters.includeArchived')}</span>
356
- </label>
357
- </Filters>}
358
- caption={t('catalog.table.caption')}
359
- columns={columns()}
360
- rows={visible}
361
- rowKey={(item) => item.id}
362
- status={status === 'loading' && items.length === 0 ? 'loading' : 'idle'}
363
- loadingLabel={t('catalog.table.loading')}
364
- filtered={filtered}
365
- empty={{
366
- icon: 'catalog',
367
- title: t('catalog.table.empty.title'),
368
- hint: t('catalog.table.empty.hint'),
369
- }}
370
- emptyFiltered={{
371
- icon: 'catalog',
372
- title: t('catalog.table.emptyFiltered.title'),
373
- hint: t('catalog.table.emptyFiltered.hint'),
374
- }}
375
- actionsLabel={t('catalog.table.column.actions')}
376
- actionsWidth="280px"
377
- actions={(item) => [
378
- {
379
- id: 'history',
380
- label: t('catalog.action.history'),
381
- icon: 'activity',
382
- onSelect: () => void openHistory(item),
383
- },
384
- ...(props.canManage
385
- ? [
386
- {
387
- id: 'edit',
388
- label: t('catalog.action.edit'),
389
- icon: 'file-text',
390
- onSelect: () => openForm(item),
391
- },
392
- ]
393
- : []),
394
- ]}
395
- />
396
715
  <Drawer
397
716
  open={formOpen}
398
717
  title={t(
@@ -445,27 +764,54 @@ export function CatalogView(props: CatalogViewProps) @{
445
764
  >{t('catalog.delete.description', {
446
765
  name: editingItem?.name ?? '',
447
766
  })}</ConfirmDialog>
767
+ <ConfirmDialog
768
+ open={confirmArchiveMany}
769
+ title={t('catalog.archiveMany.title')}
770
+ confirmLabel={t('catalog.action.archiveSelected')}
771
+ cancelLabel={t('catalog.form.cancel')}
772
+ busy={status === 'submitting'}
773
+ onConfirm={archiveMany}
774
+ onCancel={() => setConfirmArchiveMany(false)}
775
+ >{t('catalog.archiveMany.description', {
776
+ count: archivable.length,
777
+ })}</ConfirmDialog>
778
+ <ToastHost
779
+ label={t('catalog.common.messages')}
780
+ closeLabel={t('catalog.action.close')}
781
+ />
448
782
  </div>
449
783
  }
450
784
 
451
785
  export function CatalogDashboardWidget() @{
452
786
  const widgetState = useMemo(() => createCatalogClientState(), []);
453
787
  const [items] = useValue(widgetState.state.items);
788
+ const [nextCursor] = useValue(widgetState.state.nextCursor);
454
789
  const [status, setStatus] = useValue(widgetState.state.status);
455
790
  useEffect(() => {
456
791
  setStatus('loading');
457
- void loadCatalogItems().then((records) => {
458
- widgetState.store.act(
459
- (transaction) => transaction.set(widgetState.state.items, records),
460
- 'catalog/widget-loaded',
461
- );
792
+ void loadCatalogItems({
793
+ sort: 'name',
794
+ direction: 'asc',
795
+ limit: WIDGET_PAGE,
796
+ query: '',
797
+ kind: '',
798
+ status: 'active',
799
+ cursor: null,
800
+ }).then((page) => {
801
+ widgetState.store.act((transaction) => {
802
+ transaction.set(widgetState.state.items, page.items);
803
+ transaction.set(widgetState.state.nextCursor, page.page.nextCursor);
804
+ }, 'catalog/widget-loaded');
462
805
  }).catch(() => undefined).finally(() => setStatus('idle'));
463
806
  }, []);
807
+ const count = new Intl.NumberFormat(activeLocale()).format(items.length);
464
808
  <Kpi
465
809
  label={t('catalog.widget.label')}
466
810
  value={status === 'loading'
467
811
  ? '…'
468
- : new Intl.NumberFormat(activeLocale()).format(items.length)}
812
+ : nextCursor === null
813
+ ? count
814
+ : count + '+'}
469
815
  note={t('catalog.widget.note')}
470
816
  href="/catalog"
471
817
  linkLabel={t('catalog.widget.link')}