domma-cms 0.54.2 → 0.66.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 (221) hide show
  1. package/CLAUDE.md +151 -77
  2. package/README.md +4 -4
  3. package/admin/css/admin.css +1 -1
  4. package/admin/dist/domma/domma-tools.css +3 -3
  5. package/admin/dist/domma/domma-tools.min.js +3 -3
  6. package/admin/js/api.js +1 -1
  7. package/admin/js/app.js +3 -3
  8. package/admin/js/lib/page-picker.js +1 -0
  9. package/admin/js/lib/plugin-accent.js +1 -0
  10. package/admin/js/lib/plugin-chrome.js +1 -0
  11. package/admin/js/lib/shortcode-context-menu.js +2 -2
  12. package/admin/js/lib/sidebar-grouping.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  14. package/admin/js/lib/sidebar-renderer.js +4 -4
  15. package/admin/js/lib/slideover-resizable.js +1 -0
  16. package/admin/js/templates/context-menu-editor.html +212 -0
  17. package/admin/js/templates/context-menus.html +16 -0
  18. package/admin/js/templates/menu-editor.html +19 -25
  19. package/admin/js/templates/plugin-code.html +1 -1
  20. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  21. package/admin/js/templates/settings.html +27 -97
  22. package/admin/js/templates/theme.html +173 -0
  23. package/admin/js/views/context-menu-editor.js +55 -0
  24. package/admin/js/views/context-menus.js +5 -0
  25. package/admin/js/views/form-editor.js +7 -7
  26. package/admin/js/views/index.js +1 -1
  27. package/admin/js/views/menu-editor.js +13 -13
  28. package/admin/js/views/plugin-marketplace.js +1 -1
  29. package/admin/js/views/plugins.js +25 -23
  30. package/admin/js/views/search.js +1 -0
  31. package/admin/js/views/settings.js +3 -3
  32. package/admin/js/views/theme.js +1 -0
  33. package/bin/cli.js +11 -2
  34. package/bin/lib/smtp-defaults.js +53 -0
  35. package/bin/update.js +13 -2
  36. package/config/menus/admin-sidebar.json +129 -23
  37. package/config/plugins.json +5 -5
  38. package/config/search.json +13 -0
  39. package/config/theme.json +18 -0
  40. package/package.json +12 -4
  41. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  42. package/plugins/_lib/admin/mail/contacts.js +301 -0
  43. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  44. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  45. package/plugins/_lib/admin/mail/identity.js +480 -0
  46. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  47. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  48. package/plugins/_lib/admin/mail/mail.css +1 -0
  49. package/plugins/_lib/admin/mail/mail.html +71 -0
  50. package/plugins/_lib/admin/mail/panes.js +253 -0
  51. package/plugins/_lib/admin/mail/reader-view.js +4453 -0
  52. package/plugins/_lib/admin/mail/resizable.js +26 -0
  53. package/plugins/_lib/admin/mail/rules.js +343 -0
  54. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  55. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  56. package/plugins/_lib/admin/mail/templates.js +238 -0
  57. package/plugins/_lib/admin/mail/threads.js +200 -0
  58. package/plugins/_lib/admin/mail/vacation.js +269 -0
  59. package/plugins/_lib/admin/ui/help.css +1 -0
  60. package/plugins/_lib/admin/ui/help.js +174 -0
  61. package/plugins/_lib/admin/ui/resizable.js +151 -0
  62. package/plugins/_lib/dataStore.js +117 -0
  63. package/plugins/_lib/mail/accounts.js +919 -0
  64. package/plugins/_lib/mail/bodyTokens.js +101 -0
  65. package/plugins/_lib/mail/compose.js +256 -0
  66. package/plugins/_lib/mail/defaults.js +57 -0
  67. package/plugins/_lib/mail/diagnostics.js +154 -0
  68. package/plugins/_lib/mail/envelope.js +161 -0
  69. package/plugins/_lib/mail/folders.js +192 -0
  70. package/plugins/_lib/mail/handoff.js +274 -0
  71. package/plugins/_lib/mail/imapPool.js +293 -0
  72. package/plugins/_lib/mail/mbox.js +74 -0
  73. package/plugins/_lib/mail/pollSchedule.js +79 -0
  74. package/plugins/_lib/mail/poller.js +135 -0
  75. package/plugins/_lib/mail/priority.js +138 -0
  76. package/plugins/_lib/mail/readRoutes.js +680 -0
  77. package/plugins/_lib/mail/render.js +291 -0
  78. package/plugins/_lib/mail/ruleRunner.js +152 -0
  79. package/plugins/_lib/mail/scheduler.js +254 -0
  80. package/plugins/_lib/mail/secretbox.js +229 -0
  81. package/plugins/_lib/mail/send.js +396 -0
  82. package/plugins/_lib/mail/store.js +1002 -0
  83. package/plugins/_lib/mail/sync.js +292 -0
  84. package/plugins/_lib/mail/syncPlan.js +126 -0
  85. package/plugins/_lib/mail/syncSelection.js +82 -0
  86. package/plugins/_lib/mail/unsubscribe.js +183 -0
  87. package/plugins/_lib/mail/vacationRunner.js +114 -0
  88. package/plugins/_lib/mail/write.js +473 -0
  89. package/plugins/_lib/schemaSync.js +83 -0
  90. package/plugins/_template/admin/css/index.css +0 -0
  91. package/plugins/_template/admin/templates/index.html +4 -4
  92. package/plugins/_template/admin/views/index.js +7 -0
  93. package/plugins/analytics/admin/css/index.css +1 -0
  94. package/plugins/analytics/admin/templates/analytics.html +22 -13
  95. package/plugins/analytics/plugin.json +3 -0
  96. package/plugins/blog/admin/css/index.css +1 -0
  97. package/plugins/blog/admin/templates/blog.html +30 -18
  98. package/plugins/blog/admin/templates/categories.html +2 -2
  99. package/plugins/blog/admin/templates/comments.html +2 -2
  100. package/plugins/blog/admin/templates/post-editor.html +34 -34
  101. package/plugins/blog/admin/templates/settings.html +6 -3
  102. package/plugins/blog/admin/views/blog.js +8 -5
  103. package/plugins/blog/admin/views/categories.js +5 -10
  104. package/plugins/blog/admin/views/comments.js +5 -5
  105. package/plugins/blog/admin/views/post-editor.js +39 -20
  106. package/plugins/blog/admin/views/settings.js +52 -50
  107. package/plugins/blog/collections/categories/schema.json +7 -6
  108. package/plugins/blog/collections/comments/schema.json +11 -10
  109. package/plugins/blog/collections/posts/schema.json +14 -13
  110. package/plugins/blog/plugin.js +36 -13
  111. package/plugins/blog/plugin.json +13 -5
  112. package/plugins/blog/plugin.public.js +312 -0
  113. package/plugins/contacts/admin/css/index.css +1 -0
  114. package/plugins/contacts/admin/templates/contacts.html +128 -0
  115. package/plugins/contacts/admin/views/contacts.js +237 -4
  116. package/plugins/contacts/collections/user-contacts/schema.json +108 -0
  117. package/plugins/contacts/plugin.js +214 -27
  118. package/plugins/contacts/plugin.json +4 -1
  119. package/plugins/invoice/admin/css/index.css +1 -0
  120. package/plugins/invoice/admin/templates/editor.html +140 -49
  121. package/plugins/invoice/admin/templates/index.html +153 -23
  122. package/plugins/invoice/admin/templates/issuers.html +2 -5
  123. package/plugins/invoice/admin/templates/receivers.html +2 -5
  124. package/plugins/invoice/admin/views/contacts-source.js +266 -0
  125. package/plugins/invoice/admin/views/editor.js +366 -199
  126. package/plugins/invoice/admin/views/export.js +199 -0
  127. package/plugins/invoice/admin/views/help-content.js +61 -0
  128. package/plugins/invoice/admin/views/index.js +582 -94
  129. package/plugins/invoice/admin/views/issuers.js +24 -17
  130. package/plugins/invoice/admin/views/media.js +172 -0
  131. package/plugins/invoice/admin/views/party-view.js +305 -67
  132. package/plugins/invoice/admin/views/payments.js +127 -0
  133. package/plugins/invoice/admin/views/print.js +130 -0
  134. package/plugins/invoice/admin/views/receivers.js +49 -16
  135. package/plugins/invoice/admin/views/send.js +212 -0
  136. package/plugins/invoice/admin/views/settings.js +594 -0
  137. package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
  138. package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
  139. package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
  140. package/plugins/invoice/collections/invoices/schema.json +19 -13
  141. package/plugins/invoice/config.js +27 -6
  142. package/plugins/invoice/pdf.js +164 -0
  143. package/plugins/invoice/plugin.js +1217 -44
  144. package/plugins/invoice/plugin.json +10 -9
  145. package/plugins/invoice/templates/_base.css +1 -0
  146. package/plugins/invoice/templates/classic-nologo.html +100 -0
  147. package/plugins/invoice/templates/classic.html +91 -0
  148. package/plugins/invoice/templates/invoice-print.html +24 -0
  149. package/plugins/invoice/templates/minimal.html +99 -0
  150. package/plugins/invoice/templates/modern-nologo.html +114 -0
  151. package/plugins/invoice/templates/modern.html +113 -0
  152. package/plugins/invoice/templates/templates.json +11 -0
  153. package/plugins/mail-reader/admin/views/mail.js +19 -0
  154. package/plugins/mail-reader/config.js +7 -0
  155. package/plugins/mail-reader/plugin.js +48 -0
  156. package/plugins/mail-reader/plugin.json +33 -0
  157. package/plugins/notes/admin/views/notes.js +1 -1
  158. package/plugins/notes/plugin.json +2 -2
  159. package/plugins/surveys/lib/audience.js +37 -0
  160. package/plugins/surveys/lib/campaigns.js +43 -0
  161. package/plugins/surveys/lib/ledger.js +110 -0
  162. package/plugins/surveys/lib/sending.js +106 -0
  163. package/plugins/surveys/lib/stats.js +62 -0
  164. package/plugins/surveys/lib/submit.js +95 -0
  165. package/plugins/surveys/lib/tokens.js +28 -0
  166. package/plugins/surveys/plugin.public.js +149 -0
  167. package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
  168. package/public/css/forms.css +1 -1
  169. package/public/css/menu-highlight.css +1 -1
  170. package/public/css/search.css +1 -0
  171. package/public/css/site.css +1 -1
  172. package/public/js/collection-context.js +2 -2
  173. package/public/js/context-menus.js +1 -0
  174. package/public/js/form-logic-engine.js +1 -1
  175. package/public/js/forms.js +2 -2
  176. package/public/js/menu-decor.mjs +1 -1
  177. package/public/js/search.js +1 -0
  178. package/public/js/site.js +1 -1
  179. package/scripts/build.js +37 -3
  180. package/scripts/copy-domma.js +48 -0
  181. package/scripts/seed.js +1996 -0
  182. package/scripts/setup.js +8 -0
  183. package/server/routes/api/collections.js +34 -0
  184. package/server/routes/api/context-menus.js +104 -0
  185. package/server/routes/api/forms.js +42 -3
  186. package/server/routes/api/notifications.js +69 -19
  187. package/server/routes/api/plugins.js +50 -6
  188. package/server/routes/api/search.js +43 -0
  189. package/server/routes/api/theme.js +69 -0
  190. package/server/routes/public.js +42 -7
  191. package/server/server.js +74 -0
  192. package/server/services/adapters/FileAdapter.js +6 -1
  193. package/server/services/content.js +26 -0
  194. package/server/services/contextMenus.js +477 -0
  195. package/server/services/email.js +29 -3
  196. package/server/services/health.js +23 -2
  197. package/server/services/markdown.js +70 -9
  198. package/server/services/menuRender.js +28 -4
  199. package/server/services/menus.js +25 -1
  200. package/server/services/permissionRegistry.js +24 -0
  201. package/server/services/pluginFiles.js +52 -11
  202. package/server/services/plugins.js +229 -6
  203. package/server/services/renderer.js +148 -22
  204. package/server/services/roles.js +1 -1
  205. package/server/services/search-migration.js +82 -0
  206. package/server/services/search.js +413 -0
  207. package/server/services/sidebar-migration.js +1 -0
  208. package/server/services/themeSettings.js +541 -0
  209. package/server/services/users.js +8 -0
  210. package/server/templates/page.html +4 -2
  211. package/plugins/contacts/data/contacts.json +0 -20
  212. package/plugins/notes/data/notes.json +0 -1
  213. package/plugins/site-search/admin/views/site-search.js +0 -116
  214. package/plugins/site-search/config.js +0 -15
  215. package/plugins/site-search/plugin.js +0 -188
  216. package/plugins/site-search/plugin.json +0 -40
  217. package/plugins/site-search/public/inject-body.html +0 -17
  218. package/plugins/site-search/public/inject-head.html +0 -1
  219. package/plugins/site-search/public/search.css +0 -1
  220. package/plugins/site-search/public/search.js +0 -1
  221. package/plugins/todo/data/todos.json +0 -1
