nexus-shared 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/CHANGELOG.md +795 -0
  2. package/README.md +220 -14
  3. package/dist/Client.Index.d.ts +13 -0
  4. package/dist/Client.Index.js +13 -0
  5. package/dist/Components/Chats/ChatComposer.d.ts +7 -1
  6. package/dist/Components/Chats/ChatComposer.js +9 -6
  7. package/dist/Components/Chats/ChatParts.d.ts +15 -2
  8. package/dist/Components/Chats/ChatParts.js +15 -4
  9. package/dist/Components/Chats/ChatPopup.d.ts +46 -0
  10. package/dist/Components/Chats/ChatPopup.js +34 -0
  11. package/dist/Components/Chats/ChatThread.d.ts +9 -1
  12. package/dist/Components/Chats/ChatThread.js +54 -6
  13. package/dist/Components/Dashboards/DashboardBoard.d.ts +62 -0
  14. package/dist/Components/Dashboards/DashboardBoard.js +594 -0
  15. package/dist/Components/Dashboards/DashboardWidgets.d.ts +155 -0
  16. package/dist/Components/Dashboards/DashboardWidgets.js +159 -0
  17. package/dist/Components/Dashboards/WidgetFrame.d.ts +51 -0
  18. package/dist/Components/Dashboards/WidgetFrame.js +45 -0
  19. package/dist/Components/Documents/Button.d.ts +6 -1
  20. package/dist/Components/Documents/Button.js +2 -2
  21. package/dist/Components/Documents/TabButtons.d.ts +6 -0
  22. package/dist/Components/Documents/TabButtons.js +5 -4
  23. package/dist/Components/Domains/DomainCard.d.ts +98 -0
  24. package/dist/Components/Domains/DomainCard.js +182 -0
  25. package/dist/Components/Forms/ApiForm.js +5 -1
  26. package/dist/Components/Forms/Crud.js +72 -24
  27. package/dist/Components/Forms/CrudActions.d.ts +5 -4
  28. package/dist/Components/Forms/CrudActions.js +42 -12
  29. package/dist/Components/Forms/CrudHistory.d.ts +6 -1
  30. package/dist/Components/Forms/CrudHistory.js +14 -6
  31. package/dist/Components/Forms/CrudParts.d.ts +11 -4
  32. package/dist/Components/Forms/CrudParts.js +76 -10
  33. package/dist/Components/Forms/CrudRowForms.js +65 -5
  34. package/dist/Components/Forms/CrudTree.js +7 -7
  35. package/dist/Components/Forms/CrudWorkspace.d.ts +15 -5
  36. package/dist/Components/Forms/CrudWorkspace.js +19 -11
  37. package/dist/Components/Forms/Form.d.ts +3 -2
  38. package/dist/Components/Forms/Form.js +33 -26
  39. package/dist/Components/Forms/FormQuestion.d.ts +6 -0
  40. package/dist/Components/Forms/FormQuestion.js +28 -0
  41. package/dist/Components/Forms/SubmitForm.js +2 -15
  42. package/dist/Components/Inputs/ComputedInput.d.ts +51 -0
  43. package/dist/Components/Inputs/ComputedInput.js +179 -0
  44. package/dist/Components/Inputs/Dropdown.js +4 -2
  45. package/dist/Components/Inputs/GroupForm.js +3 -2
  46. package/dist/Components/Inputs/InputRenderer.d.ts +5 -1
  47. package/dist/Components/Inputs/MarkdownEditor.d.ts +6 -0
  48. package/dist/Components/Inputs/MarkdownEditor.js +32 -6
  49. package/dist/Components/Inputs/PictureInput.js +8 -3
  50. package/dist/Components/Inputs/RowsInput.d.ts +14 -1
  51. package/dist/Components/Inputs/RowsInput.js +78 -5
  52. package/dist/Components/Inputs/TabularForm.js +16 -3
  53. package/dist/Components/Inputs/TreeView.d.ts +24 -1
  54. package/dist/Components/Inputs/TreeView.js +84 -7
  55. package/dist/Components/Layouts/Popup.d.ts +22 -2
  56. package/dist/Components/Layouts/Popup.js +108 -6
  57. package/dist/Components/Messages/MailClient.d.ts +50 -0
  58. package/dist/Components/Messages/MailClient.js +410 -0
  59. package/dist/Components/Messages/MailColumns.d.ts +21 -0
  60. package/dist/Components/Messages/MailColumns.js +92 -0
  61. package/dist/Components/Messages/MailCompose.d.ts +46 -0
  62. package/dist/Components/Messages/MailCompose.js +154 -0
  63. package/dist/Components/Messages/MailReader.d.ts +43 -0
  64. package/dist/Components/Messages/MailReader.js +207 -0
  65. package/dist/Components/Messages/MailThread.d.ts +71 -0
  66. package/dist/Components/Messages/MailThread.js +185 -0
  67. package/dist/Components/Messages/MessageBell.d.ts +41 -0
  68. package/dist/Components/Messages/MessageBell.js +76 -0
  69. package/dist/Components/Viewers/DataTable.js +7 -5
  70. package/dist/Components/Viewers/DataTableColumns.d.ts +2 -2
  71. package/dist/Components/Viewers/DataTableColumns.js +2 -1
  72. package/dist/Components/Viewers/DataTableParts.d.ts +3 -1
  73. package/dist/Components/Viewers/DataTableParts.js +2 -2
  74. package/dist/Components/Viewers/OwnerFiles.d.ts +57 -5
  75. package/dist/Components/Viewers/OwnerFiles.js +142 -31
  76. package/dist/Helpers/ApiModules.js +4 -0
  77. package/dist/Helpers/AuditEntries.d.ts +50 -0
  78. package/dist/Helpers/AuditEntries.js +96 -0
  79. package/dist/Helpers/ChatBackend.d.ts +8 -1
  80. package/dist/Helpers/ChatBackend.js +18 -0
  81. package/dist/Helpers/ChatHelpers.d.ts +2 -0
  82. package/dist/Helpers/ChatHelpers.js +4 -1
  83. package/dist/Helpers/CrudColumns.d.ts +44 -6
  84. package/dist/Helpers/CrudColumns.js +63 -13
  85. package/dist/Helpers/CrudHelpers.d.ts +36 -1
  86. package/dist/Helpers/CrudHelpers.js +61 -0
  87. package/dist/Helpers/CrudPickerPlan.d.ts +58 -0
  88. package/dist/Helpers/CrudPickerPlan.js +46 -0
  89. package/dist/Helpers/CrudRowForms.d.ts +18 -1
  90. package/dist/Helpers/CrudRowForms.js +35 -2
  91. package/dist/Helpers/DashboardHelpers.d.ts +115 -0
  92. package/dist/Helpers/DashboardHelpers.js +305 -0
  93. package/dist/Helpers/DomainCards.d.ts +140 -0
  94. package/dist/Helpers/DomainCards.js +370 -0
  95. package/dist/Helpers/FileAdapter.d.ts +82 -2
  96. package/dist/Helpers/FileAdapter.js +72 -1
  97. package/dist/Helpers/FormStore.d.ts +11 -1
  98. package/dist/Helpers/FormStore.js +66 -19
  99. package/dist/Helpers/InputConditions.d.ts +6 -0
  100. package/dist/Helpers/InputConditions.js +24 -0
  101. package/dist/Helpers/InputMemory.js +40 -30
  102. package/dist/Helpers/InputNarrowing.d.ts +38 -6
  103. package/dist/Helpers/InputNarrowing.js +102 -21
  104. package/dist/Helpers/MailClientHelpers.d.ts +96 -0
  105. package/dist/Helpers/MailClientHelpers.js +211 -0
  106. package/dist/Helpers/MessageBackend.d.ts +152 -0
  107. package/dist/Helpers/MessageBackend.js +329 -0
  108. package/dist/Helpers/MessageHelpers.d.ts +160 -0
  109. package/dist/Helpers/MessageHelpers.js +573 -0
  110. package/dist/Helpers/MessageUnread.d.ts +95 -0
  111. package/dist/Helpers/MessageUnread.js +245 -0
  112. package/dist/Helpers/NexusDriveAdapter.d.ts +17 -1
  113. package/dist/Helpers/NexusDriveAdapter.js +32 -3
  114. package/dist/Helpers/NumberHelpers.d.ts +61 -0
  115. package/dist/Helpers/NumberHelpers.js +124 -8
  116. package/dist/Helpers/PagedPicker.d.ts +42 -0
  117. package/dist/Helpers/PagedPicker.js +85 -0
  118. package/dist/Helpers/RowsStore.d.ts +24 -0
  119. package/dist/Helpers/RowsStore.js +79 -10
  120. package/dist/Helpers/TableHelpers.d.ts +15 -2
  121. package/dist/Helpers/TableHelpers.js +28 -1
  122. package/dist/Helpers/TableStore.d.ts +7 -3
  123. package/dist/Helpers/TableStore.js +9 -4
  124. package/dist/Helpers/ToolbarFit.d.ts +23 -0
  125. package/dist/Helpers/ToolbarFit.js +30 -0
  126. package/dist/Helpers/TreeStore.d.ts +75 -6
  127. package/dist/Helpers/TreeStore.js +182 -18
  128. package/dist/Interfaces/ApiInterfaces.d.ts +10 -1
  129. package/dist/Interfaces/ApiInterfaces.js +10 -1
  130. package/dist/Interfaces/ChatInterfaces.d.ts +22 -0
  131. package/dist/Interfaces/CrudInterfaces.d.ts +78 -5
  132. package/dist/Interfaces/DashboardInterfaces.d.ts +123 -0
  133. package/dist/Interfaces/DashboardInterfaces.js +1 -0
  134. package/dist/Interfaces/DomainInterfaces.d.ts +145 -0
  135. package/dist/Interfaces/DomainInterfaces.js +1 -0
  136. package/dist/Interfaces/FileInterfaces.d.ts +21 -0
  137. package/dist/Interfaces/FileInterfaces.js +2 -0
  138. package/dist/Interfaces/FormInterfaces.d.ts +78 -11
  139. package/dist/Interfaces/InputInterfaces.d.ts +12 -5
  140. package/dist/Interfaces/MailInterfaces.d.ts +192 -0
  141. package/dist/Interfaces/MailInterfaces.js +1 -0
  142. package/dist/Interfaces/TableInterfaces.d.ts +28 -2
  143. package/dist/Services/JoinSources.d.ts +47 -6
  144. package/dist/Services/JoinSources.js +68 -33
  145. package/dist/Services/MessageApi.d.ts +18 -0
  146. package/dist/Services/MessageApi.js +64 -0
  147. package/dist/Shared.Index.d.ts +12 -0
  148. package/dist/Shared.Index.js +12 -0
  149. package/package.json +7 -1
  150. package/src/Styles/Nexus.Button.css +29 -0
  151. package/src/Styles/Nexus.Chat.css +114 -22
  152. package/src/Styles/Nexus.Crud.css +119 -28
  153. package/src/Styles/Nexus.Dashboard.css +763 -0
  154. package/src/Styles/Nexus.Domain.css +384 -0
  155. package/src/Styles/Nexus.Editor.css +20 -0
  156. package/src/Styles/Nexus.Files.css +27 -0
  157. package/src/Styles/Nexus.Index.css +3 -0
  158. package/src/Styles/Nexus.Input.css +31 -0
  159. package/src/Styles/Nexus.Mail.css +1062 -0
  160. package/src/Styles/Nexus.Popup.css +58 -0
  161. package/src/Styles/Nexus.Table.css +1458 -1449
  162. package/src/Styles/Nexus.Theme.Picker.css +1 -0
  163. package/src/Styles/Nexus.Tree.css +10 -0
