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,51 +1,213 @@
1
1
  /**
2
2
  * Invoice plugin - editor view. Create or edit a single invoice.
3
+ *
4
+ * The screen is a reactive graph rather than a render function: one observable
5
+ * per field, one computed per derived number, and a single M.applyBindings to
6
+ * connect them to the markup. Nothing here writes to the DOM.
7
+ *
8
+ * Line items are objects of observables rather than plain objects in a Model
9
+ * field, because `data-model="lineItems[0].quantity"` would mutate that object
10
+ * in place and the field's observable would never fire - the documented trap in
11
+ * docs/Bindings.md. Per-field observables make every keystroke a tracked write,
12
+ * so the line total, the subtotal, the VAT and the total all follow it.
3
13
  */
4
- function escapeHtml(s) {
5
- return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
14
+ import {canDownloadPdf, downloadPdf, openPrintWindow} from './print.js?v=9';
15
+ import {disposeOnLeave} from './view-lifecycle.js?v=8';
16
+ import {openSendDialog} from './send.js?v=8';
17
+ import {openPaymentDialog, PAYMENT_METHODS} from './payments.js?v=8';
18
+ import {billingProfile, contactsAvailable, openContactPicker} from './contacts-source.js?v=8';
19
+ import {INVOICE_HELP} from './help-content.js?v=8';
20
+ import {openHelpPanel, scanHelp} from '/plugins/_lib/admin/ui/help.js?v=8';
21
+
22
+ const BASE = '/api/plugins/invoice';
23
+
24
+ function round2(n) {
25
+ return Math.round((Number(n) + Number.EPSILON) * 100) / 100;
6
26
  }
7
27
 
8
- function fmt(amount, currency) {
28
+ function money(amount, currency) {
9
29
  try {
10
30
  return new Intl.NumberFormat('en-GB', { style: 'currency', currency: currency || 'GBP' }).format(Number(amount) || 0);
11
31
  } catch {
12
- return `${currency || 'GBP'} ${Number(amount).toFixed(2)}`;
32
+ return `${currency || 'GBP'} ${(Number(amount) || 0).toFixed(2)}`;
13
33
  }
14
34
  }
15
35
 
16
- function round2(n) {
17
- return Math.round((Number(n) + Number.EPSILON) * 100) / 100;
36
+ /** Ids only have to be unique within this mount - they key the rendered rows. */
37
+ let lineSeq = 0;
38
+
39
+ /**
40
+ * One line item as a small reactive record.
41
+ *
42
+ * @param {object} item `{description, quantity, unitPrice}`
43
+ * @param {object} currency the form's currency observable
44
+ * @returns {object} observables plus the two computeds the row renders
45
+ */
46
+ function makeLine(item, currency) {
47
+ const line = {
48
+ id: `line-${++lineSeq}`,
49
+ description: M.observable(item?.description ?? ''),
50
+ quantity: M.observable(Number(item?.quantity) || 0),
51
+ unitPrice: M.observable(Number(item?.unitPrice) || 0)
52
+ };
53
+ line.lineTotal = M.computed(() => round2((Number(line.quantity.value) || 0) * (Number(line.unitPrice.value) || 0)));
54
+ line.lineTotalText = M.computed(() => money(line.lineTotal.value, currency.value));
55
+ return line;
18
56
  }
19
57
 
20
- const BASE = '/api/plugins/invoice';
58
+ /**
59
+ * One recorded payment as a row. The stored fields never change - the ledger is
60
+ * append/remove only - so the computeds exist for the one thing that can move
61
+ * underneath them: the invoice's currency.
62
+ *
63
+ * @param {object} payment
64
+ * @param {object} currency the form's currency observable
65
+ * @returns {object}
66
+ */
67
+ function makePaymentRow(payment, currency) {
68
+ const row = {
69
+ id: payment.id,
70
+ reference: payment.reference || '-',
71
+ amount: Number(payment.amount) || 0,
72
+ date: payment.date || '',
73
+ method: payment.method || 'other'
74
+ };
75
+ row.amountText = M.computed(() => money(row.amount, currency.value));
76
+ row.dateText = row.date ? D(row.date).format('DD MMM YYYY') : '-';
77
+ row.methodText = PAYMENT_METHODS[row.method] || row.method;
78
+ return row;
79
+ }
21
80
 
22
81
  export const editorView = {
23
82
  templateUrl: '/plugins/invoice/admin/templates/editor.html',
24
83
 
25
84
  async onMount($container) {
26
- const id = R.current?.params?.id ?? null;
85
+ // Domma's router hands onMount ONE argument and never the route params,
86
+ // and `R.current` is a METHOD - `R.current?.params` is undefined on the
87
+ // function object. Read the slug off the hash, as every core editor does.
88
+ const routeMatch = location.hash.match(/^#\/plugins\/invoice\/edit\/([^?#]+)/);
89
+ const id = routeMatch ? decodeURIComponent(routeMatch[1]) : null;
27
90
  const isEdit = Boolean(id);
28
- let invoice = null;
29
- let issuers = [];
30
- let receivers = [];
31
- let lineItems = [];
91
+ const today = new Date().toISOString().slice(0, 10);
92
+
93
+ const form = {
94
+ isEdit: M.observable(isEdit),
95
+ canPdf: M.observable(false),
96
+ canImport: M.observable(false),
97
+ number: M.observable(''),
98
+ status: M.observable('draft'),
99
+ issueDate: M.observable(today),
100
+ dueDate: M.observable(''),
101
+ issuerId: M.observable(''),
102
+ receiverId: M.observable(''),
103
+ currency: M.observable('GBP'),
104
+ vatEnabled: M.observable(true),
105
+ vatRate: M.observable(20),
106
+ notes: M.observable(''),
107
+ issuers: M.observable([]),
108
+ receivers: M.observable([]),
109
+ lineItems: M.observableArray([]),
110
+ payments: M.observableArray([])
111
+ };
112
+
113
+ // Derived money. Each reads only what it needs, so a keystroke in one
114
+ // row's quantity re-runs that row's total and the three below it - not
115
+ // the other rows, and not the selects.
116
+ form.subtotal = M.computed(() => round2(form.lineItems.value.reduce((sum, line) => sum + line.lineTotal.value, 0)));
117
+ form.vat = M.computed(() => (form.vatEnabled.value ? round2(form.subtotal.value * (Number(form.vatRate.value) || 0) / 100) : 0));
118
+ form.total = M.computed(() => round2(form.subtotal.value + form.vat.value));
119
+ form.subtotalText = M.computed(() => money(form.subtotal.value, form.currency.value));
120
+ form.vatText = M.computed(() => money(form.vat.value, form.currency.value));
121
+ form.totalText = M.computed(() => money(form.total.value, form.currency.value));
122
+
123
+ // The ledger. `paid` is summed here rather than taken from the server's
124
+ // totals so the balance follows a line item being edited, live, before
125
+ // anything is saved.
126
+ form.paid = M.computed(() => round2(form.payments.value.reduce((sum, p) => sum + p.amount, 0)));
127
+ form.balance = M.computed(() => round2(form.total.value - form.paid.value));
128
+ form.paidText = M.computed(() => money(form.paid.value, form.currency.value));
129
+ form.balanceText = M.computed(() => money(Math.max(0, form.balance.value), form.currency.value));
130
+ form.hasPayments = M.computed(() => form.payments.value.length > 0);
131
+ form.noPayments = M.computed(() => form.payments.value.length === 0);
132
+ form.settled = M.computed(() => form.total.value > 0 && form.balance.value <= 0);
133
+ form.balanceLabel = M.computed(() => (form.settled.value ? 'Paid in full' : 'Balance due'));
134
+
135
+ // ---- data -----------------------------------------------------------
136
+
137
+ /**
138
+ * Reload the receiver list and keep the current selection.
139
+ *
140
+ * Rebuilding a select's options resets it, so the value is put back -
141
+ * which is also how a freshly imported receiver becomes the selected
142
+ * one without a second click.
143
+ *
144
+ * @param {string} [select] id to select afterwards
145
+ * @returns {Promise<void>}
146
+ */
147
+ async function loadReceivers(select) {
148
+ const res = await H.get(`${BASE}/receivers`);
149
+ const receivers = res.data ?? res ?? [];
150
+ form.receivers.value = receivers.map((r) => ({ id: r.id, label: r.company || r.name || '(unnamed)' }));
151
+ if (select) form.receiverId.value = select;
152
+ }
32
153
 
33
154
  try {
34
155
  const [issRes, recRes] = await Promise.all([
35
156
  H.get(`${BASE}/issuers`),
36
157
  H.get(`${BASE}/receivers`)
37
158
  ]);
38
- issuers = issRes.data ?? issRes ?? [];
39
- receivers = recRes.data ?? recRes ?? [];
159
+ const issuers = (issRes.data ?? issRes ?? []);
160
+ const receivers = (recRes.data ?? recRes ?? []);
161
+ // data-options-text takes an expression against the item, so the
162
+ // label is resolved here rather than spelled out in the attribute.
163
+ form.issuers.value = issuers.map((i) => ({ id: i.id, label: i.name || '(unnamed)' }));
164
+ form.receivers.value = receivers.map((r) => ({ id: r.id, label: r.company || r.name || '(unnamed)' }));
40
165
  } catch {
41
166
  E.toast('Could not load issuers / receivers.', { type: 'error' });
42
167
  }
43
168
 
169
+ // A new invoice opens on the house defaults - the issuer we trade as,
170
+ // the currency, the VAT rate and the payment terms. An existing one
171
+ // opens on what it was saved with, so changing a default never edits
172
+ // an invoice that already exists.
173
+ if (!isEdit) {
174
+ try {
175
+ const res = await H.get(`${BASE}/settings`);
176
+ const settings = res.data ?? res;
177
+ form.currency.value = settings.defaultCurrency ?? 'GBP';
178
+ form.vatEnabled.value = settings.vatEnabledByDefault !== false;
179
+ form.vatRate.value = settings.defaultVatRate ?? 20;
180
+ if (settings.defaultIssuerId) form.issuerId.value = settings.defaultIssuerId;
181
+
182
+ const terms = Number(settings.defaultPaymentTermsDays);
183
+ if (Number.isFinite(terms) && terms > 0) {
184
+ form.dueDate.value = D(today).add(terms, 'days').format('YYYY-MM-DD');
185
+ }
186
+ } catch {
187
+ // The shipped defaults already in the observables are fine.
188
+ }
189
+ }
190
+
44
191
  if (isEdit) {
45
192
  try {
46
193
  const res = await H.get(`${BASE}/invoices/${id}`);
47
- invoice = res.data ?? res;
48
- lineItems = Array.isArray(invoice.lineItems) ? [...invoice.lineItems] : [];
194
+ const invoice = res.data ?? res;
195
+ form.number.value = invoice.number ?? '';
196
+ form.status.value = invoice.status ?? 'draft';
197
+ form.issueDate.value = invoice.issueDate ?? today;
198
+ form.dueDate.value = invoice.dueDate ?? '';
199
+ form.issuerId.value = invoice.issuerId ?? '';
200
+ form.receiverId.value = invoice.receiverId ?? '';
201
+ form.currency.value = invoice.currency ?? 'GBP';
202
+ form.vatEnabled.value = invoice.vatEnabled !== false;
203
+ form.vatRate.value = invoice.vatRate ?? 20;
204
+ form.notes.value = invoice.notes ?? '';
205
+ if (Array.isArray(invoice.lineItems)) {
206
+ form.lineItems.value = invoice.lineItems.map((li) => makeLine(li, form.currency));
207
+ }
208
+ if (Array.isArray(invoice.payments)) {
209
+ form.payments.value = invoice.payments.map((p) => makePaymentRow(p, form.currency));
210
+ }
49
211
  } catch {
50
212
  E.toast('Invoice not found.', { type: 'error' });
51
213
  R.navigate('#/plugins/invoice');
@@ -53,201 +215,156 @@ export const editorView = {
53
215
  }
54
216
  }
55
217
 
56
- const $title = $container.find('#editor-title');
57
- const $number = $container.find('#f-number');
58
- const $status = $container.find('#f-status');
59
- const $issueDate = $container.find('#f-issue-date');
60
- const $dueDate = $container.find('#f-due-date');
61
- const $issuer = $container.find('#f-issuer');
62
- const $receiver = $container.find('#f-receiver');
63
- const $vatEnabled = $container.find('#f-vat-enabled');
64
- const $vatRate = $container.find('#f-vat-rate');
65
- const $currency = $container.find('#f-currency');
66
- const $notes = $container.find('#f-notes');
67
- const $print = $container.find('#btn-print');
68
- const $body = $container.find('#line-items-body');
69
- const $vatRow = $container.find('#vat-row');
70
- const $tSubtotal = $container.find('#t-subtotal');
71
- const $tVat = $container.find('#t-vat');
72
- const $tVatRate = $container.find('#t-vat-rate');
73
- const $tTotal = $container.find('#t-total');
74
-
75
- function populateSelect($select, list, valueKey, labelFn, selected) {
76
- const el = $select.get(0);
77
- while (el.firstChild) el.removeChild(el.firstChild);
78
- const placeholder = document.createElement('option');
79
- placeholder.value = '';
80
- placeholder.textContent = '- select -';
81
- el.appendChild(placeholder);
82
- for (const item of list) {
83
- const opt = document.createElement('option');
84
- opt.value = item[valueKey];
85
- opt.textContent = labelFn(item);
86
- if (item[valueKey] === selected) opt.selected = true;
87
- el.appendChild(opt);
88
- }
218
+ if (!form.lineItems.value.length) {
219
+ form.lineItems.push(makeLine({ quantity: 1, unitPrice: 0 }, form.currency));
89
220
  }
90
221
 
91
- populateSelect($issuer, issuers, 'id', (i) => i.name || '(unnamed)', invoice?.issuerId);
92
- populateSelect($receiver, receivers, 'id', (r) => r.company || r.name || '(unnamed)', invoice?.receiverId);
93
-
94
- const today = new Date().toISOString().slice(0, 10);
95
- $title.text(isEdit ? `Edit Invoice ${invoice?.number ?? ''}` : 'New Invoice');
96
- $number.val(invoice?.number ?? '');
97
- $status.val(invoice?.status ?? 'draft');
98
- $issueDate.val(invoice?.issueDate ?? today);
99
- $dueDate.val(invoice?.dueDate ?? '');
100
- $vatEnabled.prop('checked', invoice?.vatEnabled !== false);
101
- $vatRate.val(invoice?.vatRate ?? 20);
102
- $currency.val(invoice?.currency ?? 'GBP');
103
- $notes.val(invoice?.notes ?? '');
104
-
105
- if (isEdit) {
106
- $print.attr('href', `${BASE}/invoices/${id}/print`).css('display', '');
107
- }
108
-
109
- if (!lineItems.length) {
110
- lineItems.push({ description: '', quantity: 1, unitPrice: 0 });
111
- }
112
- renderLineItems();
113
- recomputeTotals();
114
-
115
- function makeCell(child) {
116
- const td = document.createElement('td');
117
- td.appendChild(child);
118
- return td;
119
- }
120
-
121
- function renderLineItems() {
122
- const body = $body.get(0);
123
- while (body.firstChild) body.removeChild(body.firstChild);
124
-
125
- lineItems.forEach((item, idx) => {
126
- const tr = document.createElement('tr');
127
-
128
- const desc = document.createElement('input');
129
- desc.type = 'text';
130
- desc.className = 'form-control li-desc';
131
- desc.dataset.idx = idx;
132
- desc.value = item.description ?? '';
133
- desc.placeholder = 'Description';
134
-
135
- const qty = document.createElement('input');
136
- qty.type = 'number';
137
- qty.className = 'form-control li-qty';
138
- qty.dataset.idx = idx;
139
- qty.min = '0';
140
- qty.step = '0.01';
141
- qty.value = Number(item.quantity) || 0;
142
-
143
- const price = document.createElement('input');
144
- price.type = 'number';
145
- price.className = 'form-control li-price';
146
- price.dataset.idx = idx;
147
- price.min = '0';
148
- price.step = '0.01';
149
- price.value = Number(item.unitPrice) || 0;
150
-
151
- const totalCell = document.createElement('td');
152
- totalCell.style.cssText = 'text-align:right;font-variant-numeric:tabular-nums;';
153
- totalCell.id = `line-total-${idx}`;
154
- totalCell.textContent = '-';
155
-
156
- const removeBtn = document.createElement('button');
157
- removeBtn.type = 'button';
158
- removeBtn.className = 'btn btn-sm btn-ghost li-remove';
159
- removeBtn.dataset.idx = idx;
160
- removeBtn.title = 'Remove';
161
- removeBtn.textContent = '×';
162
-
163
- tr.appendChild(makeCell(desc));
164
- tr.appendChild(makeCell(qty));
165
- tr.appendChild(makeCell(price));
166
- tr.appendChild(totalCell);
167
- tr.appendChild(makeCell(removeBtn));
168
- body.appendChild(tr);
169
- });
222
+ // ---- behaviour ------------------------------------------------------
223
+ //
224
+ // These hang off the data rather than being passed as `methods`, because
225
+ // inside a data-each a bare handler name does not resolve at all - a row
226
+ // reaches them through `$parent.removeLine($data)`, and $parent is the
227
+ // data object.
228
+
229
+ form.addLine = () => {
230
+ form.lineItems.push(makeLine({ quantity: 1, unitPrice: 0 }, form.currency));
231
+ };
232
+
233
+ form.removeLine = (line) => {
234
+ form.lineItems.remove(line);
235
+ if (!form.lineItems.value.length) form.addLine();
236
+ };
237
+
238
+ /**
239
+ * Move a line by one position.
240
+ *
241
+ * Through `.value =` rather than splicing in place: an observableArray
242
+ * notifies on assignment, and a mutated array is the same array, so
243
+ * `data-each` would never hear about it.
244
+ *
245
+ * @param {object} line
246
+ * @param {number} delta
247
+ * @returns {void}
248
+ */
249
+ form.moveLine = (line, delta) => {
250
+ const lines = [...form.lineItems.value];
251
+ const from = lines.indexOf(line);
252
+ const to = from + delta;
253
+ if (from < 0 || to < 0 || to >= lines.length) return;
254
+ lines.splice(to, 0, ...lines.splice(from, 1));
255
+ form.lineItems.value = lines;
256
+ };
257
+
258
+ /**
259
+ * @param {object} line
260
+ * @returns {void}
261
+ */
262
+ form.duplicateLine = (line) => {
263
+ const lines = [...form.lineItems.value];
264
+ const at = lines.indexOf(line);
265
+ const copy = makeLine({
266
+ description: line.description.value,
267
+ quantity: line.quantity.value,
268
+ unitPrice: line.unitPrice.value
269
+ }, form.currency);
270
+ lines.splice(at + 1, 0, copy);
271
+ form.lineItems.value = lines;
272
+ };
273
+
274
+ form.showHelp = () => openHelpPanel(INVOICE_HELP);
275
+
276
+ // Contacts is optional - disabled, or not installed - so the button is
277
+ // only offered once the probe has answered. data-if takes the element
278
+ // out of the document while the answer is false, which is why its icon
279
+ // (if it ever gains one) would have to be scanned afterwards.
280
+ contactsAvailable().then((ok) => { form.canImport.value = ok; });
281
+
282
+ form.importReceiver = () => openContactPicker({
283
+ title: 'Bill a contact',
284
+ onPick: async (contact) => {
285
+ try {
286
+ // The billing profile uses the receiver's own field names,
287
+ // so it posts straight through with nothing to translate.
288
+ const profile = await billingProfile(contact.id);
289
+ const res = await H.post(`${BASE}/receivers`, profile);
290
+ const created = res?.data ?? res;
291
+ if (created?.error) throw new Error(created.error);
292
+ await loadReceivers(created.id);
293
+ E.toast(`${profile.company || profile.name} added as a receiver.`, { type: 'success' });
294
+ } catch (err) {
295
+ E.toast(err?.message ?? 'Could not import that contact.', { type: 'error' });
296
+ }
297
+ }
298
+ });
170
299
 
171
- updateLineTotals();
172
- }
300
+ form.openPrint = () => openPrintWindow(id);
301
+ form.downloadPdf = () => downloadPdf(id);
173
302
 
174
- function updateLineTotals() {
175
- const currency = $currency.val();
176
- lineItems.forEach((item, idx) => {
177
- const lineTotal = round2((Number(item.quantity) || 0) * (Number(item.unitPrice) || 0));
178
- const cell = $container.find(`#line-total-${idx}`).get(0);
179
- if (cell) cell.textContent = fmt(lineTotal, currency);
180
- });
181
- }
303
+ /** One place to take an invoice the server just changed and follow it. */
304
+ const applyServerInvoice = (updated) => {
305
+ if (!updated) return;
306
+ if (updated.status) form.status.value = updated.status;
307
+ form.payments.value = (updated.payments ?? []).map((p) => makePaymentRow(p, form.currency));
308
+ };
182
309
 
183
- function recomputeTotals() {
184
- const currency = $currency.val();
185
- const vatEnabled = $vatEnabled.prop('checked');
186
- const vatRate = Number($vatRate.val()) || 0;
310
+ form.recordPayment = () => openPaymentDialog(id, form.balance.value, applyServerInvoice);
187
311
 
188
- let subtotal = 0;
189
- for (const item of lineItems) {
190
- subtotal += (Number(item.quantity) || 0) * (Number(item.unitPrice) || 0);
312
+ form.removePayment = async (row) => {
313
+ const ok = await E.confirm(`Remove the ${money(row.amount, form.currency.value)} payment?`);
314
+ if (!ok) return;
315
+ try {
316
+ const res = await H.delete(`${BASE}/invoices/${id}/payments/${row.id}`);
317
+ const out = res?.data ?? res;
318
+ if (out?.error) throw new Error(out.error);
319
+ applyServerInvoice(out);
320
+ E.toast('Payment removed.', { type: 'success' });
321
+ } catch (err) {
322
+ E.toast(err?.message ?? 'Could not remove the payment.', { type: 'error' });
191
323
  }
192
- subtotal = round2(subtotal);
193
- const vat = vatEnabled ? round2(subtotal * (vatRate / 100)) : 0;
194
- const total = round2(subtotal + vat);
195
-
196
- $tSubtotal.text(fmt(subtotal, currency));
197
- $tVat.text(fmt(vat, currency));
198
- $tVatRate.text(vatRate);
199
- $tTotal.text(fmt(total, currency));
200
- $vatRow.css('display', vatEnabled ? '' : 'none');
201
- updateLineTotals();
202
- }
324
+ };
203
325
 
204
- $body.on('input', '.li-desc', function () {
205
- lineItems[Number(this.dataset.idx)].description = this.value;
206
- });
207
- $body.on('input', '.li-qty', function () {
208
- lineItems[Number(this.dataset.idx)].quantity = Number(this.value) || 0;
209
- recomputeTotals();
210
- });
211
- $body.on('input', '.li-price', function () {
212
- lineItems[Number(this.dataset.idx)].unitPrice = Number(this.value) || 0;
213
- recomputeTotals();
214
- });
215
- $body.on('click', '.li-remove', function () {
216
- const idx = Number(this.dataset.idx);
217
- lineItems.splice(idx, 1);
218
- if (!lineItems.length) lineItems.push({ description: '', quantity: 1, unitPrice: 0 });
219
- renderLineItems();
220
- recomputeTotals();
326
+ form.sendInvoice = () => openSendDialog(id, (updated) => {
327
+ // Sending can move a draft to sent, so the screen has to follow.
328
+ if (updated?.status) form.status.value = updated.status;
221
329
  });
222
330
 
223
- $container.find('#btn-add-line').on('click', () => {
224
- lineItems.push({ description: '', quantity: 1, unitPrice: 0 });
225
- renderLineItems();
226
- recomputeTotals();
227
- });
228
-
229
- $vatEnabled.on('change', recomputeTotals);
230
- $vatRate.on('input', recomputeTotals);
231
- $currency.on('change', recomputeTotals);
331
+ // The button only exists where the host can actually render one; a
332
+ // button that always answers "not installed" is worse than no button.
333
+ //
334
+ // Its icon has to be scanned AFTER it appears: data-if takes the element
335
+ // out of the document while the answer is false, so the scan at the end
336
+ // of mount would run against a detached node and the span would come
337
+ // back empty. M.flush() settles the binding first - reading the DOM
338
+ // before the microtask has run would scan the old shape.
339
+ if (isEdit) {
340
+ canDownloadPdf().then((ok) => {
341
+ if (!ok) return;
342
+ form.canPdf.value = true;
343
+ M.flush();
344
+ Domma.icons.scan($container.get(0));
345
+ });
346
+ }
232
347
 
233
- $container.find('#btn-save').on('click', async () => {
234
- const issuerId = $issuer.val();
235
- const receiverId = $receiver.val();
236
- if (!issuerId) { E.toast('Pick an issuer.', { type: 'error' }); return; }
237
- if (!receiverId) { E.toast('Pick a receiver.', { type: 'error' }); return; }
348
+ form.save = async () => {
349
+ if (!form.issuerId.value) { E.toast('Pick an issuer.', { type: 'error' }); return; }
350
+ if (!form.receiverId.value) { E.toast('Pick a receiver.', { type: 'error' }); return; }
238
351
 
239
352
  const body = {
240
- number: $number.val()?.trim() || undefined,
241
- status: $status.val(),
242
- issueDate: $issueDate.val(),
243
- dueDate: $dueDate.val() || '',
244
- issuerId,
245
- receiverId,
246
- vatEnabled: $vatEnabled.prop('checked'),
247
- vatRate: Number($vatRate.val()) || 0,
248
- currency: $currency.val(),
249
- notes: $notes.val() ?? '',
250
- lineItems
353
+ number: String(form.number.value ?? '').trim() || undefined,
354
+ status: form.status.value,
355
+ issueDate: form.issueDate.value,
356
+ dueDate: form.dueDate.value || '',
357
+ issuerId: form.issuerId.value,
358
+ receiverId: form.receiverId.value,
359
+ vatEnabled: Boolean(form.vatEnabled.value),
360
+ vatRate: Number(form.vatRate.value) || 0,
361
+ currency: form.currency.value,
362
+ notes: form.notes.value ?? '',
363
+ lineItems: form.lineItems.value.map((line) => ({
364
+ description: line.description.value,
365
+ quantity: Number(line.quantity.value) || 0,
366
+ unitPrice: Number(line.unitPrice.value) || 0
367
+ }))
251
368
  };
252
369
 
253
370
  try {
@@ -260,8 +377,58 @@ export const editorView = {
260
377
  } catch (err) {
261
378
  E.toast(err?.message ?? 'Save failed.', { type: 'error' });
262
379
  }
380
+ };
381
+
382
+ const handle = M.applyBindings(form, $container.get(0));
383
+
384
+ // A line is a row, so its actions belong on the row. The item is found
385
+ // by POSITION rather than by an id attribute: data-each owns this
386
+ // markup and stamps no id on the <tr>, and the rendered order is the
387
+ // array order.
388
+ const linesBody = $container.find('#line-items-body').get(0);
389
+ const lineMenu = linesBody && E.contextMenu(linesBody, {
390
+ match: 'tr',
391
+ inherit: false,
392
+ items: (target) => {
393
+ const index = [...linesBody.children].indexOf(target);
394
+ const line = form.lineItems.value[index];
395
+ if (!line) return [];
396
+ const last = form.lineItems.value.length - 1;
397
+ return [
398
+ { header: line.description.value || `Line ${index + 1}` },
399
+ { label: 'Duplicate', icon: 'copy', action: () => form.duplicateLine(line) },
400
+ { label: 'Add a line below', icon: 'plus', action: () => form.addLine() },
401
+ { type: 'divider' },
402
+ { label: 'Move up', icon: 'chevron-up', disabled: index === 0,
403
+ action: () => form.moveLine(line, -1) },
404
+ { label: 'Move down', icon: 'chevron-down', disabled: index === last,
405
+ action: () => form.moveLine(line, 1) },
406
+ { type: 'divider' },
407
+ { label: 'Remove', icon: 'trash', danger: true,
408
+ action: () => form.removeLine(line) }
409
+ ];
410
+ }
411
+ });
412
+
413
+ // Domma's calendar rather than `<input type="date">`: one control in
414
+ // every browser, themed from --dm-* tokens, and it writes ISO into the
415
+ // input and fires input/change, so `data-model` hears it exactly as it
416
+ // hears typing. The due date cannot precede the issue date, and saying
417
+ // so in the picker beats validating it after the fact.
418
+ const issuePicker = E.datePicker($container.find('#f-issue-date').get(0), {
419
+ onChange: (iso) => duePicker?.setOptions({min: iso || null})
420
+ });
421
+ const duePicker = E.datePicker($container.find('#f-due-date').get(0), {
422
+ min: form.issueDate.value || null
263
423
  });
264
424
 
265
- Domma.icons.scan();
425
+ disposeOnLeave($container.find('#line-items-table').get(0), handle,
426
+ () => lineMenu?.destroy?.(),
427
+ () => issuePicker?.destroy?.(),
428
+ () => duePicker?.destroy?.());
429
+
430
+ Domma.icons.scan($container.get(0));
431
+ M.flush();
432
+ scanHelp($container.get(0));
266
433
  }
267
434
  };