@@ -1,6 +1,30 @@
1
1
  /**
2
2
  * Invoice plugin - list view.
3
+ *
4
+ * The page is bound: every filter control is an observable, the filtered list
5
+ * and the KPIs are computeds over them, and one M.effect pushes the result into
6
+ * Domma's table. There is no applyFilters()/refreshKpis() pass and no
7
+ * hand-rolled debounce - the search box is `.extend({rateLimit})`, which holds
8
+ * the NOTIFICATION back without ever delaying the write.
9
+ *
10
+ * The table itself stays imperative: T owns that markup and re-renders it on
11
+ * setData, so bindings placed inside it would not survive. The effect is the
12
+ * seam between the two.
13
+ *
14
+ * Per-row actions live in a CONTEXT MENU rather than in a column of buttons.
15
+ * There are eleven of them now - print, send, PDF, two spreadsheet formats,
16
+ * duplicate, two status moves, archive, delete - and a row of eleven icons is
17
+ * not a table any more. The same menu opens from the row's ⋯ button, so the
18
+ * gesture is discoverable without a right-click.
3
19
  */
20
+ import {canDownloadPdf, downloadPdf, downloadPdfBatch, openPrintWindow} from './print.js?v=9';
21
+ import {disposeOnLeave} from './view-lifecycle.js?v=8';
22
+ import {openSendDialog} from './send.js?v=8';
23
+ import {openInvoiceSettings} from './settings.js?v=8';
24
+ import {exportInvoice, exportList} from './export.js?v=8';
25
+ import {INVOICE_HELP} from './help-content.js?v=8';
26
+ import {openHelpPanel, scanHelp} from '/plugins/_lib/admin/ui/help.js?v=8';
27
+
4
28
  function escapeHtml(s) {
5
29
  return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
6
30
  }
