domma-cms 0.55.1 → 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 (211) hide show
  1. package/CLAUDE.md +130 -2
  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/plugin-code.html +1 -1
  19. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  20. package/admin/js/templates/settings.html +16 -97
  21. package/admin/js/templates/theme.html +173 -0
  22. package/admin/js/views/context-menu-editor.js +55 -0
  23. package/admin/js/views/context-menus.js +5 -0
  24. package/admin/js/views/form-editor.js +7 -7
  25. package/admin/js/views/index.js +1 -1
  26. package/admin/js/views/plugin-marketplace.js +1 -1
  27. package/admin/js/views/plugins.js +25 -23
  28. package/admin/js/views/search.js +1 -0
  29. package/admin/js/views/settings.js +3 -3
  30. package/admin/js/views/theme.js +1 -0
  31. package/bin/cli.js +2 -0
  32. package/bin/update.js +13 -2
  33. package/config/menus/admin-sidebar.json +129 -23
  34. package/config/plugins.json +5 -5
  35. package/config/search.json +13 -0
  36. package/config/theme.json +18 -0
  37. package/package.json +12 -4
  38. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  39. package/plugins/_lib/admin/mail/contacts.js +301 -0
  40. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  41. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  42. package/plugins/_lib/admin/mail/identity.js +480 -0
  43. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  44. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  45. package/plugins/_lib/admin/mail/mail.css +1 -0
  46. package/plugins/_lib/admin/mail/mail.html +71 -0
  47. package/plugins/_lib/admin/mail/panes.js +253 -0
  48. package/plugins/_lib/admin/mail/reader-view.js +4453 -0
  49. package/plugins/_lib/admin/mail/resizable.js +26 -0
  50. package/plugins/_lib/admin/mail/rules.js +343 -0
  51. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  52. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  53. package/plugins/_lib/admin/mail/templates.js +238 -0
  54. package/plugins/_lib/admin/mail/threads.js +200 -0
  55. package/plugins/_lib/admin/mail/vacation.js +269 -0
  56. package/plugins/_lib/admin/ui/help.css +1 -0
  57. package/plugins/_lib/admin/ui/help.js +174 -0
  58. package/plugins/_lib/admin/ui/resizable.js +151 -0
  59. package/plugins/_lib/dataStore.js +117 -0
  60. package/plugins/_lib/mail/accounts.js +919 -0
  61. package/plugins/_lib/mail/bodyTokens.js +101 -0
  62. package/plugins/_lib/mail/compose.js +256 -0
  63. package/plugins/_lib/mail/defaults.js +57 -0
  64. package/plugins/_lib/mail/diagnostics.js +154 -0
  65. package/plugins/_lib/mail/envelope.js +161 -0
  66. package/plugins/_lib/mail/folders.js +192 -0
  67. package/plugins/_lib/mail/handoff.js +274 -0
  68. package/plugins/_lib/mail/imapPool.js +293 -0
  69. package/plugins/_lib/mail/mbox.js +74 -0
  70. package/plugins/_lib/mail/pollSchedule.js +79 -0
  71. package/plugins/_lib/mail/poller.js +135 -0
  72. package/plugins/_lib/mail/priority.js +138 -0
  73. package/plugins/_lib/mail/readRoutes.js +680 -0
  74. package/plugins/_lib/mail/render.js +291 -0
  75. package/plugins/_lib/mail/ruleRunner.js +152 -0
  76. package/plugins/_lib/mail/scheduler.js +254 -0
  77. package/plugins/_lib/mail/secretbox.js +229 -0
  78. package/plugins/_lib/mail/send.js +396 -0
  79. package/plugins/_lib/mail/store.js +1002 -0
  80. package/plugins/_lib/mail/sync.js +292 -0
  81. package/plugins/_lib/mail/syncPlan.js +126 -0
  82. package/plugins/_lib/mail/syncSelection.js +82 -0
  83. package/plugins/_lib/mail/unsubscribe.js +183 -0
  84. package/plugins/_lib/mail/vacationRunner.js +114 -0
  85. package/plugins/_lib/mail/write.js +473 -0
  86. package/plugins/_lib/schemaSync.js +83 -0
  87. package/plugins/_template/admin/css/index.css +0 -0
  88. package/plugins/_template/admin/templates/index.html +4 -4
  89. package/plugins/_template/admin/views/index.js +7 -0
  90. package/plugins/analytics/admin/css/index.css +1 -0
  91. package/plugins/analytics/admin/templates/analytics.html +22 -13
  92. package/plugins/analytics/plugin.json +3 -0
  93. package/plugins/blog/admin/css/index.css +1 -0
  94. package/plugins/blog/admin/templates/blog.html +30 -18
  95. package/plugins/blog/admin/templates/categories.html +2 -2
  96. package/plugins/blog/admin/templates/comments.html +2 -2
  97. package/plugins/blog/admin/templates/post-editor.html +34 -34
  98. package/plugins/blog/admin/templates/settings.html +6 -3
  99. package/plugins/blog/admin/views/blog.js +8 -5
  100. package/plugins/blog/admin/views/categories.js +5 -10
  101. package/plugins/blog/admin/views/comments.js +5 -5
  102. package/plugins/blog/admin/views/post-editor.js +39 -20
  103. package/plugins/blog/admin/views/settings.js +52 -50
  104. package/plugins/blog/collections/categories/schema.json +7 -6
  105. package/plugins/blog/collections/comments/schema.json +11 -10
  106. package/plugins/blog/collections/posts/schema.json +14 -13
  107. package/plugins/blog/plugin.js +36 -13
  108. package/plugins/blog/plugin.json +13 -5
  109. package/plugins/blog/plugin.public.js +312 -0
  110. package/plugins/contacts/admin/css/index.css +1 -0
  111. package/plugins/contacts/admin/templates/contacts.html +128 -0
  112. package/plugins/contacts/admin/views/contacts.js +237 -4
  113. package/plugins/contacts/collections/user-contacts/schema.json +108 -0
  114. package/plugins/contacts/plugin.js +214 -27
  115. package/plugins/contacts/plugin.json +4 -1
  116. package/plugins/invoice/admin/css/index.css +1 -0
  117. package/plugins/invoice/admin/templates/editor.html +140 -49
  118. package/plugins/invoice/admin/templates/index.html +153 -23
  119. package/plugins/invoice/admin/templates/issuers.html +2 -5
  120. package/plugins/invoice/admin/templates/receivers.html +2 -5
  121. package/plugins/invoice/admin/views/contacts-source.js +266 -0
  122. package/plugins/invoice/admin/views/editor.js +366 -199
  123. package/plugins/invoice/admin/views/export.js +199 -0
  124. package/plugins/invoice/admin/views/help-content.js +61 -0
  125. package/plugins/invoice/admin/views/index.js +582 -94
  126. package/plugins/invoice/admin/views/issuers.js +24 -17
  127. package/plugins/invoice/admin/views/media.js +172 -0
  128. package/plugins/invoice/admin/views/party-view.js +305 -67
  129. package/plugins/invoice/admin/views/payments.js +127 -0
  130. package/plugins/invoice/admin/views/print.js +130 -0
  131. package/plugins/invoice/admin/views/receivers.js +49 -16
  132. package/plugins/invoice/admin/views/send.js +212 -0
  133. package/plugins/invoice/admin/views/settings.js +594 -0
  134. package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
  135. package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
  136. package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
  137. package/plugins/invoice/collections/invoices/schema.json +19 -13
  138. package/plugins/invoice/config.js +27 -6
  139. package/plugins/invoice/pdf.js +164 -0
  140. package/plugins/invoice/plugin.js +1217 -44
  141. package/plugins/invoice/plugin.json +10 -9
  142. package/plugins/invoice/templates/_base.css +1 -0
  143. package/plugins/invoice/templates/classic-nologo.html +100 -0
  144. package/plugins/invoice/templates/classic.html +91 -0
  145. package/plugins/invoice/templates/invoice-print.html +24 -0
  146. package/plugins/invoice/templates/minimal.html +99 -0
  147. package/plugins/invoice/templates/modern-nologo.html +114 -0
  148. package/plugins/invoice/templates/modern.html +113 -0
  149. package/plugins/invoice/templates/templates.json +11 -0
  150. package/plugins/mail-reader/admin/views/mail.js +19 -0
  151. package/plugins/mail-reader/config.js +7 -0
  152. package/plugins/mail-reader/plugin.js +48 -0
  153. package/plugins/mail-reader/plugin.json +33 -0
  154. package/plugins/notes/admin/views/notes.js +1 -1
  155. package/plugins/notes/plugin.json +2 -2
  156. package/plugins/surveys/lib/audience.js +37 -0
  157. package/plugins/surveys/lib/campaigns.js +43 -0
  158. package/plugins/surveys/lib/ledger.js +110 -0
  159. package/plugins/surveys/lib/sending.js +106 -0
  160. package/plugins/surveys/lib/stats.js +62 -0
  161. package/plugins/surveys/lib/submit.js +95 -0
  162. package/plugins/surveys/lib/tokens.js +28 -0
  163. package/plugins/surveys/plugin.public.js +149 -0
  164. package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
  165. package/public/css/forms.css +1 -1
  166. package/public/css/search.css +1 -0
  167. package/public/css/site.css +1 -1
  168. package/public/js/collection-context.js +2 -2
  169. package/public/js/context-menus.js +1 -0
  170. package/public/js/form-logic-engine.js +1 -1
  171. package/public/js/forms.js +2 -2
  172. package/public/js/search.js +1 -0
  173. package/public/js/site.js +1 -1
  174. package/scripts/build.js +37 -3
  175. package/scripts/copy-domma.js +48 -0
  176. package/scripts/seed.js +1996 -0
  177. package/server/routes/api/collections.js +34 -0
  178. package/server/routes/api/context-menus.js +104 -0
  179. package/server/routes/api/forms.js +42 -3
  180. package/server/routes/api/notifications.js +69 -19
  181. package/server/routes/api/plugins.js +50 -6
  182. package/server/routes/api/search.js +43 -0
  183. package/server/routes/api/theme.js +69 -0
  184. package/server/routes/public.js +42 -7
  185. package/server/server.js +74 -0
  186. package/server/services/adapters/FileAdapter.js +6 -1
  187. package/server/services/content.js +26 -0
  188. package/server/services/contextMenus.js +477 -0
  189. package/server/services/markdown.js +70 -9
  190. package/server/services/permissionRegistry.js +24 -0
  191. package/server/services/pluginFiles.js +52 -11
  192. package/server/services/plugins.js +229 -6
  193. package/server/services/renderer.js +144 -22
  194. package/server/services/roles.js +1 -1
  195. package/server/services/search-migration.js +82 -0
  196. package/server/services/search.js +413 -0
  197. package/server/services/sidebar-migration.js +1 -0
  198. package/server/services/themeSettings.js +541 -0
  199. package/server/services/users.js +8 -0
  200. package/server/templates/page.html +4 -2
  201. package/plugins/contacts/data/contacts.json +0 -20
  202. package/plugins/notes/data/notes.json +0 -1
  203. package/plugins/site-search/admin/views/site-search.js +0 -116
  204. package/plugins/site-search/config.js +0 -15
  205. package/plugins/site-search/plugin.js +0 -188
  206. package/plugins/site-search/plugin.json +0 -40
  207. package/plugins/site-search/public/inject-body.html +0 -17
  208. package/plugins/site-search/public/inject-head.html +0 -1
  209. package/plugins/site-search/public/search.css +0 -1
  210. package/plugins/site-search/public/search.js +0 -1
  211. package/plugins/todo/data/todos.json +0 -1
