domma-cms 0.66.0 → 0.69.3

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