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
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Invoice plugin - recording a payment.
3
+ *
4
+ * The ledger has its own routes rather than riding on PUT, which is what keeps
5
+ * the stored status reconciled with what has actually been paid; this dialog is
6
+ * the admin's way in. Same shape as the Send dialog: F.create for the form,
7
+ * light-DOM projection into the slideover, and the panel detached on close.
8
+ */
9
+ const BASE = '/api/plugins/invoice';
10
+
11
+ export const PAYMENT_METHODS = {
12
+ 'bank-transfer': 'Bank transfer',
13
+ 'card': 'Card',
14
+ 'cash': 'Cash',
15
+ 'cheque': 'Cheque',
16
+ 'direct-debit': 'Direct debit',
17
+ 'other': 'Other'
18
+ };
19
+
20
+ /**
21
+ * @param {string} id invoice id
22
+ * @param {number} balance what is still outstanding, prefilled as the amount
23
+ * @param {Function} [onSaved] called with the updated invoice
24
+ * @returns {void}
25
+ */
26
+ export function openPaymentDialog(id, balance, onSaved) {
27
+ const form = F.create({
28
+ amount: {
29
+ type: 'number', label: 'Amount', required: true,
30
+ formConfig: { hint: 'Part payments are fine - the balance follows.' }
31
+ },
32
+ // Deliberately NO `type`.
33
+ //
34
+ // `type: 'date'` renders the right control and then makes the form
35
+ // impossible to submit: Forma maps it to <input type="date">, which
36
+ // writes a STRING back, while M.types.date accepts only a real Date
37
+ // instance - so validate() fails with "Expected type date" every time
38
+ // and the Record button silently does nothing. Declaring no type skips
39
+ // the type check entirely (models.js only checks when def.type is set),
40
+ // and the input is given its type below, after render.
41
+ date: { label: 'Date received', required: true },
42
+ method: {
43
+ type: 'select', label: 'Method',
44
+ options: Object.entries(PAYMENT_METHODS).map(([value, label]) => ({ value, label }))
45
+ },
46
+ reference: {
47
+ type: 'string', label: 'Reference',
48
+ formConfig: { placeholder: 'FT12345', hint: 'Whatever lets you find it on a statement.' }
49
+ }
50
+ }, {
51
+ amount: balance > 0 ? balance : '',
52
+ date: new Date().toISOString().slice(0, 10),
53
+ method: 'bank-transfer',
54
+ reference: ''
55
+ }, { showSubmitButton: false });
56
+
57
+ const wrapper = document.createElement('div');
58
+ wrapper.className = 'invoice-party-form';
59
+ const fields = document.createElement('div');
60
+ form.renderTo(fields);
61
+ // Domma's calendar, not `<input type="date">`: one control in every
62
+ // browser, and it writes ISO back through the input, which is what
63
+ // form.getData() reads. A payment cannot be received in the future.
64
+ const dateInput = fields.querySelector('[name="date"]');
65
+ const datePicker = dateInput && E.datePicker(dateInput, {
66
+ max: new Date().toISOString().slice(0, 10)
67
+ });
68
+ wrapper.appendChild(fields);
69
+
70
+ const actions = document.createElement('div');
71
+ actions.className = 'invoice-party-actions';
72
+ const cancelBtn = document.createElement('button');
73
+ cancelBtn.type = 'button';
74
+ cancelBtn.className = 'btn btn-ghost';
75
+ cancelBtn.textContent = 'Cancel';
76
+ const saveBtn = document.createElement('button');
77
+ saveBtn.type = 'button';
78
+ saveBtn.className = 'btn btn-primary';
79
+ saveBtn.textContent = 'Record payment';
80
+ actions.appendChild(cancelBtn);
81
+ actions.appendChild(saveBtn);
82
+ wrapper.appendChild(actions);
83
+
84
+ const slideover = E.slideover({
85
+ title: 'Record Payment',
86
+ size: 'md',
87
+ position: 'right',
88
+ onClose: () => setTimeout(() => {
89
+ datePicker?.destroy?.();
90
+ slideover.destroy();
91
+ slideover.element?.remove();
92
+ }, 350)
93
+ });
94
+ slideover.element.appendChild(wrapper);
95
+
96
+ cancelBtn.addEventListener('click', () => slideover.close());
97
+
98
+ saveBtn.addEventListener('click', async () => {
99
+ if (!form.validate()) return;
100
+ const values = form.getData();
101
+ if (!(Number(values.amount) > 0)) {
102
+ E.toast('The amount has to be more than zero.', { type: 'error' });
103
+ return;
104
+ }
105
+ saveBtn.disabled = true;
106
+ try {
107
+ const res = await H.post(`${BASE}/invoices/${id}/payments`, {
108
+ amount: Number(values.amount),
109
+ date: values.date,
110
+ method: values.method,
111
+ reference: values.reference
112
+ });
113
+ const out = res?.data ?? res;
114
+ if (out?.error) throw new Error(out.error);
115
+ slideover.close();
116
+ E.toast(out.totals?.balance <= 0 ? 'Payment recorded - this invoice is settled.' : 'Payment recorded.',
117
+ { type: 'success' });
118
+ if (typeof onSaved === 'function') onSaved(out);
119
+ } catch (err) {
120
+ E.toast(err?.message ?? 'Could not record the payment.', { type: 'error' });
121
+ saveBtn.disabled = false;
122
+ }
123
+ });
124
+
125
+ slideover.open();
126
+ Domma.icons.scan(wrapper);
127
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Invoice plugin - opening the printable invoice.
3
+ *
4
+ * `authenticate` is Bearer-only (see server/middleware/auth.js), so a plain
5
+ * <a href target="_blank"> to /invoices/:id/print arrives with no Authorization
6
+ * header and comes back 401 every time. The markup is fetched through H, which
7
+ * the admin's http-interceptor tokenises, and written into a window opened
8
+ * SYNCHRONOUSLY on the click - a window opened after an await counts as
9
+ * unsolicited and the pop-up blocker eats it.
10
+ */
11
+ const BASE = '/api/plugins/invoice';
12
+
13
+ const PLACEHOLDER = '<!doctype html><html><head><title>Invoice</title></head>' +
14
+ '<body style="font:14px system-ui,sans-serif;padding:2rem;color:#555;">Preparing invoice…</body></html>';
15
+
16
+ /**
17
+ * @param {string} id invoice id
18
+ * @param {boolean} [auto] have the printout raise the print dialog itself
19
+ * @returns {Promise<void>}
20
+ */
21
+ export async function openPrintWindow(id, auto = false) {
22
+ const win = window.open('', '_blank');
23
+ if (!win) {
24
+ E.toast('Allow pop-ups for this site to print an invoice.', { type: 'error' });
25
+ return;
26
+ }
27
+ win.document.write(PLACEHOLDER);
28
+
29
+ try {
30
+ const res = await H.get(`${BASE}/invoices/${id}/print-html${auto ? '?print=1' : ''}`);
31
+ const html = (res?.data ?? res)?.html;
32
+ if (!html) throw new Error('The server returned an empty invoice.');
33
+ win.document.open();
34
+ win.document.write(html);
35
+ win.document.close();
36
+ } catch (err) {
37
+ win.close();
38
+ E.toast(err?.message ?? 'Could not open the printout.', { type: 'error' });
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Does this host have a renderer? Asked once and remembered - the answer cannot
44
+ * change without a server restart, and both views ask on every mount.
45
+ *
46
+ * @returns {Promise<boolean>}
47
+ */
48
+ let pdfAvailable = null;
49
+
50
+ export async function canDownloadPdf() {
51
+ if (pdfAvailable !== null) return pdfAvailable;
52
+ try {
53
+ const res = await H.get(`${BASE}/pdf/status`);
54
+ pdfAvailable = Boolean((res?.data ?? res)?.available);
55
+ } catch {
56
+ pdfAvailable = false;
57
+ }
58
+ return pdfAvailable;
59
+ }
60
+
61
+ /**
62
+ * Download the server-rendered PDF.
63
+ *
64
+ * A download is a NAVIGATION, so it cannot carry the admin's bearer token - the
65
+ * same reason the Print button could not link straight at /print. The server
66
+ * mints a two-minute token bound to this one invoice and puts it in the query
67
+ * string, and an <a download> click is what turns that URL into a file without
68
+ * taking the SPA off its route.
69
+ *
70
+ * @param {string} id
71
+ * @returns {Promise<void>}
72
+ */
73
+ export async function downloadPdf(id) {
74
+ try {
75
+ const res = await H.post(`${BASE}/invoices/${id}/pdf-link`, {});
76
+ const link = res?.data ?? res;
77
+ if (!link?.url) throw new Error(link?.error || 'No download link was returned.');
78
+
79
+ const a = document.createElement('a');
80
+ a.href = link.url;
81
+ a.download = link.filename || 'invoice.pdf';
82
+ a.rel = 'noopener';
83
+ document.body.appendChild(a);
84
+ a.click();
85
+ a.remove();
86
+ E.toast('Preparing your PDF…', { type: 'info' });
87
+ } catch (err) {
88
+ E.toast(err?.message ?? 'Could not build the PDF.', { type: 'error' });
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Download one PDF holding every invoice given, a page each.
94
+ *
95
+ * Same two-minute token dance as the single download, for the same reason - a
96
+ * download is a navigation and carries no bearer token. The ids are POSTed
97
+ * rather than put in the query string: the server keeps the list and the URL
98
+ * carries only a claim ticket, so the link stays short whether it is three
99
+ * invoices or a hundred.
100
+ *
101
+ * The toast is not decoration. Chrome lays out every sheet before the first
102
+ * byte arrives, so a large batch is several seconds during which an <a> click
103
+ * looks exactly like a button that did nothing.
104
+ *
105
+ * @param {string[]} ids
106
+ * @returns {Promise<void>}
107
+ */
108
+ export async function downloadPdfBatch(ids) {
109
+ if (!ids.length) {
110
+ E.toast('There are no invoices to export.', { type: 'info' });
111
+ return;
112
+ }
113
+
114
+ try {
115
+ E.toast(`Building a PDF of ${ids.length} invoice${ids.length === 1 ? '' : 's'}…`, { type: 'info' });
116
+ const res = await H.post(`${BASE}/invoices/batch-pdf-link`, { ids });
117
+ const link = res?.data ?? res;
118
+ if (!link?.url) throw new Error(link?.error || 'No download link was returned.');
119
+
120
+ const a = document.createElement('a');
121
+ a.href = link.url;
122
+ a.download = link.filename || 'invoices.pdf';
123
+ a.rel = 'noopener';
124
+ document.body.appendChild(a);
125
+ a.click();
126
+ a.remove();
127
+ } catch (err) {
128
+ E.toast(err?.message ?? 'Could not build the PDF.', { type: 'error' });
129
+ }
130
+ }
@@ -1,22 +1,55 @@
1
1
  /**
2
- * Invoice plugin - receivers CRUD view.
2
+ * Invoice plugin - receivers screen.
3
+ *
4
+ * The same panel the settings slideover puts in its Receivers tab, plus the
5
+ * Contacts import - a receiver is a contact's billing side, and Contacts
6
+ * already holds the company name, the billing address and the VAT number.
3
7
  */
4
- import {mountPartyView} from './party-view.js?v=1';
8
+ import {RECEIVER_FIELDS, mountPartyPanel} from './party-view.js?v=8';
9
+ import {billingProfile, contactsAvailable, openContactPicker} from './contacts-source.js?v=8';
10
+ import {disposeOnLeave} from './view-lifecycle.js?v=8';
5
11
 
6
12
  export const receiversView = {
7
13
  templateUrl: '/plugins/invoice/admin/templates/receivers.html',
8
- onMount: ($container) => mountPartyView($container, {
9
- kind: 'receiver',
10
- endpoint: '/api/plugins/invoice/receivers',
11
- label: 'Receiver',
12
- fields: [
13
- { key: 'name', label: 'Contact Name', required: true, type: 'text' },
14
- { key: 'company', label: 'Company', type: 'text' },
15
- { key: 'email', label: 'Email', type: 'text' },
16
- { key: 'phone', label: 'Phone', type: 'text' },
17
- { key: 'vatNumber', label: 'VAT Number', type: 'text' },
18
- { key: 'reference', label: 'Reference', type: 'text' },
19
- { key: 'address', label: 'Address', type: 'textarea' }
20
- ]
21
- })
14
+
15
+ async onMount($container) {
16
+ const host = $container.find('#party-host').get(0);
17
+ if (!host) return;
18
+
19
+ const panel = mountPartyPanel(host, {
20
+ kind: 'receiver',
21
+ endpoint: '/api/plugins/invoice/receivers',
22
+ label: 'Receiver',
23
+ fields: RECEIVER_FIELDS
24
+ });
25
+
26
+ disposeOnLeave(host, panel.dispose);
27
+
28
+ if (await contactsAvailable()) {
29
+ const importBtn = document.createElement('button');
30
+ importBtn.type = 'button';
31
+ importBtn.className = 'btn btn-sm btn-outline';
32
+ importBtn.textContent = 'Import from Contacts';
33
+ importBtn.addEventListener('click', () => openContactPicker({
34
+ title: 'Import a receiver',
35
+ onPick: async (contact) => {
36
+ try {
37
+ // The billing profile uses the receiver's own field
38
+ // names, which is the point of that endpoint - it goes
39
+ // straight to POST /receivers with nothing to translate.
40
+ const profile = await billingProfile(contact.id);
41
+ const res = await H.post('/api/plugins/invoice/receivers', profile);
42
+ if (res?.error) throw new Error(res.error);
43
+ E.toast(`${profile.company || profile.name} imported.`, { type: 'success' });
44
+ await panel.reload();
45
+ } catch (err) {
46
+ E.toast(err?.message ?? 'Could not import that contact.', { type: 'error' });
47
+ }
48
+ }
49
+ }));
50
+ host.querySelector('.invoice-panel-head')?.prepend(importBtn);
51
+ }
52
+
53
+ Domma.icons.scan($container.get(0));
54
+ }
22
55
  };
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Invoice plugin - the Send dialog.
3
+ *
4
+ * The defaults (recipient, subject, covering note, whether a PDF can be
5
+ * attached) come from the server rather than being templated here, so the
6
+ * settings that shape the email have one reader.
7
+ *
8
+ * Recipients can also come from Contacts - one contact, or everybody in a
9
+ * group. They are appended to the To field as text rather than held in a
10
+ * parallel list, because the field has to stay typeable: half the time the
11
+ * address someone wants is an accounts inbox that is in nobody's contacts.
12
+ *
13
+ * The form is F.create - schema first, then .renderTo() - and the panel is
14
+ * projected into the slideover's light DOM, because setContent() sanitises and
15
+ * would strip the inputs.
16
+ */
17
+ import {addressesFor, contactsAvailable, openContactPicker} from './contacts-source.js?v=8';
18
+
19
+ const BASE = '/api/plugins/invoice';
20
+
21
+ /**
22
+ * Merge new addresses into a comma-separated To field, without duplicates.
23
+ *
24
+ * @param {string} current
25
+ * @param {string[]} extra
26
+ * @returns {string}
27
+ */
28
+ export function mergeRecipients(current, extra) {
29
+ const seen = new Set();
30
+ const out = [];
31
+ for (const item of [...String(current ?? '').split(/[,;]/), ...extra]) {
32
+ const address = String(item ?? '').trim();
33
+ if (!address || seen.has(address.toLowerCase())) continue;
34
+ seen.add(address.toLowerCase());
35
+ out.push(address);
36
+ }
37
+ return out.join(', ');
38
+ }
39
+
40
+ /**
41
+ * @param {string} id invoice id
42
+ * @param {Function} [onSent] called with the updated invoice
43
+ * @returns {Promise<void>}
44
+ */
45
+ export async function openSendDialog(id, onSent) {
46
+ let defaults;
47
+ try {
48
+ const res = await H.get(`${BASE}/invoices/${id}/send-defaults`);
49
+ defaults = res?.data ?? res;
50
+ if (defaults?.error) throw new Error(defaults.error);
51
+ } catch (err) {
52
+ E.toast(err?.message ?? 'Could not prepare the email.', { type: 'error' });
53
+ return;
54
+ }
55
+
56
+ const haveContacts = await contactsAvailable();
57
+
58
+ const schema = {
59
+ to: { type: 'string', label: 'To', required: true,
60
+ formConfig: { placeholder: 'name@example.com',
61
+ hint: defaults.to
62
+ ? 'Separate several addresses with commas.'
63
+ : 'This receiver has no email address on file.' } },
64
+ subject: { type: 'string', label: 'Subject', required: true },
65
+ message: { type: 'textarea', label: 'Covering note',
66
+ formConfig: { rows: 3, hint: 'Sent above the invoice summary.' } }
67
+ };
68
+ if (defaults.canAttachPdf) {
69
+ schema.attachPdf = { type: 'boolean', label: 'Attach a PDF',
70
+ formConfig: { hint: 'Without it the invoice is sent inline instead.' } };
71
+ }
72
+
73
+ const form = F.create(schema, {
74
+ to: defaults.to || '',
75
+ subject: defaults.subject || '',
76
+ message: defaults.message || '',
77
+ attachPdf: Boolean(defaults.attachPdf)
78
+ }, { showSubmitButton: false });
79
+
80
+ const wrapper = document.createElement('div');
81
+ wrapper.className = 'invoice-party-form';
82
+
83
+ if (defaults.sendCount) {
84
+ const trail = document.createElement('p');
85
+ trail.className = 'text-muted text-sm mb-0';
86
+ const when = defaults.sentAt ? D(defaults.sentAt).format('DD MMM YYYY HH:mm') : 'previously';
87
+ trail.textContent = `Last sent ${when} (${defaults.sendCount} ${defaults.sendCount === 1 ? 'time' : 'times'}).`;
88
+ wrapper.appendChild(trail);
89
+ }
90
+
91
+ const fields = document.createElement('div');
92
+ form.renderTo(fields);
93
+ wrapper.appendChild(fields);
94
+
95
+ // Contacts, appended to whatever is already in To. The value goes back
96
+ // through the MODEL as well as the input: Forma reads getData() off the
97
+ // model, so an input written behind its back would send the old address.
98
+ if (haveContacts) {
99
+ const toInput = fields.querySelector('[name="to"]');
100
+ if (toInput) {
101
+ const row = document.createElement('div');
102
+ row.className = 'invoice-recipient-actions';
103
+
104
+ /**
105
+ * @param {object[]} contacts
106
+ * @returns {void}
107
+ */
108
+ const add = (contacts) => {
109
+ const found = contacts.flatMap((c) => addressesFor(c).slice(0, 1));
110
+ const missing = contacts.filter((c) => addressesFor(c).length === 0);
111
+ if (!found.length) {
112
+ E.toast('None of those contacts has an email address.', { type: 'error' });
113
+ return;
114
+ }
115
+ const merged = mergeRecipients(form.getData().to, found);
116
+ form.setData({ to: merged });
117
+ toInput.value = merged;
118
+ if (missing.length) {
119
+ E.toast(`${missing.length} contact(s) had no email address and were skipped.`,
120
+ { type: 'warning' });
121
+ }
122
+ };
123
+
124
+ const one = document.createElement('button');
125
+ one.type = 'button';
126
+ one.className = 'btn btn-sm btn-outline';
127
+ one.textContent = 'Add a contact';
128
+ one.addEventListener('click', () => openContactPicker({
129
+ title: 'Add a recipient',
130
+ onPick: (contact) => add([contact])
131
+ }));
132
+ row.appendChild(one);
133
+
134
+ const many = document.createElement('button');
135
+ many.type = 'button';
136
+ many.className = 'btn btn-sm btn-outline';
137
+ many.textContent = 'Add a group';
138
+ many.title = 'Filter by group, then Select all shown';
139
+ many.addEventListener('click', () => openContactPicker({
140
+ title: 'Add recipients',
141
+ multiple: true,
142
+ onPick: (contacts) => add(contacts)
143
+ }));
144
+ row.appendChild(many);
145
+
146
+ // Under the field it belongs to, whatever Forma wrapped it in.
147
+ (toInput.closest('.domma-form-field') || toInput.parentElement || wrapper).appendChild(row);
148
+ }
149
+ }
150
+
151
+ const actions = document.createElement('div');
152
+ actions.className = 'invoice-party-actions';
153
+ const cancelBtn = document.createElement('button');
154
+ cancelBtn.type = 'button';
155
+ cancelBtn.className = 'btn btn-ghost';
156
+ cancelBtn.textContent = 'Cancel';
157
+ const sendBtn = document.createElement('button');
158
+ sendBtn.type = 'button';
159
+ sendBtn.className = 'btn btn-primary';
160
+ sendBtn.textContent = 'Send';
161
+ actions.appendChild(cancelBtn);
162
+ actions.appendChild(sendBtn);
163
+ wrapper.appendChild(actions);
164
+
165
+ const slideover = E.slideover({
166
+ title: 'Send Invoice',
167
+ size: 'md',
168
+ position: 'right',
169
+ onClose: () => setTimeout(() => {
170
+ slideover.destroy();
171
+ slideover.element?.remove();
172
+ }, 350)
173
+ });
174
+ slideover.element.appendChild(wrapper);
175
+
176
+ cancelBtn.addEventListener('click', () => slideover.close());
177
+
178
+ sendBtn.addEventListener('click', async () => {
179
+ if (!form.validate()) return;
180
+ const values = form.getData();
181
+
182
+ // Sending is slow (rendering the PDF is about a second) and it is not
183
+ // repeatable - say so, and make a second click impossible.
184
+ sendBtn.disabled = true;
185
+ cancelBtn.disabled = true;
186
+ const original = sendBtn.textContent;
187
+ sendBtn.textContent = 'Sending…';
188
+
189
+ try {
190
+ const res = await H.post(`${BASE}/invoices/${id}/send`, {
191
+ to: values.to,
192
+ subject: values.subject,
193
+ message: values.message,
194
+ attachPdf: Boolean(values.attachPdf)
195
+ });
196
+ const out = res?.data ?? res;
197
+ if (out?.error) throw new Error(out.error);
198
+ slideover.close();
199
+ E.toast(out.attached ? `Sent to ${out.to} with the PDF attached.` : `Sent to ${out.to}.`,
200
+ { type: 'success' });
201
+ if (typeof onSent === 'function') onSent(out.invoice);
202
+ } catch (err) {
203
+ E.toast(err?.message ?? 'The invoice could not be sent.', { type: 'error' });
204
+ sendBtn.disabled = false;
205
+ cancelBtn.disabled = false;
206
+ sendBtn.textContent = original;
207
+ }
208
+ });
209
+
210
+ slideover.open();
211
+ Domma.icons.scan(wrapper);
212
+ }