@@ -0,0 +1,594 @@
1
+ /**
2
+ * Invoice plugin - the settings slideover.
3
+ *
4
+ * Opened from the cog in the plugin banner. It is a slideover rather than a
5
+ * screen on purpose: settings are read while looking at the list, and a route
6
+ * change would throw the list's filters away to show six form fields.
7
+ *
8
+ * Built as SECTIONS, each drawn the first time its tab is opened. The issuer
9
+ * and receiver tabs each carry a live CRUD table, so drawing all six up front
10
+ * would mean three API round trips for tabs nobody looked at - and the two
11
+ * tables would fight over the same T instance name.
12
+ *
13
+ * Each tab saves its OWN keys. The server treats PUT /settings as a patch over
14
+ * what is stored, which is what makes that safe; a PUT that merged over the
15
+ * shipped defaults instead would have Save on the Email tab silently reset the
16
+ * currency.
17
+ */
18
+ import {ISSUER_FIELDS, RECEIVER_FIELDS, mountPartyPanel} from './party-view.js?v=8';
19
+ import {billingProfile, contactsAvailable, openContactPicker} from './contacts-source.js?v=8';
20
+ import {openMediaPicker} from './media.js?v=8';
21
+ import {makeResizable} from '/plugins/_lib/admin/ui/resizable.js?v=8';
22
+ import {openHelpPanel, scanHelp} from '/plugins/_lib/admin/ui/help.js?v=8';
23
+
24
+ const BASE = '/api/plugins/invoice';
25
+
26
+ /**
27
+ * A `<div>` with Domma classes on it, appended to a parent.
28
+ *
29
+ * @param {HTMLElement} parent
30
+ * @param {string} [className]
31
+ * @param {string} [tag]
32
+ * @returns {HTMLElement}
33
+ */
34
+ function box(parent, className = '', tag = 'div') {
35
+ const el = document.createElement(tag);
36
+ if (className) el.className = className;
37
+ parent.appendChild(el);
38
+ return el;
39
+ }
40
+
41
+ /**
42
+ * A line of explanation at the top of a section.
43
+ *
44
+ * @param {HTMLElement} parent
45
+ * @param {string} text
46
+ * @returns {HTMLElement}
47
+ */
48
+ function note(parent, text) {
49
+ const el = box(parent, 'text-sm text-muted mb-3', 'p');
50
+ el.textContent = text;
51
+ return el;
52
+ }
53
+
54
+ /**
55
+ * The row of buttons at the foot of a section, with its status line.
56
+ *
57
+ * @param {HTMLElement} parent
58
+ * @returns {{row: HTMLElement, say: Function, busy: Function}}
59
+ */
60
+ function actionBar(parent) {
61
+ const row = box(parent, 'flex items-center gap-2 mt-3');
62
+ const status = box(row, 'flex-1 text-sm text-muted');
63
+
64
+ return {
65
+ row,
66
+ say(text, tone = 'quiet') {
67
+ status.textContent = text ?? '';
68
+ status.className = 'flex-1 text-sm '
69
+ + ({ ok: 'text-success', bad: 'text-danger' }[tone] ?? 'text-muted');
70
+ },
71
+ busy(on) {
72
+ for (const el of row.querySelectorAll('button')) el.disabled = on;
73
+ }
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Render a Forma form into a section.
79
+ *
80
+ * Labels above and one column, because the panel is narrow and a two-column
81
+ * grid puts a hint under a field it does not belong to.
82
+ *
83
+ * @param {HTMLElement} parent
84
+ * @param {object} schema
85
+ * @param {object} data
86
+ * @returns {object} the Forma instance
87
+ */
88
+ function formIn(parent, schema, data) {
89
+ const host = box(parent, 'invoice-settings-form');
90
+ const form = F.create(schema, data, {
91
+ layout: 'stacked',
92
+ labelPosition: 'top',
93
+ // Forma adds its own Submit unless told not to, and every section here
94
+ // has a Save in its action bar.
95
+ showSubmitButton: false
96
+ });
97
+ form.renderTo(host);
98
+ return form;
99
+ }
100
+
101
+ /**
102
+ * Open the settings slideover.
103
+ *
104
+ * @param {{onChange?: Function, tab?: string}} [options] - `onChange` is
105
+ * called after anything is saved; `tab` opens on that section, which
106
+ * is how "you need an issuer first" can take you straight to it
107
+ * @returns {Promise<void>}
108
+ */
109
+ export async function openInvoiceSettings({ onChange, tab } = {}) {
110
+ let settings;
111
+ try {
112
+ const res = await H.get(`${BASE}/settings`);
113
+ settings = res.data ?? res;
114
+ } catch (err) {
115
+ E.toast(err?.message ?? 'Could not read the invoice settings.', { type: 'error' });
116
+ return;
117
+ }
118
+
119
+ let issuers = [];
120
+ try {
121
+ const res = await H.get(`${BASE}/issuers`);
122
+ issuers = res.data ?? (Array.isArray(res) ? res : []);
123
+ } catch { /* the Issuers tab will say so */ }
124
+
125
+ const haveContacts = await contactsAvailable();
126
+ const panels = [];
127
+
128
+ /**
129
+ * Save a subset of the settings and keep the local copy in step.
130
+ *
131
+ * @param {object} patch
132
+ * @returns {Promise<void>}
133
+ */
134
+ async function save(patch) {
135
+ const res = await H.put(`${BASE}/settings`, patch);
136
+ const out = res?.data ?? res;
137
+ if (out?.error) throw new Error(out.error);
138
+ settings = out.settings ?? { ...settings, ...patch };
139
+ onChange?.(settings);
140
+ }
141
+
142
+ // ---- sections -------------------------------------------------------
143
+
144
+ /**
145
+ * Issuers: us. One profile per trading name, and which one a new invoice
146
+ * opens as.
147
+ */
148
+ function renderIssuers(panel) {
149
+ note(panel, 'An issuer is you - a trading name, its address, VAT number and the account an invoice is paid into. Add one profile per business you invoice as.');
150
+
151
+ const defaults = box(panel, 'invoice-settings-inline');
152
+ const label = box(defaults, 'form-label', 'label');
153
+ label.textContent = 'Invoice as';
154
+ label.setAttribute('for', 'invoice-default-issuer');
155
+
156
+ const select = box(defaults, 'form-select', 'select');
157
+ select.id = 'invoice-default-issuer';
158
+ const none = box(select, '', 'option');
159
+ none.value = '';
160
+ none.textContent = 'Ask every time';
161
+ for (const issuer of issuers) {
162
+ const option = box(select, '', 'option');
163
+ option.value = issuer.id;
164
+ option.textContent = issuer.name || '(unnamed)';
165
+ }
166
+ select.value = settings.defaultIssuerId ?? '';
167
+
168
+ const bar = actionBar(panel);
169
+ bar.say('A new invoice opens as this issuer.');
170
+ select.addEventListener('change', async () => {
171
+ try {
172
+ await save({ defaultIssuerId: select.value });
173
+ bar.say('Saved.', 'ok');
174
+ } catch (err) {
175
+ bar.say(err?.message ?? 'Could not save.', 'bad');
176
+ }
177
+ });
178
+
179
+ const host = box(panel, '');
180
+ panels.push(mountPartyPanel(host, {
181
+ kind: 'issuer',
182
+ endpoint: `${BASE}/issuers`,
183
+ label: 'Issuer',
184
+ fields: ISSUER_FIELDS
185
+ }));
186
+ }
187
+
188
+ /** Receivers: who we bill, importable from Contacts. */
189
+ function renderReceivers(panel) {
190
+ note(panel, haveContacts
191
+ ? 'Who you bill. Import one from Contacts and its company name, billing address, VAT number and reference come with it.'
192
+ : 'Who you bill - company name, billing address and VAT number.');
193
+
194
+ const host = box(panel, '');
195
+ const party = mountPartyPanel(host, {
196
+ kind: 'receiver',
197
+ endpoint: `${BASE}/receivers`,
198
+ label: 'Receiver',
199
+ fields: RECEIVER_FIELDS
200
+ });
201
+ panels.push(party);
202
+
203
+ if (!haveContacts) return;
204
+
205
+ const importBtn = document.createElement('button');
206
+ importBtn.type = 'button';
207
+ importBtn.className = 'btn btn-sm btn-outline';
208
+ importBtn.textContent = 'Import from Contacts';
209
+ importBtn.addEventListener('click', () => openContactPicker({
210
+ title: 'Import a receiver',
211
+ onPick: async (contact) => {
212
+ try {
213
+ const profile = await billingProfile(contact.id);
214
+ // The billing profile uses the receiver's own field names,
215
+ // which is the whole point of that endpoint - it goes
216
+ // straight to POST /receivers with nothing to translate.
217
+ const res = await H.post(`${BASE}/receivers`, profile);
218
+ if (res?.error) throw new Error(res.error);
219
+ E.toast(`${profile.company || profile.name} imported.`, { type: 'success' });
220
+ await party.reload();
221
+ } catch (err) {
222
+ E.toast(err?.message ?? 'Could not import that contact.', { type: 'error' });
223
+ }
224
+ }
225
+ }));
226
+ host.querySelector('.invoice-panel-head')?.prepend(importBtn);
227
+ }
228
+
229
+ /** The values a new invoice starts with. */
230
+ function renderDefaults(panel) {
231
+ note(panel, 'What a new invoice starts with. Changing these never touches an invoice that already exists.');
232
+
233
+ const form = formIn(panel, {
234
+ defaultCurrency: { type: 'string', label: 'Currency', required: true,
235
+ formConfig: { hint: 'Three-letter ISO code - GBP, EUR, USD.' } },
236
+ defaultVatRate: { type: 'number', label: 'VAT rate (%)', min: 0, max: 100 },
237
+ vatEnabledByDefault: { type: 'boolean', label: 'VAT on by default' },
238
+ defaultPaymentTermsDays: { type: 'number', label: 'Payment terms (days)', min: 0, max: 365,
239
+ formConfig: { hint: 'Fills the due date on a new invoice. 0 leaves it blank.' } }
240
+ }, {
241
+ defaultCurrency: settings.defaultCurrency ?? 'GBP',
242
+ defaultVatRate: settings.defaultVatRate ?? 20,
243
+ vatEnabledByDefault: settings.vatEnabledByDefault !== false,
244
+ defaultPaymentTermsDays: settings.defaultPaymentTermsDays ?? 0
245
+ });
246
+
247
+ const bar = actionBar(panel);
248
+ const saveBtn = box(bar.row, 'btn btn-sm btn-primary', 'button');
249
+ saveBtn.type = 'button';
250
+ saveBtn.textContent = 'Save';
251
+ saveBtn.addEventListener('click', async () => {
252
+ if (!form.validate()) return;
253
+ bar.busy(true);
254
+ try {
255
+ await save(form.getData());
256
+ bar.say('Saved.', 'ok');
257
+ } catch (err) {
258
+ bar.say(err?.message ?? 'Could not save.', 'bad');
259
+ } finally {
260
+ bar.busy(false);
261
+ }
262
+ });
263
+ }
264
+
265
+ /** How invoice numbers are built. */
266
+ function renderNumbering(panel) {
267
+ note(panel, 'Numbers are allocated when an invoice is created, per prefix and per year, and never reused. Typing a number on an invoice yourself still wins.');
268
+
269
+ const form = formIn(panel, {
270
+ numberPrefix: { type: 'string', label: 'Prefix', required: true },
271
+ numberPadding: { type: 'number', label: 'Digits', min: 1, max: 10,
272
+ formConfig: { hint: 'The sequence is padded to this width.' } }
273
+ }, {
274
+ numberPrefix: settings.numberPrefix ?? 'INV',
275
+ numberPadding: settings.numberPadding ?? 4
276
+ });
277
+
278
+ const preview = box(panel, 'invoice-number-preview text-sm');
279
+
280
+ /** @returns {void} */
281
+ function paint() {
282
+ const values = form.getData();
283
+ const year = new Date().getFullYear();
284
+ const digits = Math.min(Math.max(Math.round(Number(values.numberPadding) || 1), 1), 10);
285
+ preview.textContent = `Next: ${values.numberPrefix || 'INV'}-${year}-${String(1).padStart(digits, '0')}`;
286
+ }
287
+ paint();
288
+ // Forma writes through the model on input, so reading getData() on the
289
+ // way past is enough - there is nothing to parse out of the DOM.
290
+ panel.addEventListener('input', paint);
291
+
292
+ const bar = actionBar(panel);
293
+ const saveBtn = box(bar.row, 'btn btn-sm btn-primary', 'button');
294
+ saveBtn.type = 'button';
295
+ saveBtn.textContent = 'Save';
296
+ saveBtn.addEventListener('click', async () => {
297
+ if (!form.validate()) return;
298
+ bar.busy(true);
299
+ try {
300
+ await save(form.getData());
301
+ bar.say('Saved.', 'ok');
302
+ } catch (err) {
303
+ bar.say(err?.message ?? 'Could not save.', 'bad');
304
+ } finally {
305
+ bar.busy(false);
306
+ }
307
+ });
308
+ }
309
+
310
+ /** The covering email, and the small print on the invoice itself. */
311
+ function renderEmail(panel) {
312
+ note(panel, 'Used when you send an invoice. {{number}}, {{issuer}}, {{total}} and {{dueDate}} are filled in per invoice.');
313
+
314
+ const form = formIn(panel, {
315
+ emailSubject: { type: 'string', label: 'Subject' },
316
+ emailIntro: { type: 'textarea', label: 'Covering note', formConfig: { rows: 3 } },
317
+ attachPdf: { type: 'boolean', label: 'Attach a PDF when the server can render one' },
318
+ footerNote: { type: 'textarea', label: 'Invoice footer', formConfig: { rows: 2,
319
+ hint: 'Printed at the foot of every invoice.' } }
320
+ }, {
321
+ emailSubject: settings.emailSubject ?? '',
322
+ emailIntro: settings.emailIntro ?? '',
323
+ attachPdf: settings.attachPdf !== false,
324
+ footerNote: settings.footerNote ?? ''
325
+ });
326
+
327
+ const bar = actionBar(panel);
328
+ const saveBtn = box(bar.row, 'btn btn-sm btn-primary', 'button');
329
+ saveBtn.type = 'button';
330
+ saveBtn.textContent = 'Save';
331
+ saveBtn.addEventListener('click', async () => {
332
+ if (!form.validate()) return;
333
+ bar.busy(true);
334
+ try {
335
+ await save(form.getData());
336
+ bar.say('Saved.', 'ok');
337
+ } catch (err) {
338
+ bar.say(err?.message ?? 'Could not save.', 'bad');
339
+ } finally {
340
+ bar.busy(false);
341
+ }
342
+ });
343
+ }
344
+
345
+ /** The house logo. */
346
+ function renderBranding(panel) {
347
+ note(panel, 'Printed at the top of every invoice. An issuer with a logo of its own uses that instead, so a single-company install sets this once and never thinks about it again.');
348
+
349
+ const frame = box(panel, 'invoice-logo-frame');
350
+ const img = box(frame, 'invoice-logo-preview', 'img');
351
+ const empty = box(frame, 'text-sm text-muted', 'span');
352
+
353
+ /** @param {string} url @returns {void} */
354
+ function show(url) {
355
+ if (url) {
356
+ img.src = url;
357
+ img.alt = 'Company logo';
358
+ img.classList.remove('invoice-hidden');
359
+ empty.textContent = '';
360
+ } else {
361
+ img.removeAttribute('src');
362
+ img.classList.add('invoice-hidden');
363
+ empty.textContent = 'No logo yet.';
364
+ }
365
+ }
366
+ show(settings.logoUrl ?? '');
367
+
368
+ const bar = actionBar(panel);
369
+ const chooseBtn = box(bar.row, 'btn btn-sm btn-primary', 'button');
370
+ chooseBtn.type = 'button';
371
+ chooseBtn.textContent = 'Upload or choose…';
372
+ chooseBtn.addEventListener('click', () => openMediaPicker(async (url) => {
373
+ bar.busy(true);
374
+ try {
375
+ await save({ logoUrl: url });
376
+ show(url);
377
+ bar.say(url ? 'Saved.' : 'Logo cleared.', 'ok');
378
+ } catch (err) {
379
+ bar.say(err?.message ?? 'Could not save.', 'bad');
380
+ } finally {
381
+ bar.busy(false);
382
+ }
383
+ }));
384
+ }
385
+
386
+ /**
387
+ * How a printed invoice is laid out, with a worked example of each.
388
+ *
389
+ * The preview is SAMPLE data rendered by the server in the real template,
390
+ * shown in an iframe. An iframe because a printed invoice is a whole
391
+ * document with its own stylesheet - dropped into the page it would fight
392
+ * the admin's CSS and show you something you will never receive.
393
+ */
394
+ async function renderTemplates(panel) {
395
+ note(panel, 'How a printed or emailed invoice is laid out. The preview is a worked example in your own name, not a picture - it is the same code that prints.');
396
+
397
+ let data;
398
+ try {
399
+ const res = await H.get(`${BASE}/templates`);
400
+ data = res.data ?? res;
401
+ } catch (err) {
402
+ const bad = box(panel, 'text-sm text-danger', 'p');
403
+ bad.textContent = err?.message ?? 'The templates could not be read.';
404
+ return;
405
+ }
406
+
407
+ let chosen = data.current || 'classic';
408
+ let withLogo = data.hasLogo;
409
+
410
+ const picker = box(panel, 'invoice-template-picker');
411
+ const frame = box(panel, 'invoice-template-preview', 'iframe');
412
+ frame.setAttribute('title', 'Invoice preview');
413
+ // No scripts, and nothing it could reach even if there were: the
414
+ // preview is for looking at.
415
+ frame.setAttribute('sandbox', '');
416
+
417
+ const bar = actionBar(panel);
418
+
419
+ const logoToggle = box(bar.row, 'invoice-check', 'label');
420
+ const logoBox = box(logoToggle, 'form-checkbox', 'input');
421
+ logoBox.type = 'checkbox';
422
+ logoBox.checked = withLogo;
423
+ logoBox.disabled = !data.hasLogo;
424
+ const logoText = box(logoToggle, '', 'span');
425
+ logoText.textContent = 'Show the logo';
426
+ logoToggle.setAttribute('data-help', data.hasLogo
427
+ ? 'Turn this off to see what an issuer with no logo gets - a layout built for having none, rather than this one with a gap where the logo would be.'
428
+ : 'There is no logo yet, so every invoice uses the no-logo layout. Upload one under Branding.');
429
+
430
+ const saveBtn = box(bar.row, 'btn btn-sm btn-primary', 'button');
431
+ saveBtn.type = 'button';
432
+ saveBtn.textContent = 'Use this template';
433
+
434
+ /**
435
+ * @param {string} id
436
+ * @returns {Promise<void>}
437
+ */
438
+ async function preview(id) {
439
+ frame.classList.add('is-loading');
440
+ try {
441
+ const res = await H.get(`${BASE}/templates/${encodeURIComponent(id)}/preview?logo=${logoBox.checked ? '1' : '0'}`);
442
+ const out = res.data ?? res;
443
+ if (out?.error) throw new Error(out.error);
444
+ // srcdoc, not a document.write into the frame: the frame is
445
+ // sandboxed, so it has no same-origin handle to write through.
446
+ frame.srcdoc = out.html;
447
+ } catch (err) {
448
+ bar.say(err?.message ?? 'That template could not be rendered.', 'bad');
449
+ } finally {
450
+ frame.classList.remove('is-loading');
451
+ }
452
+ }
453
+
454
+ /** @returns {void} */
455
+ function paintChoice() {
456
+ for (const card of picker.children) {
457
+ card.classList.toggle('is-chosen', card.dataset.template === chosen);
458
+ }
459
+ saveBtn.disabled = chosen === (data.current || 'classic');
460
+ saveBtn.textContent = saveBtn.disabled ? 'In use' : 'Use this template';
461
+ }
462
+
463
+ for (const template of data.templates) {
464
+ const card = box(picker, 'invoice-template-card', 'button');
465
+ card.type = 'button';
466
+ card.dataset.template = template.id;
467
+
468
+ const name = box(card, 'invoice-template-name', 'strong');
469
+ name.textContent = template.name;
470
+
471
+ const desc = box(card, 'invoice-template-desc', 'span');
472
+ desc.textContent = template.description;
473
+
474
+ if (!template.hasNoLogoVariant) {
475
+ const tag = box(card, 'badge badge-secondary invoice-template-tag', 'span');
476
+ tag.textContent = 'no logo';
477
+ tag.title = 'This style has nowhere to put a logo, by design.';
478
+ }
479
+
480
+ card.addEventListener('click', () => {
481
+ chosen = template.id;
482
+ paintChoice();
483
+ preview(template.id);
484
+ });
485
+ }
486
+
487
+ logoBox.addEventListener('change', () => preview(chosen));
488
+
489
+ saveBtn.addEventListener('click', async () => {
490
+ bar.busy(true);
491
+ try {
492
+ await save({ template: chosen });
493
+ data.current = chosen;
494
+ bar.say('Saved. New printouts use this layout.', 'ok');
495
+ } catch (err) {
496
+ bar.say(err?.message ?? 'Could not save.', 'bad');
497
+ } finally {
498
+ bar.busy(false);
499
+ paintChoice();
500
+ }
501
+ });
502
+
503
+ paintChoice();
504
+ await preview(chosen);
505
+ }
506
+
507
+ const sections = [
508
+ { id: 'issuers', label: 'Issuers', render: renderIssuers },
509
+ { id: 'receivers', label: 'Receivers', render: renderReceivers },
510
+ { id: 'defaults', label: 'Defaults', render: renderDefaults },
511
+ { id: 'numbering', label: 'Numbering', render: renderNumbering },
512
+ { id: 'email', label: 'Email', render: renderEmail },
513
+ { id: 'branding', label: 'Branding', render: renderBranding },
514
+ // After Branding: a template is chosen by looking at it, and what it
515
+ // looks like depends on whether there is a logo yet.
516
+ { id: 'templates', label: 'Templates', render: renderTemplates }
517
+ ];
518
+
519
+ // ---- the panel ------------------------------------------------------
520
+
521
+ const slideover = E.slideover({
522
+ title: 'Invoice Settings',
523
+ size: 'lg',
524
+ position: 'right',
525
+ onClose: () => setTimeout(() => {
526
+ stopResize();
527
+ for (const panel of panels) panel.dispose();
528
+ slideover.destroy();
529
+ slideover.element?.remove();
530
+ }, 350)
531
+ });
532
+
533
+ // A table of issuers wants a different width to a two-field form, and which
534
+ // one someone is looking at changes by the minute - so the panel is dragged
535
+ // rather than sized here, and the width is remembered.
536
+ const stopResize = makeResizable(slideover, {
537
+ storageKey: 'domma-invoice.settings-width',
538
+ defaultWidth: 780,
539
+ handleClass: 'invoice-slideover-resizer'
540
+ });
541
+
542
+ const host = document.createElement('div');
543
+ host.className = 'invoice-settings';
544
+
545
+ const tabs = box(host, 'tabs');
546
+ const list = box(tabs, 'tab-list');
547
+ const drawn = [];
548
+
549
+ const startAt = Math.max(0, sections.findIndex(s => s.id === tab));
550
+
551
+ sections.forEach((section, index) => {
552
+ const trigger = box(list, 'tab-item' + (index === startAt ? ' active' : ''), 'button');
553
+ trigger.type = 'button';
554
+ trigger.textContent = section.label;
555
+
556
+ const panel = box(tabs, 'tab-panel' + (index === startAt ? ' active' : ''));
557
+ drawn.push({ section, panel, done: false });
558
+
559
+ trigger.addEventListener('click', () => {
560
+ const entry = drawn[index];
561
+ if (entry.done) return;
562
+ entry.done = true;
563
+ draw(entry);
564
+ });
565
+ });
566
+
567
+ /**
568
+ * Draw one section, tolerating one that throws.
569
+ *
570
+ * @param {object} entry
571
+ * @returns {void}
572
+ */
573
+ async function draw(entry) {
574
+ entry.panel.textContent = '';
575
+ try {
576
+ // Awaited: the Templates section fetches before it can draw, and an
577
+ // unawaited rejection here would be an unhandled one rather than
578
+ // the message below.
579
+ await entry.section.render(entry.panel);
580
+ } catch (err) {
581
+ const message = box(entry.panel, 'text-sm text-danger', 'p');
582
+ message.textContent = err?.message || 'This section could not be loaded.';
583
+ }
584
+ Domma.icons.scan(entry.panel);
585
+ scanHelp(entry.panel);
586
+ }
587
+
588
+ slideover.element.appendChild(host);
589
+ // `active` is which tab to open on; E.tabs owns it from here.
590
+ E.tabs(tabs, { active: startAt });
591
+ drawn[startAt].done = true;
592
+ draw(drawn[startAt]);
593
+ slideover.open();
594
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Tearing a view's reactive graph down when the router replaces it.
3
+ *
4
+ * The admin has no unmount hook, so the only signal available is Domma's
5
+ * `router:afterChange`. That event also fires for the navigation that MOUNTED
6
+ * this view - subscribing in onMount and disposing on the first event kills the
7
+ * bindings the instant they were applied. It does not look like a crash: values
8
+ * written during activation stay on screen, so the screen reads correctly and
9
+ * nothing updates again, and a `data-each` whose item template was lifted at
10
+ * activation renders no rows at all.
11
+ *
12
+ * Keying on whether a node the view owns is still in the document is what
13
+ * separates "we have just arrived" from "we have been replaced": the router
14
+ * swaps the container's contents, so our node is detached exactly once.
15
+ */
16
+
17
+ /**
18
+ * @param {Element} owned a node this view put in the container
19
+ * @param {...Function} disposers M.effect disposers, or handles with dispose()
20
+ * @returns {void}
21
+ */
22
+ export function disposeOnLeave(owned, ...disposers) {
23
+ const stop = M.subscribe('router:afterChange', () => {
24
+ if (owned && owned.isConnected) return;
25
+ for (const d of disposers) {
26
+ try {
27
+ if (typeof d === 'function') d();
28
+ else d?.dispose?.();
29
+ } catch { /* already torn down */ }
30
+ }
31
+ if (typeof stop === 'function') stop();
32
+ });
33
+ }