domma-cms 0.66.0 → 0.69.4

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 +64 -1
  2. package/README.md +14 -10
  3. package/admin/css/admin.css +1 -1
  4. package/admin/index.html +1 -0
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +4 -4
  7. package/admin/js/lib/analytics-shape.js +1 -0
  8. package/admin/js/lib/contacts-arrange.js +1 -0
  9. package/admin/js/lib/help-popover.js +1 -0
  10. package/admin/js/lib/notes-arrange.js +1 -0
  11. package/admin/js/lib/plugin-accent.js +1 -1
  12. package/admin/js/lib/plugin-chrome.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.js +1 -1
  14. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  15. package/admin/js/lib/sidebar-renderer.js +3 -3
  16. package/admin/js/lib/todo-arrange.js +1 -0
  17. package/admin/js/lib/tool-kit.js +1 -0
  18. package/admin/js/templates/analytics.html +138 -0
  19. package/admin/js/templates/contacts.html +156 -0
  20. package/admin/js/templates/notes.html +137 -0
  21. package/admin/js/templates/plugin-marketplace.html +17 -4
  22. package/admin/js/templates/plugins.html +13 -7
  23. package/admin/js/templates/theme.html +38 -0
  24. package/admin/js/templates/todo.html +113 -0
  25. package/admin/js/views/analytics.js +62 -0
  26. package/admin/js/views/contacts.js +174 -0
  27. package/admin/js/views/index.js +1 -1
  28. package/admin/js/views/notes.js +104 -0
  29. package/admin/js/views/plugin-marketplace.js +1 -1
  30. package/admin/js/views/plugins.js +24 -22
  31. package/admin/js/views/theme.js +1 -1
  32. package/admin/js/views/todo.js +96 -0
  33. package/bin/cli.js +4 -10
  34. package/bin/lib/plugin-version.js +28 -0
  35. package/bin/update.js +14 -0
  36. package/config/menus/admin-sidebar.json +1 -1
  37. package/config/server.json +3 -1
  38. package/package.json +3 -7
  39. package/plugins/_lib/admin/mail/reader-view.js +20 -0
  40. package/plugins/_lib/mail/store.js +21 -1
  41. package/plugins/blog/CLAUDE.md +237 -0
  42. package/plugins/blog/admin/css/index.css +178 -1
  43. package/plugins/blog/admin/templates/blog.html +35 -53
  44. package/plugins/blog/admin/templates/categories.html +19 -6
  45. package/plugins/blog/admin/templates/comments.html +26 -9
  46. package/plugins/blog/admin/templates/post-editor.html +36 -13
  47. package/plugins/blog/admin/templates/settings.html +2 -11
  48. package/plugins/blog/admin/views/blog.js +288 -162
  49. package/plugins/blog/admin/views/categories.js +212 -201
  50. package/plugins/blog/admin/views/comments.js +207 -158
  51. package/plugins/blog/admin/views/kit.js +372 -0
  52. package/plugins/blog/admin/views/post-editor.js +269 -21
  53. package/plugins/blog/admin/views/settings-cog.js +56 -0
  54. package/plugins/blog/admin/views/settings.js +16 -86
  55. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  56. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  57. package/plugins/blog/blocks/blog-card-row.css +22 -0
  58. package/plugins/blog/blocks/blog-card-row.html +11 -0
  59. package/plugins/blog/blocks/blog-card.css +35 -0
  60. package/plugins/blog/blocks/blog-card.html +16 -0
  61. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  62. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  63. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  64. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  65. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  66. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  67. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  68. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  69. package/plugins/blog/collections/posts/schema.json +3 -1
  70. package/plugins/blog/config.js +6 -0
  71. package/plugins/blog/lib/layouts.js +304 -0
  72. package/plugins/blog/lib/page.js +158 -0
  73. package/plugins/blog/lib/render.js +119 -0
  74. package/plugins/blog/lib/samples.js +234 -0
  75. package/plugins/blog/plugin.js +225 -15
  76. package/plugins/blog/plugin.json +21 -81
  77. package/plugins/blog/plugin.public.js +115 -210
  78. package/plugins/blog/public/blog.css +177 -0
  79. package/plugins/blog/public/blog.js +586 -0
  80. package/plugins/blog/public/samples/aurora.svg +7 -0
  81. package/plugins/blog/public/samples/dusk.svg +7 -0
  82. package/plugins/blog/public/samples/ember.svg +7 -0
  83. package/plugins/blog/public/samples/harbour.svg +7 -0
  84. package/plugins/blog/public/samples/meadow.svg +7 -0
  85. package/plugins/blog/public/samples/rosewood.svg +7 -0
  86. package/plugins/blog/templates/index.html +9 -8
  87. package/plugins/blog/templates/post.html +4 -17
  88. package/plugins/blog/tests/layouts.test.js +97 -0
  89. package/plugins/blog/tests/public.test.js +71 -0
  90. package/plugins/free-tier.lock.json +74 -0
  91. package/plugins/mail-reader/CLAUDE.md +78 -0
  92. package/plugins/mail-reader/plugin.json +2 -1
  93. package/public/css/theme-switcher.css +1 -0
  94. package/public/js/analytics.js +1 -0
  95. package/public/js/theme-switcher.js +1 -0
  96. package/scripts/build.js +41 -2
  97. package/scripts/run-tests.mjs +94 -0
  98. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  99. package/{plugins/contacts/plugin.js → server/routes/api/contacts.js} +71 -43
  100. package/server/routes/api/dashboard.js +8 -2
  101. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  102. package/server/routes/api/plugin-marketplace.js +78 -23
  103. package/server/routes/api/plugins.js +148 -3
  104. package/server/routes/api/todo.js +178 -0
  105. package/server/server.js +61 -1
  106. package/server/services/collections.js +13 -3
  107. package/server/services/managerClient.js +72 -136
  108. package/server/services/markdown.js +54 -1
  109. package/server/services/permissionRegistry.js +50 -0
  110. package/server/services/pluginEntitlement.js +171 -0
  111. package/server/services/pluginEnvelope.js +242 -0
  112. package/server/services/pluginInstaller.js +246 -26
  113. package/server/services/pluginScaffold.js +5 -0
  114. package/server/services/plugins.js +117 -7
  115. package/server/services/presetCollections.js +344 -0
  116. package/server/services/promoted-plugins-migration.js +254 -0
  117. package/server/services/publicCors.js +46 -0
  118. package/server/services/renderer.js +83 -3
  119. package/server/services/sidebarBadges.js +37 -0
  120. package/server/services/siteGitignore.js +310 -0
  121. package/server/services/themeSettings.js +39 -0
  122. package/config/theme.json +0 -18
  123. package/plugins/analytics/admin/css/index.css +0 -1
  124. package/plugins/analytics/admin/templates/analytics.html +0 -70
  125. package/plugins/analytics/admin/views/analytics.js +0 -176
  126. package/plugins/analytics/config.js +0 -14
  127. package/plugins/analytics/plugin.json +0 -60
  128. package/plugins/analytics/public/inject-body.html +0 -60
  129. package/plugins/analytics/public/inject-head.html +0 -1
  130. package/plugins/blog/templates/author.html +0 -9
  131. package/plugins/blog/templates/category.html +0 -9
  132. package/plugins/blog/templates/tag.html +0 -9
  133. package/plugins/contacts/admin/templates/contacts.html +0 -254
  134. package/plugins/contacts/admin/views/contacts.js +0 -943
  135. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  136. package/plugins/contacts/collections/user-contacts/schema.json +0 -179
  137. package/plugins/contacts/config.js +0 -6
  138. package/plugins/contacts/plugin.json +0 -36
  139. package/plugins/demo-viewer/config.js +0 -4
  140. package/plugins/demo-viewer/plugin.js +0 -36
  141. package/plugins/demo-viewer/plugin.json +0 -9
  142. package/plugins/invoice/admin/css/index.css +0 -1
  143. package/plugins/invoice/admin/templates/editor.html +0 -220
  144. package/plugins/invoice/admin/templates/index.html +0 -173
  145. package/plugins/invoice/admin/templates/issuers.html +0 -2
  146. package/plugins/invoice/admin/templates/receivers.html +0 -2
  147. package/plugins/invoice/admin/views/contacts-source.js +0 -266
  148. package/plugins/invoice/admin/views/editor.js +0 -434
  149. package/plugins/invoice/admin/views/export.js +0 -199
  150. package/plugins/invoice/admin/views/help-content.js +0 -61
  151. package/plugins/invoice/admin/views/index.js +0 -643
  152. package/plugins/invoice/admin/views/issuers.js +0 -30
  153. package/plugins/invoice/admin/views/media.js +0 -172
  154. package/plugins/invoice/admin/views/party-view.js +0 -386
  155. package/plugins/invoice/admin/views/payments.js +0 -127
  156. package/plugins/invoice/admin/views/print.js +0 -130
  157. package/plugins/invoice/admin/views/receivers.js +0 -55
  158. package/plugins/invoice/admin/views/send.js +0 -212
  159. package/plugins/invoice/admin/views/settings.js +0 -594
  160. package/plugins/invoice/admin/views/view-lifecycle.js +0 -33
  161. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -82
  162. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -16
  163. package/plugins/invoice/collections/invoices/schema.json +0 -33
  164. package/plugins/invoice/config.js +0 -37
  165. package/plugins/invoice/pdf.js +0 -164
  166. package/plugins/invoice/plugin.js +0 -1456
  167. package/plugins/invoice/plugin.json +0 -86
  168. package/plugins/invoice/templates/_base.css +0 -1
  169. package/plugins/invoice/templates/classic-nologo.html +0 -100
  170. package/plugins/invoice/templates/classic.html +0 -91
  171. package/plugins/invoice/templates/invoice-print.html +0 -237
  172. package/plugins/invoice/templates/minimal.html +0 -99
  173. package/plugins/invoice/templates/modern-nologo.html +0 -114
  174. package/plugins/invoice/templates/modern.html +0 -113
  175. package/plugins/invoice/templates/templates.json +0 -11
  176. package/plugins/notes/admin/templates/notes.html +0 -83
  177. package/plugins/notes/admin/views/notes.js +0 -281
  178. package/plugins/notes/collections/user-notes/schema.json +0 -54
  179. package/plugins/notes/config.js +0 -6
  180. package/plugins/notes/plugin.json +0 -33
  181. package/plugins/surveys/admin/templates/audience.html +0 -47
  182. package/plugins/surveys/admin/templates/results.html +0 -58
  183. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  184. package/plugins/surveys/admin/templates/surveys.html +0 -25
  185. package/plugins/surveys/admin/views/audience.js +0 -301
  186. package/plugins/surveys/admin/views/results.js +0 -172
  187. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  188. package/plugins/surveys/admin/views/surveys.js +0 -161
  189. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  190. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  191. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  192. package/plugins/surveys/collections/surveys/schema.json +0 -23
  193. package/plugins/surveys/config.js +0 -8
  194. package/plugins/surveys/lib/audience.js +0 -37
  195. package/plugins/surveys/lib/campaigns.js +0 -43
  196. package/plugins/surveys/lib/ledger.js +0 -110
  197. package/plugins/surveys/lib/sending.js +0 -106
  198. package/plugins/surveys/lib/stats.js +0 -62
  199. package/plugins/surveys/lib/submit.js +0 -95
  200. package/plugins/surveys/lib/tokens.js +0 -28
  201. package/plugins/surveys/plugin.js +0 -174
  202. package/plugins/surveys/plugin.json +0 -65
  203. package/plugins/surveys/plugin.public.js +0 -149
  204. package/plugins/surveys/public/dist-shared.mjs +0 -1
  205. package/plugins/surveys/public/survey.css +0 -1
  206. package/plugins/surveys/public/survey.mjs +0 -1
  207. package/plugins/surveys/templates/survey-page.html +0 -63
  208. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  209. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  210. package/plugins/theme-switcher/config.js +0 -10
  211. package/plugins/theme-switcher/plugin.js +0 -26
  212. package/plugins/theme-switcher/plugin.json +0 -74
  213. package/plugins/theme-switcher/public/inject-body.html +0 -153
  214. package/plugins/theme-switcher/public/inject-head.html +0 -260
  215. package/plugins/todo/admin/templates/todo.html +0 -158
  216. package/plugins/todo/admin/views/todo.js +0 -343
  217. package/plugins/todo/collections/todos/schema.json +0 -60
  218. package/plugins/todo/config.js +0 -7
  219. package/plugins/todo/plugin.js +0 -102
  220. package/plugins/todo/plugin.json +0 -33
  221. /package/{plugins/contacts/admin/css/index.css → admin/css/contacts.css} +0 -0