@@ -15,141 +39,605 @@ const STATUS_BADGE = {
15
39
  cancelled: 'badge-secondary'
16
40
  };
17
41
 
42
+ /**
43
+ * What the row should SAY its status is.
44
+ *
45
+ * `overdue` is derived server-side on every read rather than stored, so a
46
+ * sent invoice past its due date reports overdue without anything having had
47
+ * to run a job to flip it. The stored status is still `sent`, which is why the
48
+ * editor's dropdown does not suddenly disagree with the list.
49
+ *
50
+ * @param {object} row
51
+ * @returns {string}
52
+ */
53
+ function displayStatus(row) {
54
+ return row?.overdue ? 'overdue' : (row?.status || 'draft');
55
+ }
56
+
57
+ function money(amount, currency) {
58
+ try {
59
+ return new Intl.NumberFormat('en-GB', { style: 'currency', currency: currency || 'GBP' }).format(Number(amount) || 0);
60
+ } catch {
61
+ return `${currency || 'GBP'} ${(Number(amount) || 0).toFixed(2)}`;
62
+ }
63
+ }
64
+
18
65
  export const indexView = {
19
66
  templateUrl: '/plugins/invoice/admin/templates/index.html',
20
67
 
21
68
  async onMount($container) {
22
- let invoices = [];
23
- let receivers = [];
24
- let table = null;
69
+ const state = {
70
+ invoices: M.observable([]),
71
+ receivers: M.observable([]),
72
+ issuers: M.observable([]),
73
+ canPdf: M.observable(false),
74
+
75
+ status: M.observable(''),
76
+ issuerId: M.observable(''),
77
+ from: M.observable(''),
78
+ to: M.observable(''),
79
+ showArchived: M.observable(false),
80
+ sortBy: M.observable('issueDate:desc'),
81
+ // The write lands immediately; only the announcement waits for the
82
+ // typing to stop, so the filter runs once per pause, not per key.
83
+ query: M.observable('').extend({ rateLimit: 200 })
84
+ };
85
+
86
+ const receiverOf = (id) => state.receivers.value.find((x) => x.id === id) ?? null;
87
+ const issuerOf = (id) => state.issuers.value.find((x) => x.id === id) ?? null;
88
+
89
+ const receiverName = (id) => {
90
+ const r = receiverOf(id);
91
+ return r ? (r.company || r.name || id) : '-';
92
+ };
93
+ const issuerName = (id) => {
94
+ const i = issuerOf(id);
95
+ return i ? (i.name || id) : '-';
96
+ };
97
+
98
+ state.issuerOptions = M.computed(() => state.issuers.value
99
+ .map((i) => ({ id: i.id, label: i.name || '(unnamed)' })));
100
+
101
+ /**
102
+ * The rows the table works on.
103
+ *
104
+ * The scalar `_` fields exist so T can SORT them: its comparator reads
105
+ * `row[column.key]`, and `totals` is an object and `receiverId` is an
106
+ * id, so sorting either column sorted by something nobody can see.
107
+ */
108
+ const rows = M.computed(() => state.invoices.value.map((inv) => ({
109
+ ...inv,
110
+ _number: inv.number ?? '',
111
+ _issuer: issuerName(inv.issuerId),
112
+ _receiver: receiverName(inv.receiverId),
113
+ _status: displayStatus(inv),
114
+ _total: Number(inv.totals?.total) || 0,
115
+ _balance: Number(inv.totals?.balance) || 0
116
+ })));
117
+
118
+ state.filtered = M.computed(() => {
119
+ const status = state.status.value;
120
+ const issuer = state.issuerId.value;
121
+ const from = state.from.value;
122
+ const to = state.to.value;
123
+ const archived = state.showArchived.value;
124
+ const q = String(state.query.value || '').toLowerCase().trim();
125
+
126
+ return rows.value.filter((inv) => {
127
+ // Archived is a shelf: it is hidden unless asked for, and when
128
+ // it IS asked for the shelf is all you get - mixing the two
129
+ // makes "show archived" look like it did nothing.
130
+ if (Boolean(inv.archived) !== archived) return false;
131
+ if (status && inv._status !== status) return false;
132
+ if (issuer && inv.issuerId !== issuer) return false;
133
+ if (from && String(inv.issueDate ?? '') < from) return false;
134
+ if (to && String(inv.issueDate ?? '') > to) return false;
135
+ if (!q) return true;
136
+
137
+ const receiver = receiverOf(inv.receiverId);
138
+ return [inv._number, inv._receiver, inv._issuer, receiver?.reference, receiver?.email, inv.notes]
139
+ .some((v) => String(v ?? '').toLowerCase().includes(q));
140
+ });
141
+ });
142
+
143
+ // ---- KPIs -----------------------------------------------------------
144
+ //
145
+ // Counted over the LIVE invoices rather than the filtered list: a KPI
146
+ // that moved with the search box would answer a different question
147
+ // every keystroke, and the one being asked here is "how is the ledger".
148
+
149
+ const live = M.computed(() => state.invoices.value.filter((i) => !i.archived));
150
+ const countBy = (test) => M.computed(() => live.value.filter(test).length);
151
+
152
+ state.kpiTotal = M.computed(() => live.value.length);
153
+ state.kpiPaid = countBy((i) => i.status === 'paid');
154
+ state.kpiDrafts = countBy((i) => i.status === 'draft');
155
+ state.kpiOverdue = countBy((i) => displayStatus(i) === 'overdue');
156
+ state.kpiOutstanding = M.computed(() => {
157
+ const owed = live.value
158
+ .filter((i) => ['sent', 'overdue'].includes(displayStatus(i)))
159
+ .reduce((sum, i) => sum + (Number(i.totals?.balance) || 0), 0);
160
+ return owed;
161
+ });
162
+ state.kpiOutstandingText = M.computed(() => {
163
+ // Whatever the first outstanding invoice is billed in. Mixing
164
+ // currencies into one total would be a lie, so the count carries
165
+ // the meaning when they differ.
166
+ const outstanding = live.value.filter((i) => ['sent', 'overdue'].includes(displayStatus(i)));
167
+ const currencies = new Set(outstanding.map((i) => i.currency || 'GBP'));
168
+ if (currencies.size > 1) return `${outstanding.length} invoices`;
169
+ return money(state.kpiOutstanding.value, [...currencies][0] || 'GBP');
170
+ });
171
+
172
+ state.hasFilters = M.computed(() => Boolean(
173
+ state.status.value || state.issuerId.value || state.from.value
174
+ || state.to.value || state.query.value || state.showArchived.value
175
+ ));
176
+
177
+ state.matchesText = M.computed(() => {
178
+ const shown = state.filtered.value.length;
179
+ const all = rows.value.filter((i) => Boolean(i.archived) === state.showArchived.value).length;
180
+ if (!all) return '';
181
+ return shown === all ? `${all} shown` : `${shown} of ${all} shown`;
182
+ });
183
+
184
+ state.hasRows = M.computed(() => state.filtered.value.length > 0);
185
+ state.isEmpty = M.computed(() => state.filtered.value.length === 0);
186
+
187
+ // Three different nothings, and saying which one it is saves a hunt:
188
+ // an empty ledger, an empty shelf, or a filter that matches nothing.
189
+ state.emptyText = M.computed(() => {
190
+ if (!state.invoices.value.length) return 'No invoices yet. Click “New Invoice” to create your first.';
191
+ if (state.showArchived.value
192
+ && !state.invoices.value.some((i) => i.archived)) return 'Nothing has been archived.';
193
+ return 'No invoice matches those filters.';
194
+ });
195
+
196
+ // ---- can we invoice at all? ----------------------------------------
197
+ //
198
+ // An invoice needs somebody at both ends, and the server refuses one
199
+ // without them. A button that leads to a screen whose Save can only
200
+ // fail is worse than a button that is plainly off, so this is computed
201
+ // from the same two lists the editor's pickers are built from.
202
+
203
+ // Each of these reads the OBSERVABLES, not each other. A computed built
204
+ // on another computed updated when something read it through a third
205
+ // computed (`newInvoiceTitle` was correct all along) but the `data-if`
206
+ // and `data-bind-disabled` hung off it never re-ran - the element stayed
207
+ // and the button stayed disabled with the right title on it. One level
208
+ // of derivation, and all three agree.
209
+ const noIssuers = () => state.issuers.value.length === 0;
210
+ const noReceivers = () => state.receivers.value.length === 0;
211
+
212
+ state.needsIssuer = M.computed(noIssuers);
213
+ state.needsReceiver = M.computed(noReceivers);
214
+ state.needsSetup = M.computed(() => noIssuers() || noReceivers());
215
+ state.cannotCreate = M.computed(() => noIssuers() || noReceivers());
216
+
217
+ state.newInvoiceTitle = M.computed(() => {
218
+ if (!noIssuers() && !noReceivers()) return 'Create a new invoice';
219
+ const missing = [noIssuers() ? 'an issuer' : '', noReceivers() ? 'a receiver' : '']
220
+ .filter(Boolean).join(' and ');
221
+ return `Add ${missing} first - an invoice needs somebody at both ends.`;
222
+ });
223
+
224
+ state.setupTitle = M.computed(() => (noIssuers() && noReceivers()
225
+ ? 'Two things first'
226
+ : 'One thing first'));
227
+
228
+ state.setupText = M.computed(() => {
229
+ if (noIssuers() && noReceivers()) {
230
+ return 'An invoice is raised by an issuer (you) for a receiver (your customer). Add one of each and New Invoice switches on.';
231
+ }
232
+ if (noIssuers()) {
233
+ return 'An issuer is you - the trading name, address and bank details an invoice is paid into.';
234
+ }
235
+ return 'A receiver is who you are billing. If you use Contacts, you can import one.';
236
+ });
237
+
238
+ state.addIssuer = () => openSettings('issuers');
239
+ state.addReceiver = () => openSettings('receivers');
240
+ state.openHelp = () => openHelpPanel(INVOICE_HELP);
241
+
242
+ // ---- the KPI tiles are filters -------------------------------------
243
+ //
244
+ // A number you cannot act on is a poster. Clicking one puts the list
245
+ // into the state it counts, which is what somebody wanted the moment
246
+ // they read it.
247
+
248
+ // Assigned once the pickers exist; the KPI tiles and Clear both reset
249
+ // the date range and are wired before them.
250
+ let clearPickers = () => {};
251
+
252
+ const filterTo = (status) => {
253
+ state.showArchived.value = false;
254
+ state.query.value = '';
255
+ state.issuerId.value = '';
256
+ state.from.value = '';
257
+ state.to.value = '';
258
+ state.status.value = status;
259
+ clearPickers();
260
+ };
261
+
262
+ state.showAll = () => filterTo('');
263
+ state.showOutstanding = () => filterTo('sent');
264
+ state.showOverdue = () => filterTo('overdue');
265
+ state.showPaid = () => filterTo('paid');
266
+ state.showDrafts = () => filterTo('draft');
267
+
268
+ state.newInvoice = () => {
269
+ if (noIssuers() || noReceivers()) {
270
+ E.toast(state.newInvoiceTitle.value, { type: 'error' });
271
+ return;
272
+ }
273
+ R.navigate('#/plugins/invoice/new');
274
+ };
275
+
276
+ state.clearFilters = () => {
277
+ state.status.value = '';
278
+ state.issuerId.value = '';
279
+ state.from.value = '';
280
+ state.to.value = '';
281
+ state.query.value = '';
282
+ state.showArchived.value = false;
283
+ // The pickers own their inputs' text, so clearing the observable is
284
+ // not enough - the field would still read as a date it no longer
285
+ // filters by.
286
+ clearPickers();
287
+ };
288
+
289
+ const exportNames = (inv) => ({
290
+ issuerName: issuerName(inv.issuerId),
291
+ receiverName: receiverName(inv.receiverId)
292
+ });
293
+
294
+ state.exportCsv = () => exportList(state.filtered.value, 'csv', exportNames);
295
+ state.exportExcel = () => exportList(state.filtered.value, 'excel', exportNames);
296
+ // The spreadsheet exports describe the invoices; this one IS them - the
297
+ // same documents a client receives, one per page. It goes through the
298
+ // server because that is where the renderer is, so unlike CSV and Excel
299
+ // it is offered only where a PDF can actually be made.
300
+ state.exportPdf = () => downloadPdfBatch(state.filtered.value.map((inv) => inv.id));
301
+
302
+ // ---- mutations ------------------------------------------------------
303
+
304
+ /** Replace one invoice in the list, in place. */
305
+ function replace(updated) {
306
+ if (!updated?.id) return;
307
+ state.invoices.value = state.invoices.value.map((x) => (x.id === updated.id ? updated : x));
308
+ }
309
+
310
+ async function setStatus(inv, status) {
311
+ try {
312
+ const res = await H.put(`${BASE}/invoices/${inv.id}`, { status });
313
+ const out = res?.data ?? res;
314
+ if (out?.error) throw new Error(out.error);
315
+ replace(out);
316
+ E.toast(`Marked ${status}.`, { type: 'success' });
317
+ } catch (err) {
318
+ E.toast(err?.message ?? 'Could not update that invoice.', { type: 'error' });
319
+ }
320
+ }
321
+
322
+ async function setArchived(inv, archived) {
323
+ try {
324
+ const res = await H.put(`${BASE}/invoices/${inv.id}`, { archived });
325
+ const out = res?.data ?? res;
326
+ if (out?.error) throw new Error(out.error);
327
+ replace(out);
328
+ E.toast(archived ? 'Archived.' : 'Restored.', { type: 'success' });
329
+ } catch (err) {
330
+ E.toast(err?.message ?? 'Could not archive that invoice.', { type: 'error' });
331
+ }
332
+ }
25
333
 
26
- function receiverName(id) {
27
- const r = receivers.find((x) => x.id === id);
28
- if (!r) return '-';
29
- return r.company || r.name || id;
334
+ async function duplicate(inv) {
335
+ try {
336
+ const res = await H.post(`${BASE}/invoices/${inv.id}/duplicate`, {});
337
+ const out = res?.data ?? res;
338
+ if (out?.error) throw new Error(out.error);
339
+ state.invoices.value = [out, ...state.invoices.value];
340
+ E.toast(`Copied to ${out.number}.`, { type: 'success' });
341
+ R.navigate(`#/plugins/invoice/edit/${out.id}`);
342
+ } catch (err) {
343
+ E.toast(err?.message ?? 'Could not copy that invoice.', { type: 'error' });
344
+ }
30
345
  }
31
346
 
32
- function fmt(amount, currency) {
347
+ async function remove(inv) {
348
+ const ok = await E.confirm(`Delete invoice ${inv.number ?? ''}? Archiving keeps it and hides it.`);
349
+ if (!ok) return;
33
350
  try {
34
- return new Intl.NumberFormat('en-GB', { style: 'currency', currency: currency || 'GBP' }).format(Number(amount) || 0);
351
+ await H.delete(`${BASE}/invoices/${inv.id}`);
352
+ E.toast('Invoice deleted.', { type: 'success' });
353
+ // One write; the KPIs, the count and the table follow.
354
+ state.invoices.value = state.invoices.value.filter((x) => x.id !== inv.id);
35
355
  } catch {
36
- return `${currency || 'GBP'} ${Number(amount).toFixed(2)}`;
356
+ E.toast('Delete failed.', { type: 'error' });
37
357
  }
38
358
  }
39
359
 
40
- function buildTable(data) {
41
- const el = $container.find('#invoices-table').get(0);
42
- if (!el) return;
360
+ // ---- the table ------------------------------------------------------
361
+
362
+ const tableEl = $container.find('#invoices-table').get(0);
363
+ let table = null;
364
+ // Declared before renderTable, which closes over it: T's onSort fires
365
+ // from inside the table, and a `let` read before its initialiser is a
366
+ // ReferenceError rather than `undefined`.
367
+ let syncingSort = false;
368
+
369
+ function renderTable(data) {
370
+ if (!tableEl) return;
43
371
  if (table) {
44
372
  table.setData(data);
45
- Domma.icons.scan(el);
373
+ Domma.icons.scan(tableEl);
46
374
  return;
47
375
  }
48
- table = T.create(el, {
376
+ table = T.create(tableEl, {
49
377
  data,
50
378
  columns: [
51
379
  {
52
- key: 'number', title: 'Number', sortable: true,
53
- render: (v, row) => `<a href="#/plugins/invoice/edit/${escapeHtml(row.id)}" style="font-weight:600;">${escapeHtml(v || '-')}</a>`
380
+ key: '_number', title: 'Number', sortable: true,
381
+ render: (v, row) => `<a class="invoice-number-link" data-id="${escapeHtml(row.id)}" href="#/plugins/invoice/edit/${escapeHtml(row.id)}">${escapeHtml(v || '-')}</a>`
54
382
  },
55
- { key: 'receiverId', title: 'Billed To', sortable: false,
56
- render: (v) => escapeHtml(receiverName(v))
57
- },
58
- { key: 'issueDate', title: 'Issue Date', sortable: true,
383
+ { key: '_receiver', title: 'Billed To', sortable: true, render: (v) => escapeHtml(v) },
384
+ { key: 'issueDate', title: 'Issued', sortable: true,
59
385
  render: (v) => v ? escapeHtml(D(v).format('DD MMM YYYY')) : '-'
60
386
  },
61
- { key: 'totals', title: 'Total', sortable: false,
62
- render: (totals, row) => `<span style="font-variant-numeric:tabular-nums;">${escapeHtml(fmt(totals?.total ?? 0, row.currency))}</span>`
387
+ { key: 'dueDate', title: 'Due', sortable: true,
388
+ render: (v, row) => {
389
+ if (!v) return '-';
390
+ const late = row._status === 'overdue';
391
+ return `<span class="${late ? 'text-danger' : ''}">${escapeHtml(D(v).format('DD MMM YYYY'))}</span>`;
392
+ }
393
+ },
394
+ { key: '_total', title: 'Total', sortable: true,
395
+ render: (v, row) => `<span class="invoice-num">${escapeHtml(money(v, row.currency))}</span>`
63
396
  },
64
- { key: 'status', title: 'Status', sortable: true,
65
- render: (v) => `<span class="badge ${STATUS_BADGE[v] || 'badge-secondary'}">${escapeHtml(v || 'draft')}</span>`
397
+ { key: '_status', title: 'Status', sortable: true,
398
+ render: (v, row) => {
399
+ const archived = row.archived ? ' <span class="badge badge-secondary">archived</span>' : '';
400
+ return `<span class="badge ${STATUS_BADGE[v] || 'badge-secondary'}">${escapeHtml(v)}</span>${archived}`;
401
+ }
66
402
  },
67
- { key: 'id', title: 'Actions', sortable: false,
68
- render: (id) => `<div style="display:flex;gap:.4rem;">
69
- <button class="btn btn-sm btn-outline edit-btn" data-id="${escapeHtml(id)}"><span data-icon="edit" data-icon-size="14"></span></button>
70
- <a class="btn btn-sm btn-outline" href="${BASE}/invoices/${escapeHtml(id)}/print" target="_blank" title="Print / PDF"><span data-icon="printer" data-icon-size="14"></span></a>
71
- <button class="btn btn-sm btn-danger delete-btn" data-id="${escapeHtml(id)}"><span data-icon="trash" data-icon-size="14"></span></button>
72
- </div>`
403
+ // A space, not '': T falls back to the column KEY for a
404
+ // falsy title, which printed a column headed "id".
405
+ { key: 'id', title: ' ', sortable: false,
406
+ render: (id) => `<button class="btn btn-sm btn-ghost invoice-row-menu" data-id="${escapeHtml(id)}" title="Actions" aria-label="Actions">⋯</button>`
73
407
  }
74
408
  ],
75
- emptyMessage: 'No invoices yet. Click “New Invoice” to create your first.'
409
+ // Domma's table ships its own search box. This view already has
410
+ // one, and its filter also matches the receiver NAME, the
411
+ // reference and the notes - which a search over the raw row
412
+ // cannot. Two boxes, one of them worse, is not a feature.
413
+ searchable: false,
414
+ pagination: true,
415
+ pageSize: 25,
416
+ defaultSort: { column: 'issueDate', direction: 'desc' },
417
+ // Clicking a header is the other half of the Sort control; this
418
+ // keeps the two showing the same answer.
419
+ onSort: ({ sorts }) => {
420
+ const first = sorts?.[0];
421
+ if (!first) return;
422
+ const value = `${first.column}:${first.direction}`;
423
+ if (state.sortBy.value !== value) {
424
+ syncingSort = true;
425
+ state.sortBy.value = value;
426
+ syncingSort = false;
427
+ }
428
+ },
429
+ // NB: Domma's table has no empty-state option, so the "nothing
430
+ // here" line is in the template, bound to `isEmpty`.
431
+ pageSizeOptions: [25, 50, 100]
76
432
  });
77
433
 
78
- el.addEventListener('click', async (e) => {
79
- const editBtn = e.target.closest('.edit-btn');
80
- const delBtn = e.target.closest('.delete-btn');
81
- if (editBtn) R.navigate('#/plugins/invoice/edit/' + editBtn.dataset.id);
82
- if (delBtn) {
83
- const id = delBtn.dataset.id;
84
- const inv = invoices.find((x) => x.id === id);
85
- const ok = await E.confirm(`Delete invoice ${inv?.number ?? ''}?`);
86
- if (!ok) return;
87
- try {
88
- await H.delete(`${BASE}/invoices/${id}`);
89
- E.toast('Invoice deleted.', { type: 'success' });
90
- await reload();
91
- } catch {
92
- E.toast('Delete failed.', { type: 'error' });
93
- }
94
- }
434
+ tableEl.addEventListener('click', (e) => {
435
+ const menuBtn = e.target.closest('.invoice-row-menu');
436
+ if (!menuBtn) return;
437
+ e.preventDefault();
438
+ const rect = menuBtn.getBoundingClientRect();
439
+ rowMenu?.open(rect.left, rect.bottom, menuBtn.closest('tr'));
95
440
  });
96
441
 
97
- Domma.icons.scan(el);
442
+ Domma.icons.scan(tableEl);
98
443
  }
99
444
 
100
- function refreshKpis() {
101
- const counts = { total: invoices.length, outstanding: 0, paid: 0, drafts: 0 };
102
- for (const i of invoices) {
103
- if (i.status === 'paid') counts.paid++;
104
- else if (i.status === 'draft') counts.drafts++;
105
- else if (i.status === 'sent' || i.status === 'overdue') counts.outstanding++;
106
- }
107
- $container.find('#kpi-total').text(counts.total);
108
- $container.find('#kpi-outstanding').text(counts.outstanding);
109
- $container.find('#kpi-paid').text(counts.paid);
110
- $container.find('#kpi-drafts').text(counts.drafts);
445
+ // The seam: tracked read of the filtered list, untracked write to the
446
+ // table, so T's own DOM reads never become dependencies of this effect.
447
+ // M.effect returns its disposer, not a handle.
448
+ const stopFeed = M.effect(() => {
449
+ const data = state.filtered.value;
450
+ M.untracked(() => renderTable(data));
451
+ });
452
+
453
+ // The Sort select drives T's own sort rather than re-ordering the data,
454
+ // so there is ONE sort in play and a header click cannot disagree with
455
+ // the select. `syncingSort` stops the round trip from looping.
456
+ const stopSort = M.effect(() => {
457
+ const [column, direction] = String(state.sortBy.value || '').split(':');
458
+ if (syncingSort || !column) return;
459
+ M.untracked(() => table?.sort(column, direction === 'asc' ? 'asc' : 'desc'));
460
+ });
461
+
462
+ // ---- the row menu ---------------------------------------------------
463
+
464
+ /**
465
+ * The invoice a gesture landed on.
466
+ *
467
+ * T does not put the row key on its `<tr>`, so the id is read off the
468
+ * only thing in the row that carries one - the number link.
469
+ *
470
+ * @param {Element} target
471
+ * @returns {object|null}
472
+ */
473
+ function invoiceFor(target) {
474
+ const id = target?.querySelector?.('[data-id]')?.dataset?.id
475
+ ?? target?.closest?.('[data-id]')?.dataset?.id;
476
+ if (!id) return null;
477
+ return state.filtered.value.find((x) => x.id === id) ?? null;
111
478
  }
112
479
 
113
- function applyFilters() {
114
- const status = $container.find('#filter-status').val() || '';
115
- const q = ($container.find('#filter-search').val() || '').toString().toLowerCase().trim();
116
- let filtered = invoices;
117
- if (status) filtered = filtered.filter((i) => i.status === status);
118
- if (q) {
119
- filtered = filtered.filter((i) =>
120
- (i.number ?? '').toLowerCase().includes(q) ||
121
- receiverName(i.receiverId).toLowerCase().includes(q)
480
+ const rowMenu = E.contextMenu('#invoices-table', {
481
+ match: 'tbody tr',
482
+ // A row menu that also offered whatever an ancestor had bound would
483
+ // be a different menu depending on where the table was mounted.
484
+ inherit: false,
485
+ items: (target) => {
486
+ const inv = invoiceFor(target);
487
+ if (!inv) return [];
488
+ const status = inv._status;
489
+
490
+ const items = [
491
+ { header: inv.number || 'Invoice' },
492
+ { label: 'Open', icon: 'edit', action: () => R.navigate(`#/plugins/invoice/edit/${inv.id}`) },
493
+ { label: 'Print / preview', icon: 'printer', action: () => openPrintWindow(inv.id) },
494
+ { label: 'Send by email', icon: 'mail',
495
+ action: () => openSendDialog(inv.id, replace) }
496
+ ];
497
+
498
+ if (state.canPdf.value) {
499
+ items.push({ label: 'Download PDF', icon: 'download', action: () => downloadPdf(inv.id) });
500
+ }
501
+
502
+ items.push(
503
+ { type: 'divider' },
504
+ { label: 'Export', icon: 'file-text', submenu: [
505
+ { label: 'CSV', action: () => exportInvoice(inv, 'csv', exportNames(inv)) },
506
+ { label: 'Excel', action: () => exportInvoice(inv, 'excel', exportNames(inv)) }
507
+ ] },
508
+ { label: 'Duplicate', icon: 'copy', action: () => duplicate(inv) },
509
+ { type: 'divider' }
510
+ );
511
+
512
+ if (status === 'draft') {
513
+ items.push({ label: 'Mark as sent', icon: 'send', action: () => setStatus(inv, 'sent') });
514
+ }
515
+ if (status !== 'paid' && status !== 'cancelled') {
516
+ items.push({ label: 'Mark as paid', icon: 'check-circle', action: () => setStatus(inv, 'paid') });
517
+ }
518
+ if (status !== 'cancelled') {
519
+ items.push({ label: 'Cancel invoice', icon: 'x-circle', action: () => setStatus(inv, 'cancelled') });
520
+ }
521
+
522
+ items.push(
523
+ { type: 'divider' },
524
+ inv.archived
525
+ ? { label: 'Restore', icon: 'archive', action: () => setArchived(inv, false) }
526
+ : { label: 'Archive', icon: 'archive', action: () => setArchived(inv, true) },
527
+ { label: 'Delete', icon: 'trash', danger: true, action: () => remove(inv) }
122
528
  );
529
+
530
+ return items;
123
531
  }
124
- buildTable(filtered);
125
- }
532
+ });
126
533
 
127
- async function reload() {
128
- try {
129
- const [invRes, recRes] = await Promise.all([
130
- H.get(`${BASE}/invoices`),
131
- H.get(`${BASE}/receivers`).catch(() => ({ data: [] }))
132
- ]);
133
- invoices = invRes.data ?? invRes ?? [];
134
- receivers = recRes.data ?? recRes ?? [];
135
- } catch {
136
- invoices = [];
137
- receivers = [];
138
- E.toast('Could not load invoices.', { type: 'error' });
534
+ // ---- settings -------------------------------------------------------
535
+
536
+ /**
537
+ * @param {string} [tab] open straight onto a section
538
+ * @returns {void}
539
+ */
540
+ const openSettings = (tab) => openInvoiceSettings({
541
+ tab,
542
+ // Issuers and receivers are edited in there, and this screen gates
543
+ // New Invoice on both - so it has to hear about it.
544
+ onChange: async () => {
545
+ try {
546
+ const [recRes, issRes] = await Promise.all([
547
+ H.get(`${BASE}/receivers`).catch(() => []),
548
+ H.get(`${BASE}/issuers`).catch(() => [])
549
+ ]);
550
+ state.receivers.value = recRes.data ?? recRes ?? [];
551
+ state.issuers.value = issRes.data ?? issRes ?? [];
552
+ } catch { /* the list still shows what it had */ }
553
+ }
554
+ });
555
+
556
+ // The cog belongs in the admin's plugin banner, which this view does
557
+ // not own - so the helper is imported at RUN time and its absence is
558
+ // survivable. An older admin simply has no banner to put it in, and the
559
+ // fallback puts it in the toolbar instead of losing the screen.
560
+ try {
561
+ const chrome = await import('/admin/js/lib/plugin-chrome.js');
562
+ // Help first, settings second: they read left to right, and the
563
+ // one you need when you are lost should not be the further away.
564
+ chrome.addBannerAction?.($container, {
565
+ id: 'invoice-help',
566
+ icon: 'help-circle',
567
+ title: 'How invoicing works',
568
+ onClick: () => openHelpPanel(INVOICE_HELP)
569
+ });
570
+ const placed = chrome.addBannerAction?.($container, {
571
+ id: 'invoice-settings',
572
+ icon: 'settings',
573
+ title: 'Invoice settings',
574
+ onClick: () => openSettings()
575
+ });
576
+ if (!placed) throw new Error('no banner');
577
+ } catch {
578
+ const toolbar = $container.find('.invoice-toolbar-row').get(0);
579
+ if (toolbar) {
580
+ const cog = document.createElement('button');
581
+ cog.type = 'button';
582
+ cog.className = 'btn btn-sm btn-ghost';
583
+ cog.title = 'Invoice settings';
584
+ const glyph = document.createElement('span');
585
+ glyph.setAttribute('data-icon', 'settings');
586
+ glyph.setAttribute('data-icon-size', '16');
587
+ cog.appendChild(glyph);
588
+ cog.addEventListener('click', () => openSettings());
589
+ toolbar.appendChild(cog);
139
590
  }
140
- refreshKpis();
141
- applyFilters();
142
591
  }
143
592
 
144
- $container.find('#btn-new-invoice').on('click', () => R.navigate('#/plugins/invoice/new'));
145
- $container.find('#filter-status').on('change', applyFilters);
146
- let timer = null;
147
- $container.find('#filter-search').on('input', () => {
148
- clearTimeout(timer);
149
- timer = setTimeout(applyFilters, 200);
593
+ const handle = M.applyBindings(state, $container.get(0));
594
+
595
+ // ---- data -----------------------------------------------------------
596
+
597
+ try {
598
+ const [invRes, recRes, issRes] = await Promise.all([
599
+ H.get(`${BASE}/invoices`),
600
+ H.get(`${BASE}/receivers`).catch(() => []),
601
+ H.get(`${BASE}/issuers`).catch(() => [])
602
+ ]);
603
+ state.receivers.value = recRes.data ?? recRes ?? [];
604
+ state.issuers.value = issRes.data ?? issRes ?? [];
605
+ state.invoices.value = invRes.data ?? invRes ?? [];
606
+ } catch {
607
+ E.toast('Could not load invoices.', { type: 'error' });
608
+ }
609
+
610
+ // Only offered where the host can actually render one; a menu item that
611
+ // always answers "not installed" is worse than no menu item.
612
+ canDownloadPdf().then((ok) => { state.canPdf.value = ok; });
613
+
614
+ // The same calendar the editor uses, on the two range filters. Each
615
+ // bounds the other, so the range cannot be typed backwards.
616
+ const fromPicker = E.datePicker($container.find('#filter-from').get(0), {
617
+ placeholder: 'Any',
618
+ onChange: (iso) => toPicker?.setOptions({min: iso || null})
150
619
  });
620
+ const toPicker = E.datePicker($container.find('#filter-to').get(0), {
621
+ placeholder: 'Any',
622
+ onChange: (iso) => fromPicker?.setOptions({max: iso || null})
623
+ });
624
+
625
+ clearPickers = () => {
626
+ fromPicker?.setValue('', {silent: true});
627
+ toPicker?.setValue('', {silent: true});
628
+ fromPicker?.setOptions({max: null});
629
+ toPicker?.setOptions({min: null});
630
+ };
631
+
632
+ disposeOnLeave(tableEl, stopFeed, stopSort, handle,
633
+ () => rowMenu?.destroy?.(),
634
+ () => fromPicker?.destroy?.(),
635
+ () => toPicker?.destroy?.());
151
636
 
152
- await reload();
153
- Domma.icons.scan();
637
+ Domma.icons.scan($container.get(0));
638
+ // After the bindings, so a control inside a data-if that has just been
639
+ // put into the document is wired too.
640
+ M.flush();
641
+ scanHelp($container.get(0));
154
642
  }
155
643
  };