@@ -1,12 +1,12 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { iconEye, iconFile, iconFileText, iconPencil, iconPhoto, iconStar, iconTrash, iconUpload, iconX, InlineIcon } from "nexus-icons";
3
+ import { iconArrowBackUp, iconEye, iconFile, iconFileText, iconPencil, iconPhoto, iconStar, iconTrash, iconUpload, iconX, InlineIcon } from "nexus-icons";
4
4
  import { useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
5
5
  import { cx } from "../../Helpers/ClassHelpers.js";
6
6
  import { formatDateTime } from "../../Helpers/DateTimeHelpers.js";
7
7
  import { avatarTone, initials } from "../../Helpers/DropdownHelpers.js";
8
8
  import { useDatePreferences } from "../../Helpers/DatePreferences.js";
9
- import { acceptOf, fileKindName, fileKindsSentence, fileRefusal, fileSizeText, PICTURE_LIMITS, pictureLimits, pictureName, roomLeft, takesDocuments, typedFile } from "../../Helpers/FileAdapter.js";
9
+ import { acceptOf, allowsFileChange, fileKindName, fileChangesNote, fileKindsSentence, fileRefusal, fileSizeText, narrowFileChanges, ownerFileActions, PENDING_REMOVAL_TAG, PICTURE_LIMITS, pictureLimits, pictureName, removalHeld, removalHeldQuestion, removalHeldSentence, roomLeft, takesDocuments, typedFile, } from "../../Helpers/FileAdapter.js";
10
10
  import { readPath } from "../../Helpers/TableHelpers.js";
11
11
  import { isPictureType } from "../../Interfaces/FileInterfaces.js";
12
12
  import { getFileAdapter, primaryPictures } from "../../Services/FileApi.js";
@@ -86,6 +86,8 @@ export function useOwnerFiles(params) {
86
86
  const [busy, setBusy] = useState(null);
87
87
  // Why the image editor could not be opened, the last time it was tried: the input says so until it opens.
88
88
  const [editorProblem, setEditorProblem] = useState(null);
89
+ // The changes a refusal said are left for an owner (`FileAdapter.changesLeft`), with its words; another owner forgets it.
90
+ const [learned, setLearned] = useState(null);
89
91
  const latest = useRef(params);
90
92
  latest.current = params;
91
93
  const { module, category, ownerId } = params;
@@ -101,9 +103,8 @@ export function useOwnerFiles(params) {
101
103
  if (!answer.ok || !answer.result)
102
104
  return setStatus({ kind: "failed", message: answer.message ?? "The files could not be loaded." });
103
105
  setStatus({ kind: "ready", data: answer.result });
104
- const primary = answer.result.files.find(file => file.isPrimary) ?? null;
105
- primaryPictures.set(ownerOf(current), primary);
106
- return primary;
106
+ primaryPictures.set(ownerOf(current), answer.result.files.find(file => file.isPrimary) ?? null);
107
+ return answer.result;
107
108
  }, [ownerKey]);
108
109
  useEffect(() => {
109
110
  if (!ownerKey) {
@@ -121,20 +122,60 @@ export function useOwnerFiles(params) {
121
122
  /** Why nothing can be changed, when it is the page's or the backend's rule (not a form shown read only). */
122
123
  const readOnlyReason = locked ?? (data && !data.category.canWrite ? data.readOnlyReason || READ_ONLY_REASON : null);
123
124
  const pictures = data ? !takesDocuments(data.category) : true;
125
+ /** A Delete here waits for a review: the page said so, or the backend did. */
126
+ const holdsRemovals = Boolean(params.holdsRemovals || data?.category.holdsRemovals);
127
+ // The changes allowed: the page's, narrowed by what a refusal said of this owner ("only added to now").
128
+ const learnedNow = learned && learned.key === ownerKey ? learned : null;
129
+ const changes = narrowFileChanges(params.changes, learnedNow?.changes);
130
+ /** Why only some changes, while some are left out (`fileChangesNote`). */
131
+ const changesNote = canWrite ? fileChangesNote({ changes: params.changes, reason: params.changesReason }, learnedNow) : null;
132
+ /** The buttons offered for the owner's files, and for one file when given. */
133
+ const offer = (file) => ownerFileActions({ canWrite, changes, maxCount: data?.category.maxCount ?? 0, kept: data?.files.length ?? 0, holdsRemovals, keeps: typeof getFileAdapter().keep === "function" }, file);
134
+ /** "The picture stays until Nexus support approves its removal." for this file. */
135
+ const nounOf = (file) => (isPictureType(file.contentType) ? "picture" : "file");
136
+ const heldSentence = (file) => removalHeldSentence(nounOf(file), params.removalReviewer);
137
+ /** "If the public sees this picture, it stays until Nexus support approves its removal.": the answer decides. */
138
+ const heldQuestion = (file) => removalHeldQuestion(nounOf(file), params.removalReviewer);
139
+ /** A Delete held for a review: the file stays, marked, until the list is read again; the person is told once. */
140
+ function held(file, answered) {
141
+ const now = typeof answered === "object" && answered !== null && answered.id === file.id ? answered : null;
142
+ setStatus(current => current.kind !== "ready" ? current : { kind: "ready", data: { ...current.data, files: current.data.files.map(item => (item.id === file.id ? { ...item, ...now, pendingRemoval: true } : item)) } });
143
+ toast("Removal sent for review", { tone: "info", message: heldSentence(file) });
144
+ }
145
+ /**
146
+ * Says why a change failed, once; a refusal that says the files may now be changed only some ways (a receipt posted
147
+ * since this page read it) also narrows what is offered for this owner, under its words.
148
+ */
149
+ function refused(title, answer) {
150
+ toastFileFailure(title, answer);
151
+ const left = getFileAdapter().changesLeft(answer);
152
+ if (left && ownerKey)
153
+ setLearned({ key: ownerKey, changes: left, reason: answer.message ?? null });
154
+ }
155
+ // What the change under way did, for `onFilesChange` (the last, when it did several: an upload of three files).
156
+ const done = useRef(null);
157
+ const noted = (kind, file) => {
158
+ done.current = { kind, file };
159
+ };
124
160
  /** Runs one change with its line of progress, then reads the files again and tells the page. */
125
161
  async function change(label, work) {
126
162
  if (busy)
127
163
  return;
128
164
  setBusy(label);
165
+ done.current = null;
166
+ const before = data?.files ?? [];
129
167
  try {
130
168
  const changed = await work();
131
169
  if (changed) {
132
- const primary = await load();
133
- if (primary !== undefined)
134
- latest.current.onChange?.(primary);
170
+ const read = await load();
171
+ if (read)
172
+ latest.current.onChange?.(read.files.find(file => file.isPrimary) ?? null);
173
+ if (done.current)
174
+ latest.current.onFilesChange?.(read ? read.files : before, done.current);
135
175
  }
136
176
  }
137
177
  finally {
178
+ done.current = null;
138
179
  setBusy(null);
139
180
  }
140
181
  }
@@ -150,7 +191,7 @@ export function useOwnerFiles(params) {
150
191
  * `replacing`: the file a one-file category keeps, deleted once the editor has saved, since the category has no room
151
192
  * for both.
152
193
  */
153
- async function editAndStore(src, name, primary, replacing) {
194
+ async function editAndStore(src, name, primary, replacing, edited = false) {
154
195
  if (!owner || !data)
155
196
  return false;
156
197
  const module = await editor(setEditorProblem);
@@ -168,9 +209,16 @@ export function useOwnerFiles(params) {
168
209
  if (replacing) {
169
210
  const removed = await getFileAdapter().remove(owner, replacing.id);
170
211
  if (!removed.ok) {
171
- toastFileFailure("The picture was not replaced", removed);
212
+ refused("The picture was not replaced", removed);
172
213
  return false;
173
214
  }
215
+ // Held for a review (a category not known to hold removals before): it keeps its place, so nothing is uploaded.
216
+ if (removalHeld(removed.result)) {
217
+ held(replacing, removed.result);
218
+ noted("hold", replacing);
219
+ return true;
220
+ }
221
+ noted("delete", replacing);
174
222
  }
175
223
  const stored = await getFileAdapter().upload(owner, { file: saved.blob, name: pictureName(saved.name || name, saved.blob.type), thumbnail: saved.thumbnail });
176
224
  if (!stored.ok || !stored.result) {
@@ -178,11 +226,13 @@ export function useOwnerFiles(params) {
178
226
  // A replaced file is gone: the list is read again either way.
179
227
  return Boolean(replacing);
180
228
  }
181
- if (primary && !stored.result.isPrimary) {
229
+ noted(edited ? "edit" : "upload", stored.result);
230
+ // A picture may be added where the main one may not be chosen: it is kept, and stays as it is.
231
+ if (primary && !stored.result.isPrimary && allowsFileChange(changes, "primary")) {
182
232
  const made = await getFileAdapter().setPrimary(owner, stored.result.id);
183
233
  if (!made.ok) {
184
234
  // Stored all the same: the list is read again, and the person is told once, not also "saved".
185
- toastFileFailure("The picture was saved, but it is not the main picture yet", made);
235
+ refused("The picture was saved, but it is not the main picture yet", made);
186
236
  return true;
187
237
  }
188
238
  }
@@ -265,8 +315,10 @@ export function useOwnerFiles(params) {
265
315
  }
266
316
  }
267
317
  const answer = await getFileAdapter().upload(owner, { file: blob, name, thumbnail });
268
- if (answer.ok)
318
+ if (answer.ok) {
269
319
  stored++;
320
+ noted("upload", answer.result ?? null);
321
+ }
270
322
  else
271
323
  toastFileFailure(`"${file.name}" was not uploaded`, answer);
272
324
  }
@@ -283,7 +335,7 @@ export function useOwnerFiles(params) {
283
335
  * keeps one file, the new picture takes the place of the one kept (Replace).
284
336
  */
285
337
  replace: async () => {
286
- if (!data)
338
+ if (!data || !offer().upload)
287
339
  return;
288
340
  const replacing = data.category.maxCount === 1 ? data.files[0] : undefined;
289
341
  if (!replacing && !hasRoom(data))
@@ -294,7 +346,7 @@ export function useOwnerFiles(params) {
294
346
  },
295
347
  /** Chooses several files and stores them: each picture through the editor, one at a time (the gallery's Upload). */
296
348
  upload: async () => {
297
- if (!data || !hasRoom(data))
349
+ if (!data || !offer().upload || !hasRoom(data))
298
350
  return;
299
351
  const chosen = await chooseFiles(acceptOf(data.category), data.category.maxCount > 1);
300
352
  if (chosen.length > 0)
@@ -305,22 +357,25 @@ export function useOwnerFiles(params) {
305
357
  * that keeps one file, the edited picture takes its place.
306
358
  */
307
359
  edit: async (file) => {
308
- if (!data)
360
+ if (!data || !offer(file).edit)
309
361
  return;
310
362
  const replacing = data.category.maxCount === 1 && data.files.length === 1 ? file : undefined;
311
363
  if (!replacing && !hasRoom(data))
312
364
  return;
313
- await change("Saving", () => editAndStore(getFileAdapter().fileUrl(file, "original"), file.name, file.isPrimary || Boolean(replacing), replacing));
365
+ await change("Saving", () => editAndStore(getFileAdapter().fileUrl(file, "original"), file.name, file.isPrimary || Boolean(replacing), replacing, true));
314
366
  },
315
367
  makePrimary: (file) => owner &&
368
+ (file ? offer(file).makePrimary : offer().clearPrimary) &&
316
369
  change("Saving", async () => {
317
370
  const answer = await getFileAdapter().setPrimary(owner, file?.id ?? null);
318
371
  if (!answer.ok)
319
- toastFileFailure(file ? "This picture could not be made the main one" : "The picture could not be removed", answer);
372
+ refused(file ? "This picture could not be made the main one" : "The picture could not be removed", answer);
373
+ else
374
+ noted("primary", file);
320
375
  return answer.ok;
321
376
  }),
322
377
  rename: async (file) => {
323
- if (!owner)
378
+ if (!owner || !offer(file).rename)
324
379
  return;
325
380
  const name = await messageBox.prompt({ title: "Rename", label: "Name", defaultValue: file.name.replace(/\.[A-Za-z][A-Za-z0-9]{0,3}$/, ""), confirmLabel: "Rename" });
326
381
  if (name === null || !name.trim())
@@ -328,25 +383,53 @@ export function useOwnerFiles(params) {
328
383
  await change("Saving", async () => {
329
384
  const answer = await getFileAdapter().rename(owner, file.id, name.trim());
330
385
  if (!answer.ok)
331
- toastFileFailure("The file was not renamed", answer);
386
+ refused("The file was not renamed", answer);
387
+ else
388
+ noted("rename", file);
332
389
  return answer.ok;
333
390
  });
334
391
  },
335
392
  remove: async (file) => {
336
- if (!owner)
393
+ if (!owner || !offer(file).remove)
337
394
  return;
338
395
  const yes = await messageBox.confirm({
339
396
  title: `Delete "${file.name}"?`,
340
- message: file.isPrimary && (data?.files.length ?? 0) > 1 ? "It is the main picture now; the next one takes its place. It cannot be brought back." : "It cannot be brought back.",
341
- tone: "danger",
397
+ message: holdsRemovals
398
+ ? heldQuestion(file)
399
+ : file.isPrimary && (data?.files.length ?? 0) > 1
400
+ ? "It is the main picture now; the next one takes its place. It cannot be brought back."
401
+ : "It cannot be brought back.",
402
+ tone: holdsRemovals ? "warning" : "danger",
342
403
  confirmLabel: "Delete",
343
404
  });
344
405
  if (!yes)
345
406
  return;
346
- await change("Deleting", async () => {
407
+ await change(holdsRemovals ? "Sending the removal for review" : "Deleting", async () => {
347
408
  const answer = await getFileAdapter().remove(owner, file.id);
348
409
  if (!answer.ok)
349
- toastFileFailure("The file was not deleted", answer);
410
+ refused("The file was not deleted", answer);
411
+ else if (removalHeld(answer.result)) {
412
+ held(file, answer.result);
413
+ noted("hold", file);
414
+ }
415
+ else
416
+ noted("delete", file);
417
+ return answer.ok;
418
+ });
419
+ },
420
+ /** Keeps a file whose removal waits for a review: the removal is cancelled. */
421
+ keep: async (file) => {
422
+ const adapter = getFileAdapter();
423
+ if (!owner || !adapter.keep || !offer(file).keep)
424
+ return;
425
+ await change("Keeping", async () => {
426
+ const answer = await adapter.keep(owner, file.id);
427
+ if (!answer.ok)
428
+ refused("The removal was not cancelled", answer);
429
+ else {
430
+ toast("Removal cancelled", { tone: "success", message: `"${file.name}" is kept.` });
431
+ noted("keep", answer.result ?? file);
432
+ }
350
433
  return answer.ok;
351
434
  });
352
435
  },
@@ -365,7 +448,7 @@ export function useOwnerFiles(params) {
365
448
  });
366
449
  },
367
450
  };
368
- return { status, data, busy, canWrite, readOnlyReason, pictures, editorProblem, actions };
451
+ return { status, data, busy, canWrite, readOnlyReason, changes, changesNote, offer, pictures, holdsRemovals, editorProblem, actions };
369
452
  }
370
453
  const FOCUSABLE = 'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
371
454
  /**
@@ -450,6 +533,18 @@ export function OwnerPicture({ owner, size = 28, aspect = 1, shape = "rect", lab
450
533
  }
451
534
  return (_jsx("button", { type: "button", className: classes, style: style, "data-tone": tone, "aria-label": label, title: label, onClick: onClick, onPointerEnter: preloadImageEditor, children: face }));
452
535
  }
536
+ /**
537
+ * "Removal waiting for review" on a file whose Delete waits for a review, a warning tag; when it was asked in the
538
+ * person's date-time format beside it where there is room (`withTime`), else in its tooltip.
539
+ */
540
+ function PendingRemoval({ file, when, withTime, className }) {
541
+ const asked = file.removalAt ? formatDateTime(file.removalAt, when) : "";
542
+ return (_jsx("span", { className: cx("nx-table__tag", className), "data-tone": "warning", title: asked ? `Removal asked ${asked}` : undefined, children: withTime && asked ? `${PENDING_REMOVAL_TAG} since ${asked}` : PENDING_REMOVAL_TAG }));
543
+ }
544
+ /** Keep, in place of Delete on a file whose removal waits: cancels the removal. */
545
+ function KeepButton({ file, busy, keep, size = "sm" }) {
546
+ return (_jsx(Button, { size: size, variant: "ghost", icon: _jsx(InlineIcon, { icon: iconArrowBackUp }), "aria-label": `Keep ${file.name}`, title: "Keep it: cancel its removal", ...busyProps(busy, () => void keep(file)), children: "Keep" }));
547
+ }
453
548
  /** The held tiles a gallery draws while its pictures are read, so the grid is there at once. */
454
549
  const HELD_TILES = [0, 1, 2];
455
550
  /** "2 of 20 · PNG, JPEG or WebP, up to 300 KB each": how many are kept, of how many, what kinds and how large. */
@@ -482,7 +577,7 @@ function noRoomReason(owned, replaces = true) {
482
577
  export function PictureGallery(props) {
483
578
  const files = useOwnerFiles(props);
484
579
  const preferences = useDatePreferences();
485
- const { status, data, busy, canWrite, readOnlyReason, editorProblem, actions } = files;
580
+ const { status, data, busy, readOnlyReason, changesNote, offer, editorProblem, actions } = files;
486
581
  const rootRef = useRef(null);
487
582
  useFocusThroughChange(rootRef, busy);
488
583
  // The main picture's original that could not be shown (a file gone since the list was read): the icon stands in.
@@ -509,7 +604,11 @@ export function PictureGallery(props) {
509
604
  const owned = data;
510
605
  // Held at the limit with its reason, as the pictures' Upload is; pressed, it says the refusal (`hasRoom`).
511
606
  const full = noRoomReason(owned, false);
512
- return (_jsxs("div", { ref: rootRef, className: "nx-files", "data-state": "ready", "aria-busy": busy ? "true" : undefined, children: [_jsxs("div", { className: "nx-files__bar", children: [_jsx("p", { className: "nx-files__note", "aria-live": "polite", children: busy ? `${busy}…` : limitsLine(owned) }), canWrite && (_jsx(Button, { variant: "primary", size: "sm", icon: _jsx(InlineIcon, { icon: iconUpload }), "aria-disabled": busy || full ? true : undefined, title: full ?? undefined, onClick: () => (busy ? undefined : void actions.upload()), onPointerEnter: preloadImageEditor, children: "Upload" }))] }), readOnlyReason && _jsx("p", { className: "nx-files__note", children: readOnlyReason }), editorProblem && !busy && (_jsx("p", { className: "nx-files__note nx-files__note--error", role: "alert", children: editorProblem })), owned.files.length === 0 ? (_jsxs("div", { className: "nx-files__empty", children: [_jsx(InlineIcon, { icon: iconFileText }), _jsxs("p", { children: ["No ", noun, "s yet."] }), canWrite && _jsxs("p", { className: "nx-files__note", children: ["Upload ", owned.category.maxCount > 1 ? `up to ${owned.category.maxCount}` : "one", "."] })] })) : (_jsx("ul", { className: "nx-files__list", children: owned.files.map(file => (_jsxs("li", { className: "nx-files__row", children: [isPictureType(file.contentType) ? (_jsx("button", { type: "button", className: "nx-picture", style: { "--nx-picture-h": "40px", "--nx-picture-ratio": 1 }, "aria-label": `View ${file.name}`, onClick: () => void actions.view(file), children: _jsx("img", { src: getFileAdapter().fileUrl(file, "thumbnail"), alt: "", loading: "lazy", decoding: "async" }) })) : (_jsx("span", { className: "nx-picture nx-picture--empty", style: { "--nx-picture-h": "40px", "--nx-picture-ratio": 1 }, "aria-hidden": "true", children: _jsx(InlineIcon, { icon: iconFile }) })), _jsxs("div", { className: "nx-files__row-text", children: [_jsx("a", { className: "nx-files__name", href: getFileAdapter().fileUrl(file, "original"), target: "_blank", rel: "noopener", children: file.name }), _jsxs("span", { className: "nx-files__note", children: [fileKindName(file.contentType), " \u00B7 ", fileSizeText(file.size), file.createdAt && ` · ${formatDateTime(file.createdAt, preferences)}`] })] }), canWrite && (_jsxs("div", { className: "nx-files__actions", children: [_jsx(Button, { size: "sm", variant: "ghost", icon: _jsx(InlineIcon, { icon: iconPencil }), "aria-label": `Rename ${file.name}`, title: "Rename", ...busyProps(busy, () => void actions.rename(file)) }), _jsx(Button, { size: "sm", variant: "ghost", hoverTone: "danger", icon: _jsx(InlineIcon, { icon: iconTrash }), "aria-label": `Delete ${file.name}`, title: "Delete", ...busyProps(busy, () => void actions.remove(file)) })] }))] }, file.id))) }))] }));
607
+ const offered = offer();
608
+ return (_jsxs("div", { ref: rootRef, className: "nx-files", "data-state": "ready", "aria-busy": busy ? "true" : undefined, children: [_jsxs("div", { className: "nx-files__bar", children: [_jsx("p", { className: "nx-files__note", "aria-live": "polite", children: busy ? `${busy}…` : limitsLine(owned) }), offered.upload && (_jsx(Button, { variant: "primary", size: "sm", icon: _jsx(InlineIcon, { icon: iconUpload }), "aria-disabled": busy || full ? true : undefined, title: full ?? undefined, onClick: () => (busy ? undefined : void actions.upload()), onPointerEnter: preloadImageEditor, children: "Upload" }))] }), readOnlyReason && _jsx("p", { className: "nx-files__note", children: readOnlyReason }), changesNote && _jsx("p", { className: "nx-files__note", children: changesNote }), editorProblem && !busy && (_jsx("p", { className: "nx-files__note nx-files__note--error", role: "alert", children: editorProblem })), owned.files.length === 0 ? (_jsxs("div", { className: "nx-files__empty", children: [_jsx(InlineIcon, { icon: iconFileText }), _jsxs("p", { children: ["No ", noun, "s yet."] }), offered.upload && _jsxs("p", { className: "nx-files__note", children: ["Upload ", owned.category.maxCount > 1 ? `up to ${owned.category.maxCount}` : "one", "."] })] })) : (_jsx("ul", { className: "nx-files__list", children: owned.files.map(file => {
609
+ const mine = offer(file);
610
+ return (_jsxs("li", { className: "nx-files__row", children: [isPictureType(file.contentType) ? (_jsx("button", { type: "button", className: "nx-picture", style: { "--nx-picture-h": "40px", "--nx-picture-ratio": 1 }, "aria-label": `View ${file.name}`, onClick: () => void actions.view(file), children: _jsx("img", { src: getFileAdapter().fileUrl(file, "thumbnail"), alt: "", loading: "lazy", decoding: "async" }) })) : (_jsx("span", { className: "nx-picture nx-picture--empty", style: { "--nx-picture-h": "40px", "--nx-picture-ratio": 1 }, "aria-hidden": "true", children: _jsx(InlineIcon, { icon: iconFile }) })), _jsxs("div", { className: "nx-files__row-text", children: [_jsx("a", { className: "nx-files__name", href: getFileAdapter().fileUrl(file, "original"), target: "_blank", rel: "noopener", children: file.name }), _jsxs("span", { className: "nx-files__note", children: [fileKindName(file.contentType), " \u00B7 ", fileSizeText(file.size), file.createdAt && ` · ${formatDateTime(file.createdAt, preferences)}`] }), file.pendingRemoval && _jsx(PendingRemoval, { file: file, when: preferences, withTime: true })] }), (mine.rename || mine.remove || mine.keep) && (_jsxs("div", { className: "nx-files__actions", children: [mine.rename && _jsx(Button, { size: "sm", variant: "ghost", icon: _jsx(InlineIcon, { icon: iconPencil }), "aria-label": `Rename ${file.name}`, title: "Rename", ...busyProps(busy, () => void actions.rename(file)) }), mine.remove && _jsx(Button, { size: "sm", variant: "ghost", hoverTone: "danger", icon: _jsx(InlineIcon, { icon: iconTrash }), "aria-label": `Delete ${file.name}`, title: "Delete", ...busyProps(busy, () => void actions.remove(file)) }), mine.keep && _jsx(KeepButton, { file: file, busy: busy, keep: actions.keep })] }))] }, file.id));
611
+ }) }))] }));
513
612
  }
514
613
  const owned = loading ? null : data;
515
614
  const kept = owned?.files ?? [];
@@ -520,7 +619,16 @@ export function PictureGallery(props) {
520
619
  const coverUrl = primary ? getFileAdapter().fileUrl(primary, "original") : null;
521
620
  const coverShown = coverUrl && coverFailed !== coverUrl;
522
621
  const editHeld = busy || noRoom ? true : undefined;
523
- return (_jsx("div", { ref: rootRef, className: "nx-files nx-files--panes", "data-state": loading ? "loading" : "ready", "aria-busy": loading || busy ? "true" : undefined, children: _jsxs("div", { className: "nx-files__frame", children: [_jsxs("div", { className: "nx-files__side", children: [_jsx("span", { className: cx("nx-picture", "nx-files__cover", !coverShown && "nx-picture--empty", loading && "nx-picture--loading"), style: shape, role: "img", "aria-label": primary ? `The main picture: ${primary.name}` : loading ? "The main picture" : "No main picture", children: coverShown ? _jsx("img", { src: coverUrl, alt: "", decoding: "async", onError: () => setCoverFailed(coverUrl) }) : _jsx(InlineIcon, { icon: iconPhoto }) }), _jsxs("div", { className: "nx-files__about", children: [canWrite && owned && (_jsxs("div", { className: "nx-files__side-actions", children: [_jsx(Button, { variant: "primary", icon: _jsx(InlineIcon, { icon: iconUpload }), "aria-disabled": busy || noRoom ? true : undefined, title: noRoom ?? undefined, onClick: () => (busy ? undefined : void (single && kept.length > 0 ? actions.replace() : actions.upload())), onPointerEnter: preloadImageEditor, onFocus: preloadImageEditor, children: single && kept.length > 0 ? "Replace" : "Upload" }), primary && (_jsx(Button, { hoverTone: "danger", icon: _jsx(InlineIcon, { icon: single ? iconTrash : iconX }), title: single ? undefined : "Show no picture: the pictures are kept", ...busyProps(busy, () => void (single ? actions.remove(primary) : actions.makePrimary(null))), children: single ? "Delete" : "Remove" }))] })), _jsx("p", { className: "nx-files__note", "aria-live": "polite", children: loading ? `Loading the ${noun}s…` : busy ? `${busy}…` : limitsLine(owned) }), readOnlyReason && _jsx("p", { className: "nx-files__note", children: readOnlyReason }), editorProblem && !busy && (_jsx("p", { className: "nx-files__note nx-files__note--error", role: "alert", children: editorProblem }))] })] }), _jsx("div", { className: "nx-files__pane", children: loading ? (_jsx("ul", { className: "nx-files__grid", style: shape, "aria-hidden": "true", children: HELD_TILES.map(index => (_jsx("li", { children: _jsx("span", { className: "nx-table__bone nx-files__bone" }) }, index))) })) : kept.length === 0 ? (_jsxs("div", { className: "nx-files__empty", children: [_jsx(InlineIcon, { icon: iconPhoto }), _jsxs("p", { children: ["No ", noun, "s yet."] }), canWrite && _jsxs("p", { className: "nx-files__note", children: ["Upload ", owned.category.maxCount > 1 ? `up to ${owned.category.maxCount}` : "one", "."] })] })) : (_jsx("ul", { className: "nx-files__grid", style: shape, "aria-label": props.title ? `${props.title}: pictures` : "Pictures", children: kept.map(file => (_jsxs("li", { className: "nx-files__tile", "data-primary": file.isPrimary || undefined, children: [_jsx("span", { className: "nx-files__thumb", children: _jsx("img", { src: getFileAdapter().fileUrl(file, "thumbnail"), alt: "", loading: "lazy", decoding: "async" }) }), file.isPrimary && (_jsx("span", { className: "nx-table__tag nx-files__mark", "data-tone": "primary", children: "Main" })), _jsxs("div", { className: "nx-files__tools", role: "group", "aria-label": file.isPrimary ? `${file.name}, the main picture` : file.name, children: [_jsx(Button, { size: "sm", variant: "ghost", icon: _jsx(InlineIcon, { icon: iconEye }), "aria-label": `View ${file.name}`, title: "View", onClick: () => void actions.view(file), onPointerEnter: preloadImageEditor }), canWrite && !file.isPrimary && _jsx(Button, { size: "sm", variant: "ghost", icon: _jsx(InlineIcon, { icon: iconStar }), "aria-label": `Make ${file.name} the main picture`, title: "Make it the main picture", ...busyProps(busy, () => void actions.makePrimary(file)) }), canWrite && (_jsx(Button, { size: "sm", variant: "ghost", icon: _jsx(InlineIcon, { icon: iconPencil }), "aria-label": `Edit ${file.name}`, title: noRoom ? `Edit. ${noRoom}` : "Edit: saved as a new picture", "aria-disabled": editHeld, onClick: () => (busy ? undefined : void actions.edit(file)), onPointerEnter: preloadImageEditor, onFocus: preloadImageEditor })), canWrite && !file.isPrimary && _jsx(Button, { size: "sm", variant: "ghost", hoverTone: "danger", icon: _jsx(InlineIcon, { icon: iconTrash }), "aria-label": `Delete ${file.name}`, title: "Delete", ...busyProps(busy, () => void actions.remove(file)) })] })] }, file.id))) })) })] }) }));
622
+ const offered = owned ? offer() : null;
623
+ // Beside Upload: Delete in a one-file category (Keep while its removal waits), else Remove (no main picture, the pictures kept).
624
+ const mainOffer = primary ? offer(primary) : null;
625
+ const sideKeep = Boolean(single && mainOffer?.keep);
626
+ const sideRemove = Boolean(primary && offered && (single ? mainOffer?.remove : offered.clearPrimary));
627
+ return (_jsx("div", { ref: rootRef, className: "nx-files nx-files--panes", "data-state": loading ? "loading" : "ready", "aria-busy": loading || busy ? "true" : undefined, children: _jsxs("div", { className: "nx-files__frame", children: [_jsxs("div", { className: "nx-files__side", children: [_jsx("span", { className: cx("nx-picture", "nx-files__cover", !coverShown && "nx-picture--empty", loading && "nx-picture--loading"), style: shape, role: "img", "aria-label": primary ? `The main picture: ${primary.name}` : loading ? "The main picture" : "No main picture", children: coverShown ? _jsx("img", { src: coverUrl, alt: "", decoding: "async", onError: () => setCoverFailed(coverUrl) }) : _jsx(InlineIcon, { icon: iconPhoto }) }), _jsxs("div", { className: "nx-files__about", children: [offered && (offered.upload || sideRemove || sideKeep) && (_jsxs("div", { className: "nx-files__side-actions", children: [offered.upload && (_jsx(Button, { variant: "primary", icon: _jsx(InlineIcon, { icon: iconUpload }), "aria-disabled": busy || noRoom ? true : undefined, title: noRoom ?? undefined, onClick: () => (busy ? undefined : void (single && kept.length > 0 ? actions.replace() : actions.upload())), onPointerEnter: preloadImageEditor, onFocus: preloadImageEditor, children: single && kept.length > 0 ? "Replace" : "Upload" })), sideRemove && primary && (_jsx(Button, { hoverTone: "danger", icon: _jsx(InlineIcon, { icon: single ? iconTrash : iconX }), title: single ? undefined : "Show no picture: the pictures are kept", ...busyProps(busy, () => void (single ? actions.remove(primary) : actions.makePrimary(null))), children: single ? "Delete" : "Remove" })), sideKeep && primary && _jsx(KeepButton, { file: primary, busy: busy, keep: actions.keep, size: "md" })] })), primary?.pendingRemoval && _jsx(PendingRemoval, { file: primary, when: preferences, withTime: true }), _jsx("p", { className: "nx-files__note", "aria-live": "polite", children: loading ? `Loading the ${noun}s…` : busy ? `${busy}…` : limitsLine(owned) }), readOnlyReason && _jsx("p", { className: "nx-files__note", children: readOnlyReason }), changesNote && _jsx("p", { className: "nx-files__note", children: changesNote }), editorProblem && !busy && (_jsx("p", { className: "nx-files__note nx-files__note--error", role: "alert", children: editorProblem }))] })] }), _jsx("div", { className: "nx-files__pane", children: loading ? (_jsx("ul", { className: "nx-files__grid", style: shape, "aria-hidden": "true", children: HELD_TILES.map(index => (_jsx("li", { children: _jsx("span", { className: "nx-table__bone nx-files__bone" }) }, index))) })) : kept.length === 0 ? (_jsxs("div", { className: "nx-files__empty", children: [_jsx(InlineIcon, { icon: iconPhoto }), _jsxs("p", { children: ["No ", noun, "s yet."] }), offered?.upload && _jsxs("p", { className: "nx-files__note", children: ["Upload ", owned.category.maxCount > 1 ? `up to ${owned.category.maxCount}` : "one", "."] })] })) : (_jsx("ul", { className: "nx-files__grid", style: shape, "aria-label": props.title ? `${props.title}: pictures` : "Pictures", children: kept.map(file => {
628
+ const mine = offer(file);
629
+ const about = [file.name, file.isPrimary && "the main picture", file.pendingRemoval && PENDING_REMOVAL_TAG.toLowerCase()].filter(Boolean).join(", ");
630
+ return (_jsxs("li", { className: "nx-files__tile", "data-primary": file.isPrimary || undefined, "data-pending": file.pendingRemoval || undefined, children: [_jsxs("span", { className: "nx-files__thumb", children: [_jsx("img", { src: getFileAdapter().fileUrl(file, "thumbnail"), alt: "", loading: "lazy", decoding: "async" }), file.pendingRemoval && _jsx(PendingRemoval, { file: file, when: preferences, className: "nx-files__pending" })] }), file.isPrimary && (_jsx("span", { className: "nx-table__tag nx-files__mark", "data-tone": "primary", children: "Main" })), _jsxs("div", { className: "nx-files__tools", role: "group", "aria-label": about, children: [_jsx(Button, { size: "sm", variant: "ghost", icon: _jsx(InlineIcon, { icon: iconEye }), "aria-label": `View ${file.name}`, title: "View", onClick: () => void actions.view(file), onPointerEnter: preloadImageEditor }), mine.makePrimary && _jsx(Button, { size: "sm", variant: "ghost", icon: _jsx(InlineIcon, { icon: iconStar }), "aria-label": `Make ${file.name} the main picture`, title: "Make it the main picture", ...busyProps(busy, () => void actions.makePrimary(file)) }), mine.edit && (_jsx(Button, { size: "sm", variant: "ghost", icon: _jsx(InlineIcon, { icon: iconPencil }), "aria-label": `Edit ${file.name}`, title: noRoom ? `Edit. ${noRoom}` : "Edit: saved as a new picture", "aria-disabled": editHeld, onClick: () => (busy ? undefined : void actions.edit(file)), onPointerEnter: preloadImageEditor, onFocus: preloadImageEditor })), mine.remove && !file.isPrimary && _jsx(Button, { size: "sm", variant: "ghost", hoverTone: "danger", icon: _jsx(InlineIcon, { icon: iconTrash }), "aria-label": `Delete ${file.name}`, title: "Delete", ...busyProps(busy, () => void actions.remove(file)) }), mine.keep && _jsx(KeepButton, { file: file, busy: busy, keep: actions.keep })] })] }, file.id));
631
+ }) })) })] }) }));
524
632
  }
525
633
  /** An owner's documents as a list: `PictureGallery` with `view="documents"`. */
526
634
  export function DocumentList(props) {
@@ -558,11 +666,13 @@ export function openPictureGallery({ popupTitle, ...props }) {
558
666
  /**
559
667
  * A column with each row's picture: every visible row's read in one call per page, and a click opens the row's
560
668
  * gallery. A change there redraws the row's picture without a reload.
669
+ * It is a `lead` column: every table draws it first of the page's columns (after the row number), pinned on the left
670
+ * when the page pins others there, wherever it stands in `columns`, until the user moves, unpins or hides it.
561
671
  *
562
672
  * @example
563
673
  * columns: [pictureColumn({ module: NexusModule.Enterprise, category: "branch-picture", rowTitle: row => row.branchName }), ...]
564
674
  */
565
- export function pictureColumn({ module, category, ownerKey = "id", rowTitle, nameKey, lockReason, onChange, aspect, shape, maxWidth, maxHeight, type, quality, readOnly, view, ...column }) {
675
+ export function pictureColumn({ module, category, ownerKey = "id", rowTitle, nameKey, lockReason, onChange, aspect, shape, maxWidth, maxHeight, type, quality, readOnly, changes, changesReason, holdsRemovals, removalReviewer, view, ...column }) {
566
676
  return {
567
677
  key: "picture",
568
678
  label: "Picture",
@@ -571,6 +681,7 @@ export function pictureColumn({ module, category, ownerKey = "id", rowTitle, nam
571
681
  sortable: false,
572
682
  searchable: false,
573
683
  resizable: false,
684
+ lead: true,
574
685
  ...column,
575
686
  value: () => null,
576
687
  render: row => {
@@ -582,7 +693,7 @@ export function pictureColumn({ module, category, ownerKey = "id", rowTitle, nam
582
693
  const owner = { module, category, ownerId };
583
694
  const named = nameKey ? readPath(row, nameKey) : null;
584
695
  const locked = typeof lockReason === "function" ? lockReason(row) : lockReason;
585
- return (_jsx(OwnerPicture, { owner: owner, aspect: aspect, shape: shape, name: typeof named === "string" ? named : null, label: title ? `${title}: pictures` : "Pictures", onClick: () => openPictureGallery({ ...owner, title, aspect, shape, maxWidth, maxHeight, type, quality, readOnly, lockReason: locked ?? null, view, onChange: primary => onChange?.(row, primary) }) }));
696
+ return (_jsx(OwnerPicture, { owner: owner, aspect: aspect, shape: shape, name: typeof named === "string" ? named : null, label: title ? `${title}: pictures` : "Pictures", onClick: () => openPictureGallery({ ...owner, title, aspect, shape, maxWidth, maxHeight, type, quality, readOnly, changes, changesReason, holdsRemovals, removalReviewer, lockReason: locked ?? null, view, onChange: primary => onChange?.(row, primary) }) }));
586
697
  },
587
698
  text: () => "",
588
699
  };
@@ -117,6 +117,9 @@ export const NEXUS_MODULES = [
117
117
  { module: NexusModule.Message, key: "message", label: "Message", env: "API_MESSAGE" },
118
118
  { module: NexusModule.Chat, key: "chat", label: "Chat", env: "API_CHAT" },
119
119
  { module: NexusModule.Accounting, key: "accounting", label: "Accounting", env: "API_ACCOUNTING" },
120
+ // Retired on 2026-11-11 (`NexusModule.Report` is deprecated): kept so the list's published entries do not change in a
121
+ // minor and an old proxy URL (/api/app/report/...) is still known. An app sets no API_REPORT, and a list a person
122
+ // picks a module from leaves it out (`info.module !== NexusModule.Report`).
120
123
  { module: NexusModule.Report, key: "report", label: "Report", env: "API_REPORT" },
121
124
  { module: NexusModule.Requisition, key: "requisition", label: "Requisition", env: "API_REQUISITION" },
122
125
  { module: NexusModule.Tenant, key: "tenant", label: "Tenant", env: "API_TENANT" },
@@ -126,6 +129,7 @@ export const NEXUS_MODULES = [
126
129
  { module: NexusModule.Pharmacy, key: "pharmacy", label: "Pharmacy", env: "API_PHARMACY" },
127
130
  { module: NexusModule.Staffing, key: "staffing", label: "Staffing", env: "API_STAFFING" },
128
131
  { module: NexusModule.Portfolio, key: "portfolio", label: "Portfolio", env: "API_PORTFOLIO" },
132
+ { module: NexusModule.Inventory, key: "inventory", label: "Inventory", env: "API_INVENTORY" },
129
133
  // The app's own API routes (/api/...), never through the proxy.
130
134
  { module: NexusModule.None, key: "app", label: "App" },
131
135
  ];
@@ -0,0 +1,50 @@
1
+ /** An entry as far as its person goes: their token, and their name when the history's answer carries it. */
2
+ export interface AuditActor {
3
+ creatorUserId?: string | null;
4
+ creatorName?: string | null;
5
+ }
6
+ /**
7
+ * A token, short. It names nobody and only tells two apart, so its first characters are enough. A token is the same
8
+ * text for one id on every entry this reader is given (the id is encrypted for the reader, not at random).
9
+ */
10
+ export declare const shortToken: (token: string) => string;
11
+ /**
12
+ * Names the people behind a history's tokens: given the tokens of entries the answer names nobody for, the app answers
13
+ * `{ [token]: "Sita Rai (sita.rai)" }` - the whole text the line shows after "by". A token it leaves out keeps its short
14
+ * form.
15
+ */
16
+ export type AuditActorNamesResolver = (tokens: string[], signal: AbortSignal) => Promise<Record<string, string>>;
17
+ /**
18
+ * Sets, once for the app, how a record's history (`AuditHistory`) names the person behind an entry its answer names
19
+ * nobody for (no `creatorName`). The history collects the tokens of every page it shows (the first and each "Show
20
+ * more"), asks once per page for those it has had no answer for, and says "by Sita Rai (sita.rai)" for each one
21
+ * answered. A token is the same text for one person on every entry this reader is given, so the app can look it up (its
22
+ * own list of people, or a call to the server that made it). A call that fails keeps the line's "User 3fA9b2…" and is
23
+ * asked again by the next history; a token left out of the answer keeps it for the page's life. Answers are kept for the
24
+ * page's life; `null` stops asking and forgets them.
25
+ *
26
+ * @example
27
+ * setAuditActorNames(async (tokens, signal) => {
28
+ * const answer = await api.post<Record<string, string>>(USERS.names, tokens, { signal, toast: false });
29
+ * return answer.ok ? answer.result : {};
30
+ * });
31
+ */
32
+ export declare function setAuditActorNames(resolver: AuditActorNamesResolver | null): void;
33
+ /** The name the app gave for a token, once it has given one. */
34
+ export declare const knownAuditActorName: (token: string | null | undefined) => string | undefined;
35
+ /**
36
+ * The tokens of these entries the app is still to be asked for: entries with no name in the answer, whose token has had
37
+ * no answer yet, each token once. Empty without a resolver.
38
+ */
39
+ export declare function unnamedAuditActors(entries: readonly AuditActor[]): string[];
40
+ /**
41
+ * Asks the app, in one call, for the names of these entries' unnamed people (`unnamedAuditActors`) and keeps the answer.
42
+ * Resolves `true` when it learnt a name. A failed or aborted call keeps nothing, so a later history asks again.
43
+ */
44
+ export declare function resolveAuditActors(entries: readonly AuditActor[], signal: AbortSignal): Promise<boolean>;
45
+ /**
46
+ * Who made a change, as the entry's line says it: "by Sita Rai" when the history's answer names them (`creatorName`) or
47
+ * the app has named their token (`setAuditActorNames`), else the start of their token ("User 3fA9b2…"), which still
48
+ * tells two people apart; `null` when it says neither.
49
+ */
50
+ export declare function auditActorText(entry: AuditActor): string | null;
@@ -0,0 +1,96 @@
1
+ /*
2
+ * The words of a record's history entry (Components/Forms/CrudHistory.tsx) that are worked out rather than drawn, so Node
3
+ * tests run them, and the app's way of naming the people behind the entries' tokens. Only `setAuditActorNames` and its
4
+ * type are exported from the package.
5
+ */
6
+ /**
7
+ * A token, short. It names nobody and only tells two apart, so its first characters are enough. A token is the same
8
+ * text for one id on every entry this reader is given (the id is encrypted for the reader, not at random).
9
+ */
10
+ export const shortToken = (token) => (token.length > 8 ? `${token.slice(0, 6)}…` : token);
11
+ let actorResolver = null;
12
+ /** What the app answered, by token, for the page's life: a second history asks only for the people it has not seen. */
13
+ const actorNames = new Map();
14
+ /** Tokens the app answered nothing for: not asked again, so a history full of one stranger never asks on every page. */
15
+ const actorUnknown = new Set();
16
+ /**
17
+ * Sets, once for the app, how a record's history (`AuditHistory`) names the person behind an entry its answer names
18
+ * nobody for (no `creatorName`). The history collects the tokens of every page it shows (the first and each "Show
19
+ * more"), asks once per page for those it has had no answer for, and says "by Sita Rai (sita.rai)" for each one
20
+ * answered. A token is the same text for one person on every entry this reader is given, so the app can look it up (its
21
+ * own list of people, or a call to the server that made it). A call that fails keeps the line's "User 3fA9b2…" and is
22
+ * asked again by the next history; a token left out of the answer keeps it for the page's life. Answers are kept for the
23
+ * page's life; `null` stops asking and forgets them.
24
+ *
25
+ * @example
26
+ * setAuditActorNames(async (tokens, signal) => {
27
+ * const answer = await api.post<Record<string, string>>(USERS.names, tokens, { signal, toast: false });
28
+ * return answer.ok ? answer.result : {};
29
+ * });
30
+ */
31
+ export function setAuditActorNames(resolver) {
32
+ actorResolver = resolver;
33
+ actorNames.clear();
34
+ actorUnknown.clear();
35
+ }
36
+ /** The name the app gave for a token, once it has given one. */
37
+ export const knownAuditActorName = (token) => (token ? actorNames.get(token) : undefined);
38
+ /**
39
+ * The tokens of these entries the app is still to be asked for: entries with no name in the answer, whose token has had
40
+ * no answer yet, each token once. Empty without a resolver.
41
+ */
42
+ export function unnamedAuditActors(entries) {
43
+ if (!actorResolver)
44
+ return [];
45
+ const tokens = new Set();
46
+ for (const entry of entries) {
47
+ const token = entry.creatorUserId;
48
+ if (!token || entry.creatorName?.trim() || actorNames.has(token) || actorUnknown.has(token))
49
+ continue;
50
+ tokens.add(token);
51
+ }
52
+ return [...tokens];
53
+ }
54
+ /**
55
+ * Asks the app, in one call, for the names of these entries' unnamed people (`unnamedAuditActors`) and keeps the answer.
56
+ * Resolves `true` when it learnt a name. A failed or aborted call keeps nothing, so a later history asks again.
57
+ */
58
+ export async function resolveAuditActors(entries, signal) {
59
+ const resolver = actorResolver;
60
+ const tokens = unnamedAuditActors(entries);
61
+ if (!resolver || tokens.length === 0)
62
+ return false;
63
+ let answer;
64
+ try {
65
+ answer = await resolver(tokens, signal);
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ // The history went away, or the app set another resolver meanwhile: this answer is not kept.
71
+ if (signal.aborted || resolver !== actorResolver)
72
+ return false;
73
+ let learnt = false;
74
+ for (const token of tokens) {
75
+ const given = answer?.[token];
76
+ const name = typeof given === "string" ? given.trim() : "";
77
+ if (name) {
78
+ actorNames.set(token, name);
79
+ learnt = true;
80
+ }
81
+ else
82
+ actorUnknown.add(token);
83
+ }
84
+ return learnt;
85
+ }
86
+ /**
87
+ * Who made a change, as the entry's line says it: "by Sita Rai" when the history's answer names them (`creatorName`) or
88
+ * the app has named their token (`setAuditActorNames`), else the start of their token ("User 3fA9b2…"), which still
89
+ * tells two people apart; `null` when it says neither.
90
+ */
91
+ export function auditActorText(entry) {
92
+ const name = entry.creatorName?.trim() || knownAuditActorName(entry.creatorUserId);
93
+ if (name)
94
+ return `by ${name}`;
95
+ return entry.creatorUserId ? `User ${shortToken(entry.creatorUserId)}` : null;
96
+ }
@@ -1,5 +1,5 @@
1
1
  import { type ApiEndpoint, type ApiModuleValue } from "../Interfaces/ApiInterfaces.ts";
2
- import type { AppChatDomain, ChatConversation, ChatDomainInfo, ChatDomainValue, ChatDraft, ChatHistory, ChatHistoryRequest, ChatId, ChatMessage, ChatParticipant, ChatParticipantPage, ChatParticipantQuery, ChatScope, ChatSummary, NewChatRequest } from "../Interfaces/ChatInterfaces.ts";
2
+ import type { AppChatDomain, ChatConversation, ChatDomainInfo, ChatDomainValue, ChatDraft, ChatHistory, ChatHistoryRequest, ChatId, ChatMessage, ChatMessageKind, ChatParticipant, ChatParticipantPage, ChatParticipantQuery, ChatScope, ChatSummary, NewChatRequest } from "../Interfaces/ChatInterfaces.ts";
3
3
  /** The actions a chat needs from a backend. A backend that does not serve one takes that feature off the panel. */
4
4
  export declare const CHAT_ACTIONS: {
5
5
  /** The conversations a record holds, for this reader. */
@@ -106,6 +106,11 @@ export declare class NexusChatBackend<TDomain extends ChatDomainValue = AppChatD
106
106
  protected toConversation(row: Row): ChatConversation;
107
107
  /** One message row. The names are the ones a Nexus contract uses; override this alone for a backend that differs. */
108
108
  protected toMessage(row: Row): ChatMessage;
109
+ /**
110
+ * What a message is, from the backend's `EChatMessageTypes` (0 a chat line, 1 the mail a thread opens with, 2 a
111
+ * notification) or a word. Anything else is a chat line.
112
+ */
113
+ protected toKind(value: unknown): ChatMessageKind;
109
114
  /** One set of counts. */
110
115
  protected toSummary(row: Row): ChatSummary;
111
116
  /** One participant row. */
@@ -125,6 +130,8 @@ export declare function getChatBackend(): ChatBackend<any>;
125
130
  * { domain: EDomainTypes.Leave, key: "leave", label: "Leave request" },
126
131
  * { domain: EDomainTypes.Procurement, key: "procurement", label: "Purchase order" },
127
132
  * ]);
133
+ *
134
+ * A row's `card` shows a message's record as a card read live from its module (`domainCard`, `DomainCard`).
128
135
  */
129
136
  export declare function defineChatDomains<TDomain extends ChatDomainValue>(list: readonly ChatDomainInfo<TDomain>[]): readonly ChatDomainInfo<TDomain>[];
130
137
  /** The registered domains. Empty until an app calls `defineChatDomains`. */
@@ -254,10 +254,23 @@ export class NexusChatBackend extends ChatBackend {
254
254
  })),
255
255
  }),
256
256
  ...(row.isSystem === true && { isSystem: true }),
257
+ ...((row.messageType ?? row.kind) !== undefined && { kind: this.toKind(row.messageType ?? row.kind) }),
257
258
  ...(Array.isArray(row.deliveredTo) && { deliveredTo: row.deliveredTo.map(id).filter((value) => value !== null) }),
258
259
  ...(Array.isArray(row.readBy) && { readBy: row.readBy.map(id).filter((value) => value !== null) }),
259
260
  };
260
261
  }
262
+ /**
263
+ * What a message is, from the backend's `EChatMessageTypes` (0 a chat line, 1 the mail a thread opens with, 2 a
264
+ * notification) or a word. Anything else is a chat line.
265
+ */
266
+ toKind(value) {
267
+ const written = String(value).toLowerCase();
268
+ if (written === "1" || written === "mail")
269
+ return "mail";
270
+ if (written === "2" || written === "notification")
271
+ return "notification";
272
+ return "chat";
273
+ }
261
274
  /** One set of counts. */
262
275
  toSummary(row) {
263
276
  const lastText = text(row.lastMessageText ?? row.lastMessage ?? row.lastBody);
@@ -309,6 +322,8 @@ let domains = [];
309
322
  * { domain: EDomainTypes.Leave, key: "leave", label: "Leave request" },
310
323
  * { domain: EDomainTypes.Procurement, key: "procurement", label: "Purchase order" },
311
324
  * ]);
325
+ *
326
+ * A row's `card` shows a message's record as a card read live from its module (`domainCard`, `DomainCard`).
312
327
  */
313
328
  export function defineChatDomains(list) {
314
329
  const seen = new Set();
@@ -317,6 +332,9 @@ export function defineChatDomains(list) {
317
332
  if (seen.has(key))
318
333
  throw new Error(`Two chat domains have the value ${key}.`);
319
334
  seen.add(key);
335
+ if (info.card && (typeof info.card.load !== "function" || typeof info.card.view !== "function")) {
336
+ throw new Error(`The chat domain ${key}'s card needs a load and a view.`);
337
+ }
320
338
  }
321
339
  domains = list;
322
340
  return list;