@@ -1,434 +0,0 @@
1
- /**
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.
13
- */
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;
26
- }
27
-
28
- function money(amount, currency) {
29
- try {
30
- return new Intl.NumberFormat('en-GB', { style: 'currency', currency: currency || 'GBP' }).format(Number(amount) || 0);
31
- } catch {
32
- return `${currency || 'GBP'} ${(Number(amount) || 0).toFixed(2)}`;
33
- }
34
- }
35
-
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;
56
- }
57
-
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
- }
80
-
81
- export const editorView = {
82
- templateUrl: '/plugins/invoice/admin/templates/editor.html',
83
-
84
- async onMount($container) {
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;
90
- const isEdit = Boolean(id);
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
- }
153
-
154
- try {
155
- const [issRes, recRes] = await Promise.all([
156
- H.get(`${BASE}/issuers`),
157
- H.get(`${BASE}/receivers`)
158
- ]);
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)' }));
165
- } catch {
166
- E.toast('Could not load issuers / receivers.', { type: 'error' });
167
- }
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
-
191
- if (isEdit) {
192
- try {
193
- const res = await H.get(`${BASE}/invoices/${id}`);
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
- }
211
- } catch {
212
- E.toast('Invoice not found.', { type: 'error' });
213
- R.navigate('#/plugins/invoice');
214
- return;
215
- }
216
- }
217
-
218
- if (!form.lineItems.value.length) {
219
- form.lineItems.push(makeLine({ quantity: 1, unitPrice: 0 }, form.currency));
220
- }
221
-
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
- });
299
-
300
- form.openPrint = () => openPrintWindow(id);
301
- form.downloadPdf = () => downloadPdf(id);
302
-
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
- };
309
-
310
- form.recordPayment = () => openPaymentDialog(id, form.balance.value, applyServerInvoice);
311
-
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' });
323
- }
324
- };
325
-
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;
329
- });
330
-
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
- }
347
-
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; }
351
-
352
- const body = {
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
- }))
368
- };
369
-
370
- try {
371
- const res = isEdit
372
- ? await H.put(`${BASE}/invoices/${id}`, body)
373
- : await H.post(`${BASE}/invoices`, body);
374
- if (res.error) throw new Error(res.error);
375
- E.toast(isEdit ? 'Invoice saved.' : 'Invoice created.', { type: 'success' });
376
- R.navigate('#/plugins/invoice');
377
- } catch (err) {
378
- E.toast(err?.message ?? 'Save failed.', { type: 'error' });
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
423
- });
424
-
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));
433
- }
434
- };
@@ -1,199 +0,0 @@
1
- /**
2
- * Invoice plugin - saving invoices as a file.
3
- *
4
- * Domma's table can export what it is showing, but it exports the RENDERED
5
- * columns: a receiver id instead of a name, an HTML badge instead of a status,
6
- * and no line items at all, because they are not in the table. An accountant
7
- * asking for "the invoices as a spreadsheet" wants neither of those, so the
8
- * rows are built here from the data and the table's own exporter is left off.
9
- *
10
- * Excel is an HTML table served as `application/vnd.ms-excel`, which is what
11
- * Domma's own exporter does and what Excel and LibreOffice both open without
12
- * a converter. It is not a real .xlsx, and it does not need to be - nothing
13
- * here has formulas, merged cells or more than one sheet.
14
- */
15
- function escapeHtml(s) {
16
- return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
17
- }
18
-
19
- /**
20
- * @param {Array<Array<string|number>>} rows
21
- * @returns {string}
22
- */
23
- function toCsv(rows) {
24
- return rows.map((row) => row.map((cell) => {
25
- const text = cell === null || cell === undefined ? '' : String(cell);
26
- return `"${text.replace(/"/g, '""')}"`;
27
- }).join(',')).join('\r\n');
28
- }
29
-
30
- /**
31
- * @param {string} title
32
- * @param {Array<Array<string|number>>} rows
33
- * @param {number} headerRows - how many leading rows to render as <th>
34
- * @returns {string}
35
- */
36
- function toExcel(title, rows, headerRows = 1) {
37
- const body = rows.map((row, index) => {
38
- const tag = index < headerRows ? 'th' : 'td';
39
- return '<tr>' + row.map((cell) => `<${tag}>${escapeHtml(cell)}</${tag}>`).join('') + '</tr>';
40
- }).join('');
41
-
42
- return `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel">
43
- <head><meta charset="UTF-8">
44
- <!--[if gte mso 9]><xml><x:ExcelWorkbook><x:ExcelWorksheets><x:ExcelWorksheet>
45
- <x:Name>${escapeHtml(title).slice(0, 31)}</x:Name>
46
- <x:WorksheetOptions><x:DisplayGridlines/></x:WorksheetOptions>
47
- </x:ExcelWorksheet></x:ExcelWorksheets></x:ExcelWorkbook></xml><![endif]-->
48
- <style>table{border-collapse:collapse}th,td{border:1px solid #999;padding:6px}th{background:#eee;font-weight:bold;text-align:left}</style>
49
- </head><body><table>${body}</table></body></html>`;
50
- }
51
-
52
- /**
53
- * @param {string} content
54
- * @param {string} mime
55
- * @param {string} filename
56
- * @returns {void}
57
- */
58
- function save(content, mime, filename) {
59
- // A BOM, because Excel reads a CSV without one as the system codepage and
60
- // turns every pound sign into £.
61
- const blob = new Blob([mime.startsWith('text/csv') ? '' + content : content], { type: mime });
62
- const url = URL.createObjectURL(blob);
63
- const a = document.createElement('a');
64
- a.href = url;
65
- a.download = filename;
66
- a.rel = 'noopener';
67
- document.body.appendChild(a);
68
- a.click();
69
- a.remove();
70
- // Revoked on the next tick: revoking synchronously races the download in
71
- // Firefox, which has not necessarily read the blob by the time click()
72
- // returns.
73
- setTimeout(() => URL.revokeObjectURL(url), 1000);
74
- }
75
-
76
- /** A filename component that no filesystem will argue about. */
77
- function safeName(text, fallback) {
78
- const cleaned = String(text ?? '').replace(/[^\w.-]+/g, '_').replace(/^_+|_+$/g, '');
79
- return cleaned || fallback;
80
- }
81
-
82
- function money(amount) {
83
- return (Number(amount) || 0).toFixed(2);
84
- }
85
-
86
- function dateText(value) {
87
- return value ? D(value).format('DD MMM YYYY') : '';
88
- }
89
-
90
- /**
91
- * One invoice as a small worksheet: the facts, then the lines, then the totals.
92
- *
93
- * @param {object} invoice
94
- * @param {{issuerName: string, receiverName: string}} names
95
- * @returns {Array<Array<string|number>>}
96
- */
97
- export function invoiceRows(invoice, { issuerName = '', receiverName = '' } = {}) {
98
- const currency = invoice.currency || 'GBP';
99
- const totals = invoice.totals ?? {};
100
- const rows = [
101
- ['Invoice', invoice.number || ''],
102
- ['Status', invoice.overdue ? 'overdue' : (invoice.status || 'draft')],
103
- ['From', issuerName],
104
- ['To', receiverName],
105
- ['Issued', dateText(invoice.issueDate)],
106
- ['Due', dateText(invoice.dueDate)],
107
- ['Currency', currency],
108
- [],
109
- ['Description', 'Quantity', 'Unit price', 'Line total']
110
- ];
111
-
112
- for (const line of invoice.lineItems ?? []) {
113
- const quantity = Number(line.quantity) || 0;
114
- const unit = Number(line.unitPrice) || 0;
115
- rows.push([line.description || '', quantity, money(unit), money(quantity * unit)]);
116
- }
117
-
118
- rows.push([]);
119
- rows.push(['', '', 'Subtotal', money(totals.subtotal)]);
120
- if (invoice.vatEnabled) rows.push(['', '', `VAT (${totals.vatRate ?? invoice.vatRate ?? 0}%)`, money(totals.vat)]);
121
- rows.push(['', '', 'Total', money(totals.total)]);
122
- if (Number(totals.paid) > 0) {
123
- rows.push(['', '', 'Paid', money(totals.paid)]);
124
- rows.push(['', '', 'Balance', money(Math.max(0, Number(totals.balance) || 0))]);
125
- }
126
-
127
- if (invoice.notes) {
128
- rows.push([]);
129
- rows.push(['Notes', invoice.notes]);
130
- }
131
- return rows;
132
- }
133
-
134
- /**
135
- * A list of invoices, one per row.
136
- *
137
- * @param {object[]} invoices
138
- * @param {(invoice: object) => {issuerName: string, receiverName: string}} names
139
- * @returns {Array<Array<string|number>>}
140
- */
141
- export function listRows(invoices, names) {
142
- const rows = [[
143
- 'Number', 'Status', 'From', 'To', 'Issued', 'Due',
144
- 'Currency', 'Subtotal', 'VAT', 'Total', 'Paid', 'Balance'
145
- ]];
146
-
147
- for (const invoice of invoices) {
148
- const totals = invoice.totals ?? {};
149
- const { issuerName = '', receiverName = '' } = names(invoice) ?? {};
150
- rows.push([
151
- invoice.number || '',
152
- invoice.overdue ? 'overdue' : (invoice.status || 'draft'),
153
- issuerName,
154
- receiverName,
155
- dateText(invoice.issueDate),
156
- dateText(invoice.dueDate),
157
- invoice.currency || 'GBP',
158
- money(totals.subtotal),
159
- money(totals.vat),
160
- money(totals.total),
161
- money(totals.paid),
162
- money(totals.balance)
163
- ]);
164
- }
165
- return rows;
166
- }
167
-
168
- /**
169
- * @param {object} invoice
170
- * @param {'csv'|'excel'} format
171
- * @param {object} names
172
- * @returns {void}
173
- */
174
- export function exportInvoice(invoice, format, names) {
175
- const rows = invoiceRows(invoice, names);
176
- const stem = safeName(invoice.number, 'invoice');
177
- if (format === 'excel') {
178
- save(toExcel(stem, rows, 0), 'application/vnd.ms-excel', `${stem}.xls`);
179
- } else {
180
- save(toCsv(rows), 'text/csv;charset=utf-8', `${stem}.csv`);
181
- }
182
- }
183
-
184
- /**
185
- * @param {object[]} invoices
186
- * @param {'csv'|'excel'} format
187
- * @param {Function} names
188
- * @param {string} [stem]
189
- * @returns {void}
190
- */
191
- export function exportList(invoices, format, names, stem = 'invoices') {
192
- const rows = listRows(invoices, names);
193
- const filename = `${safeName(stem, 'invoices')}-${D().format('YYYY-MM-DD')}`;
194
- if (format === 'excel') {
195
- save(toExcel('Invoices', rows, 1), 'application/vnd.ms-excel', `${filename}.xls`);
196
- } else {
197
- save(toCsv(rows), 'text/csv;charset=utf-8', `${filename}.csv`);
198
- }
199
- }