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,172 +0,0 @@
1
- /**
2
- * Invoice plugin - choosing or uploading a logo.
3
- *
4
- * A logo is a file, not a URL somebody types, so every place that holds one
5
- * (the Branding setting, an issuer's own override) opens this: the media
6
- * library as a grid, with an Upload button that adds to it.
7
- *
8
- * Upload goes through a raw `fetch` rather than `H`, and deliberately. `H`
9
- * sets `Content-Type: application/json` and `JSON.stringify`s its body, so a
10
- * FormData posted through it arrives as `{}` - the file never leaves the
11
- * browser and the server answers "No file uploaded". The Media screen in the
12
- * admin uses the same raw call for the same reason; the token comes out of
13
- * Domma storage, which is where the admin's interceptor reads it from too.
14
- */
15
- const IMAGE = /\.(png|jpe?g|gif|webp|svg)$/i;
16
-
17
- /**
18
- * Upload one file to the media library.
19
- *
20
- * @param {File} file
21
- * @returns {Promise<{name: string, url: string}>}
22
- */
23
- async function uploadMedia(file) {
24
- const body = new FormData();
25
- body.append('file', file);
26
-
27
- const res = await fetch('/api/media', {
28
- method: 'POST',
29
- body,
30
- headers: { Authorization: 'Bearer ' + (S.get('auth_token') || '') }
31
- });
32
-
33
- let payload = null;
34
- try {
35
- payload = await res.json();
36
- } catch { /* a non-JSON body means the status is all we have */ }
37
-
38
- if (!res.ok) throw new Error(payload?.error || `Upload failed (${res.status}).`);
39
- // POST /api/media answers with an array when several files went up.
40
- return Array.isArray(payload) ? payload[0] : payload;
41
- }
42
-
43
- /**
44
- * Open the picker.
45
- *
46
- * @param {(url: string) => void} onPick called with the chosen media path
47
- * @returns {Promise<void>}
48
- */
49
- export async function openMediaPicker(onPick) {
50
- const slideover = E.slideover({
51
- title: 'Choose a logo',
52
- size: 'md',
53
- position: 'right',
54
- onClose: () => setTimeout(() => {
55
- slideover.destroy();
56
- slideover.element?.remove();
57
- }, 350)
58
- });
59
-
60
- const wrapper = document.createElement('div');
61
- wrapper.className = 'invoice-party-form';
62
-
63
- const bar = document.createElement('div');
64
- bar.className = 'invoice-panel-head';
65
-
66
- const uploadBtn = document.createElement('button');
67
- uploadBtn.type = 'button';
68
- uploadBtn.className = 'btn btn-sm btn-primary';
69
- uploadBtn.textContent = 'Upload…';
70
-
71
- const fileInput = document.createElement('input');
72
- fileInput.type = 'file';
73
- fileInput.accept = 'image/*';
74
- fileInput.className = 'invoice-hidden';
75
-
76
- const clearBtn = document.createElement('button');
77
- clearBtn.type = 'button';
78
- clearBtn.className = 'btn btn-sm btn-ghost';
79
- clearBtn.textContent = 'No logo';
80
- clearBtn.title = 'Clear the logo';
81
-
82
- bar.appendChild(clearBtn);
83
- bar.appendChild(uploadBtn);
84
- bar.appendChild(fileInput);
85
- wrapper.appendChild(bar);
86
-
87
- const status = document.createElement('p');
88
- status.className = 'text-sm text-muted';
89
- wrapper.appendChild(status);
90
-
91
- const grid = document.createElement('div');
92
- grid.className = 'invoice-media-grid';
93
- wrapper.appendChild(grid);
94
-
95
- slideover.element.appendChild(wrapper);
96
-
97
- /**
98
- * @param {string} url
99
- * @returns {void}
100
- */
101
- function choose(url) {
102
- onPick(url);
103
- slideover.close();
104
- }
105
-
106
- /**
107
- * @returns {Promise<void>}
108
- */
109
- async function refresh() {
110
- status.textContent = 'Loading…';
111
- let media = [];
112
- try {
113
- const res = await H.get('/api/media');
114
- media = (res.data ?? res ?? []).filter((m) => IMAGE.test(m.name || m.url || ''));
115
- } catch {
116
- status.textContent = 'The media library could not be read.';
117
- return;
118
- }
119
-
120
- grid.textContent = '';
121
- status.textContent = media.length
122
- ? ''
123
- : 'No images yet. Upload one and it will appear here.';
124
-
125
- for (const item of media) {
126
- const tile = document.createElement('button');
127
- tile.type = 'button';
128
- tile.className = 'invoice-media-tile';
129
- tile.title = item.name;
130
-
131
- const img = document.createElement('img');
132
- img.src = item.url;
133
- img.alt = item.name;
134
- img.loading = 'lazy';
135
- tile.appendChild(img);
136
-
137
- const name = document.createElement('span');
138
- name.textContent = item.name;
139
- tile.appendChild(name);
140
-
141
- tile.addEventListener('click', () => choose(item.url));
142
- grid.appendChild(tile);
143
- }
144
- }
145
-
146
- uploadBtn.addEventListener('click', () => fileInput.click());
147
- clearBtn.addEventListener('click', () => choose(''));
148
-
149
- fileInput.addEventListener('change', async () => {
150
- const file = fileInput.files?.[0];
151
- if (!file) return;
152
- uploadBtn.disabled = true;
153
- status.textContent = 'Uploading…';
154
- try {
155
- const saved = await uploadMedia(file);
156
- E.toast('Uploaded.', { type: 'success' });
157
- // Straight to the newly uploaded file: somebody who has just
158
- // chosen it in a file dialog has already picked it.
159
- if (saved?.url) { choose(saved.url); return; }
160
- await refresh();
161
- } catch (err) {
162
- status.textContent = err?.message ?? 'Upload failed.';
163
- E.toast(err?.message ?? 'Upload failed.', { type: 'error' });
164
- } finally {
165
- uploadBtn.disabled = false;
166
- fileInput.value = '';
167
- }
168
- });
169
-
170
- slideover.open();
171
- await refresh();
172
- }
@@ -1,386 +0,0 @@
1
- /**
2
- * Shared CRUD panel for issuers and receivers.
3
- *
4
- * A sortable Domma table, and a slideover whose form is generated by F.create
5
- * from the same fields[] config the caller already supplies - the fields were
6
- * being assembled by hand with document.createElement, which is the thing
7
- * F.create exists to replace.
8
- *
9
- * It mounts into an ELEMENT rather than a route view, because there are now two
10
- * places that want it: the Issuers and Receivers screens, and two tabs of the
11
- * settings slideover. Nothing here knows which it is in, and nothing here
12
- * subscribes to the router - the caller owns the teardown, since a panel in a
13
- * slideover dies when the slideover closes and a panel in a view dies when the
14
- * route changes.
15
- *
16
- * The slideover takes the form's container as an ELEMENT. E.slideover's own
17
- * setContent sanitises, which strips inputs; passing an element has it appended
18
- * instead (Slideover.create, elements.js), which is the light-DOM route the
19
- * root CLAUDE.md prescribes.
20
- */
21
- import {openMediaPicker} from './media.js?v=8';
22
- import {scanHelp} from '/plugins/_lib/admin/ui/help.js?v=8';
23
-
24
- function escapeHtml(s) {
25
- return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
26
- }
27
-
28
- /**
29
- * Draw a party panel into `host`.
30
- *
31
- * @param {HTMLElement} host
32
- * @param {object} config - {kind, endpoint, label, fields[], intro?}
33
- * @returns {{reload: Function, dispose: Function, parties: object}}
34
- */
35
- export function mountPartyPanel(host, config) {
36
- // One observable holds the list; the table follows it, so a create, an edit
37
- // and a delete each write once and nothing calls a reload by hand.
38
- const parties = M.observable([]);
39
- let table = null;
40
- let menu = null;
41
-
42
- host.textContent = '';
43
- host.classList.add('invoice-party-panel');
44
-
45
- if (config.intro) {
46
- const intro = document.createElement('p');
47
- intro.className = 'text-sm text-muted mb-2';
48
- intro.textContent = config.intro;
49
- host.appendChild(intro);
50
- }
51
-
52
- const head = document.createElement('div');
53
- head.className = 'invoice-panel-head';
54
- const newBtn = document.createElement('button');
55
- newBtn.type = 'button';
56
- newBtn.className = 'btn btn-sm btn-primary';
57
- newBtn.textContent = `New ${config.label}`;
58
- head.appendChild(newBtn);
59
- host.appendChild(head);
60
-
61
- const tableHost = document.createElement('div');
62
- host.appendChild(tableHost);
63
-
64
- /**
65
- * Domma's table has no empty-state option (`emptyMessage` was passed here
66
- * for a year and silently ignored, so an empty list showed a header row
67
- * and nothing else). Ours, and re-evaluated on every data push - otherwise
68
- * it would stay on screen under the first row somebody added.
69
- *
70
- * @param {object[]} data
71
- * @returns {void}
72
- */
73
- function paintEmptyLine(data) {
74
- tableHost.querySelector(':scope > .invoice-empty')?.remove();
75
- if (data.length) return;
76
- const empty = document.createElement('p');
77
- empty.className = 'text-sm text-muted invoice-empty';
78
- empty.textContent = `No ${config.label.toLowerCase()}s yet.`;
79
- tableHost.appendChild(empty);
80
- }
81
-
82
- function buildTable(data) {
83
- if (!tableHost.isConnected && table) return;
84
- if (table) {
85
- table.setData(data);
86
- paintEmptyLine(data);
87
- Domma.icons.scan(tableHost);
88
- return;
89
- }
90
-
91
- const cols = [
92
- { key: 'name', title: 'Name', sortable: true,
93
- render: (v, row) => `<a href="#" data-id="${escapeHtml(row.id)}" class="party-edit invoice-number-link">${escapeHtml(v || '(unnamed)')}</a>`
94
- }
95
- ];
96
- if (config.fields.some((f) => f.key === 'company')) {
97
- cols.push({ key: 'company', title: 'Company', sortable: true, render: (v) => escapeHtml(v || '-') });
98
- }
99
- cols.push(
100
- { key: 'email', title: 'Email', sortable: true, render: (v) => v ? `<a href="mailto:${escapeHtml(v)}">${escapeHtml(v)}</a>` : '-' },
101
- { key: 'vatNumber', title: 'VAT', sortable: false, render: (v) => v ? `<code>${escapeHtml(v)}</code>` : '-' },
102
- // A space, not '': T falls back to the column KEY for a falsy
103
- // title, which printed a column headed "id".
104
- { key: 'id', title: ' ', sortable: false,
105
- render: (id) => `<div class="invoice-row-actions">
106
- <button class="btn btn-sm btn-outline party-edit" data-id="${escapeHtml(id)}" title="Edit"><span data-icon="edit" data-icon-size="14"></span></button>
107
- <button class="btn btn-sm btn-danger party-delete" data-id="${escapeHtml(id)}" title="Delete"><span data-icon="trash" data-icon-size="14"></span></button>
108
- </div>` }
109
- );
110
-
111
- table = T.create(tableHost, {
112
- data,
113
- columns: cols,
114
- pagination: data.length > 10,
115
- pageSize: 10
116
- });
117
-
118
- paintEmptyLine(data);
119
-
120
- tableHost.addEventListener('click', async (e) => {
121
- const editEl = e.target.closest('.party-edit');
122
- const delEl = e.target.closest('.party-delete');
123
- if (editEl) {
124
- e.preventDefault();
125
- openEditor(parties.value.find((p) => p.id === editEl.dataset.id));
126
- }
127
- if (delEl) {
128
- const party = parties.value.find((p) => p.id === delEl.dataset.id);
129
- if (party) await remove(party);
130
- }
131
- });
132
-
133
- // The same gesture the invoice list uses, for the same reason: the row
134
- // is the thing, so the actions belong on the row rather than in a
135
- // column of buttons that grows every time one is added.
136
- menu = E.contextMenu(tableHost, {
137
- match: 'tbody tr',
138
- inherit: false,
139
- items: (target) => {
140
- const id = target?.querySelector?.('[data-id]')?.dataset?.id;
141
- const party = parties.value.find((x) => x.id === id);
142
- if (!party) return [];
143
-
144
- const items = [
145
- { header: party.company || party.name || config.label },
146
- { label: `Edit ${config.label.toLowerCase()}`, icon: 'edit', action: () => openEditor(party) },
147
- { label: 'Duplicate', icon: 'copy', action: () => duplicate(party) }
148
- ];
149
- if (party.email) {
150
- items.push({ label: 'Copy email address', icon: 'copy',
151
- action: () => copyText(party.email) });
152
- }
153
- for (const extra of config.extraItems ?? []) {
154
- items.push(extra(party));
155
- }
156
- items.push(
157
- { type: 'divider' },
158
- { label: 'Delete', icon: 'trash', danger: true, action: () => remove(party) }
159
- );
160
- return items;
161
- }
162
- });
163
-
164
- Domma.icons.scan(tableHost);
165
- scanHelp(tableHost);
166
- }
167
-
168
- /**
169
- * Put text on the clipboard, saying so either way.
170
- *
171
- * `navigator.clipboard` needs a secure context and refuses without one, so
172
- * the failure is reported rather than silently doing nothing.
173
- *
174
- * @param {string} text
175
- * @returns {Promise<void>}
176
- */
177
- async function copyText(text) {
178
- try {
179
- await navigator.clipboard.writeText(text);
180
- E.toast('Copied.', { type: 'success' });
181
- } catch {
182
- E.toast('Your browser would not let the page copy that.', { type: 'error' });
183
- }
184
- }
185
-
186
- /**
187
- * @param {object} party
188
- * @returns {Promise<void>}
189
- */
190
- async function duplicate(party) {
191
- // Only the declared fields, not a spread: an entry read back from the
192
- // server carries an id and whatever else the collection put on it, and
193
- // posting those straight back stores them a second time.
194
- const copy = {};
195
- for (const field of config.fields) copy[field.key] = party[field.key] ?? '';
196
- copy.name = `${party.name || 'Copy'} (copy)`;
197
- try {
198
- const res = await H.post(config.endpoint, copy);
199
- if (res?.error) throw new Error(res.error);
200
- E.toast('Copied.', { type: 'success' });
201
- await reload();
202
- } catch (err) {
203
- E.toast(err?.message ?? 'Could not copy that.', { type: 'error' });
204
- }
205
- }
206
-
207
- /**
208
- * @param {object} party
209
- * @returns {Promise<void>}
210
- */
211
- async function remove(party) {
212
- const ok = await E.confirm(`Delete ${config.label.toLowerCase()} "${party?.name ?? ''}"?`);
213
- if (!ok) return;
214
- try {
215
- await H.delete(`${config.endpoint}/${party.id}`);
216
- E.toast('Deleted.', { type: 'success' });
217
- parties.value = parties.value.filter((p) => p.id !== party.id);
218
- } catch {
219
- E.toast('Delete failed.', { type: 'error' });
220
- }
221
- }
222
-
223
- const stopFeed = M.effect(() => {
224
- const rows = parties.value;
225
- M.untracked(() => buildTable(rows));
226
- });
227
-
228
- async function reload() {
229
- try {
230
- const res = await H.get(config.endpoint);
231
- parties.value = res.data ?? res ?? [];
232
- } catch {
233
- parties.value = [];
234
- E.toast(`Could not load ${config.label.toLowerCase()}s.`, { type: 'error' });
235
- }
236
- }
237
-
238
- function openEditor(existing) {
239
- const isEdit = Boolean(existing);
240
-
241
- // F.create takes a SCHEMA, initial data and options - not a container -
242
- // and the instance renders itself with .renderTo(). It builds an
243
- // M.create model underneath, so getData() reads back through the model
244
- // rather than off the DOM.
245
- const schema = {};
246
- const initial = {};
247
- for (const field of config.fields) {
248
- schema[field.key] = {
249
- type: field.type === 'textarea' ? 'textarea' : 'string',
250
- label: field.label,
251
- required: Boolean(field.required)
252
- };
253
- if (field.hint) schema[field.key].formConfig = { hint: field.hint };
254
- initial[field.key] = existing?.[field.key] ?? '';
255
- }
256
-
257
- const form = F.create(schema, initial, { showSubmitButton: false });
258
-
259
- const wrapper = document.createElement('div');
260
- wrapper.className = 'invoice-party-form';
261
- const fields = document.createElement('div');
262
- form.renderTo(fields);
263
- wrapper.appendChild(fields);
264
-
265
- // A logo is a file, not a URL somebody types. The picker writes the
266
- // chosen path back through the MODEL rather than the input, because
267
- // Forma reads getData() off the model and an input written behind its
268
- // back saves the old value.
269
- for (const field of config.fields.filter((f) => f.media)) {
270
- const input = fields.querySelector(`[name="${field.key}"]`);
271
- if (!input) continue;
272
- const pick = document.createElement('button');
273
- pick.type = 'button';
274
- pick.className = 'btn btn-sm btn-outline invoice-media-pick';
275
- pick.textContent = 'Choose…';
276
- pick.addEventListener('click', () => openMediaPicker((url) => {
277
- form.model.set(field.key, url);
278
- input.value = url;
279
- }));
280
- input.insertAdjacentElement('afterend', pick);
281
- input.parentElement?.classList.add('invoice-media-row');
282
- }
283
-
284
- const actions = document.createElement('div');
285
- actions.className = 'invoice-party-actions';
286
- const cancelBtn = document.createElement('button');
287
- cancelBtn.type = 'button';
288
- cancelBtn.className = 'btn btn-ghost';
289
- cancelBtn.textContent = 'Cancel';
290
- const saveBtn = document.createElement('button');
291
- saveBtn.type = 'button';
292
- saveBtn.className = 'btn btn-primary';
293
- saveBtn.textContent = isEdit ? 'Save changes' : 'Create';
294
- actions.appendChild(cancelBtn);
295
- actions.appendChild(saveBtn);
296
- wrapper.appendChild(actions);
297
-
298
- const slideover = E.slideover({
299
- title: isEdit ? `Edit ${config.label}` : `New ${config.label}`,
300
- size: 'md',
301
- position: 'right',
302
- // close() only hides the panel and destroy() tears the component
303
- // down without detaching its element - measured, not assumed - so
304
- // opening the editor twenty times left twenty panels in the
305
- // document. Both, then, once the slide-out has run. Hanging it off
306
- // onClose covers Escape and the backdrop as well as the buttons.
307
- onClose: () => setTimeout(() => {
308
- slideover.destroy();
309
- slideover.element?.remove();
310
- }, 350)
311
- });
312
-
313
- // Light-DOM projection. setContent() sanitises, which strips the inputs
314
- // F.create has just rendered - root CLAUDE.md, gotcha 4.
315
- slideover.element.appendChild(wrapper);
316
-
317
- cancelBtn.addEventListener('click', () => slideover.close());
318
-
319
- saveBtn.addEventListener('click', async () => {
320
- if (!form.validate()) return;
321
- const values = form.getData();
322
- try {
323
- const res = isEdit
324
- ? await H.put(`${config.endpoint}/${existing.id}`, values)
325
- : await H.post(config.endpoint, values);
326
- if (res.error) throw new Error(res.error);
327
- slideover.close();
328
- E.toast(isEdit ? 'Saved.' : 'Created.', { type: 'success' });
329
- await reload();
330
- } catch (err) {
331
- E.toast(err?.message ?? 'Save failed.', { type: 'error' });
332
- }
333
- });
334
-
335
- slideover.open();
336
- Domma.icons.scan(wrapper);
337
- }
338
-
339
- newBtn.addEventListener('click', () => openEditor(null));
340
-
341
- reload();
342
-
343
- return {
344
- parties,
345
- reload,
346
- dispose: () => {
347
- stopFeed();
348
- menu?.destroy?.();
349
- menu = null;
350
- table?.destroy?.();
351
- table = null;
352
- }
353
- };
354
- }
355
-
356
- /** The issuer fields, shared by the Issuers screen and the settings tab. */
357
- export const ISSUER_FIELDS = [
358
- { key: 'name', label: 'Trading Name', required: true, type: 'text' },
359
- { key: 'email', label: 'Email', type: 'text' },
360
- { key: 'phone', label: 'Phone', type: 'text' },
361
- { key: 'website', label: 'Website', type: 'text' },
362
- { key: 'vatNumber', label: 'VAT Number', type: 'text' },
363
- { key: 'logoUrl', label: 'Logo', type: 'text', media: true,
364
- hint: 'Printed at the top of this issuer’s invoices. Leave it empty to use the logo from Settings > Branding.' },
365
- { key: 'address', label: 'Address', type: 'textarea' },
366
- // Payment, as structured fields rather than one blob: these are read off a
367
- // printed invoice by someone typing them into a banking app.
368
- { key: 'accountName', label: 'Account Name', type: 'text' },
369
- { key: 'bankName', label: 'Bank', type: 'text' },
370
- { key: 'sortCode', label: 'Sort Code', type: 'text' },
371
- { key: 'accountNumber', label: 'Account Number', type: 'text' },
372
- { key: 'iban', label: 'IBAN', type: 'text' },
373
- { key: 'bic', label: 'BIC / SWIFT', type: 'text' },
374
- { key: 'bankDetails', label: 'Other Payment Notes', type: 'textarea' }
375
- ];
376
-
377
- /** The receiver fields, likewise. */
378
- export const RECEIVER_FIELDS = [
379
- { key: 'name', label: 'Contact Name', required: true, type: 'text' },
380
- { key: 'company', label: 'Company', type: 'text' },
381
- { key: 'email', label: 'Email', type: 'text' },
382
- { key: 'phone', label: 'Phone', type: 'text' },
383
- { key: 'vatNumber', label: 'VAT Number', type: 'text' },
384
- { key: 'reference', label: 'Reference', type: 'text' },
385
- { key: 'address', label: 'Address', type: 'textarea' }
386
- ];
@@ -1,127 +0,0 @@
1
- /**
2
- * Invoice plugin - recording a payment.
3
- *
4
- * The ledger has its own routes rather than riding on PUT, which is what keeps
5
- * the stored status reconciled with what has actually been paid; this dialog is
6
- * the admin's way in. Same shape as the Send dialog: F.create for the form,
7
- * light-DOM projection into the slideover, and the panel detached on close.
8
- */
9
- const BASE = '/api/plugins/invoice';
10
-
11
- export const PAYMENT_METHODS = {
12
- 'bank-transfer': 'Bank transfer',
13
- 'card': 'Card',
14
- 'cash': 'Cash',
15
- 'cheque': 'Cheque',
16
- 'direct-debit': 'Direct debit',
17
- 'other': 'Other'
18
- };
19
-
20
- /**
21
- * @param {string} id invoice id
22
- * @param {number} balance what is still outstanding, prefilled as the amount
23
- * @param {Function} [onSaved] called with the updated invoice
24
- * @returns {void}
25
- */
26
- export function openPaymentDialog(id, balance, onSaved) {
27
- const form = F.create({
28
- amount: {
29
- type: 'number', label: 'Amount', required: true,
30
- formConfig: { hint: 'Part payments are fine - the balance follows.' }
31
- },
32
- // Deliberately NO `type`.
33
- //
34
- // `type: 'date'` renders the right control and then makes the form
35
- // impossible to submit: Forma maps it to <input type="date">, which
36
- // writes a STRING back, while M.types.date accepts only a real Date
37
- // instance - so validate() fails with "Expected type date" every time
38
- // and the Record button silently does nothing. Declaring no type skips
39
- // the type check entirely (models.js only checks when def.type is set),
40
- // and the input is given its type below, after render.
41
- date: { label: 'Date received', required: true },
42
- method: {
43
- type: 'select', label: 'Method',
44
- options: Object.entries(PAYMENT_METHODS).map(([value, label]) => ({ value, label }))
45
- },
46
- reference: {
47
- type: 'string', label: 'Reference',
48
- formConfig: { placeholder: 'FT12345', hint: 'Whatever lets you find it on a statement.' }
49
- }
50
- }, {
51
- amount: balance > 0 ? balance : '',
52
- date: new Date().toISOString().slice(0, 10),
53
- method: 'bank-transfer',
54
- reference: ''
55
- }, { showSubmitButton: false });
56
-
57
- const wrapper = document.createElement('div');
58
- wrapper.className = 'invoice-party-form';
59
- const fields = document.createElement('div');
60
- form.renderTo(fields);
61
- // Domma's calendar, not `<input type="date">`: one control in every
62
- // browser, and it writes ISO back through the input, which is what
63
- // form.getData() reads. A payment cannot be received in the future.
64
- const dateInput = fields.querySelector('[name="date"]');
65
- const datePicker = dateInput && E.datePicker(dateInput, {
66
- max: new Date().toISOString().slice(0, 10)
67
- });
68
- wrapper.appendChild(fields);
69
-
70
- const actions = document.createElement('div');
71
- actions.className = 'invoice-party-actions';
72
- const cancelBtn = document.createElement('button');
73
- cancelBtn.type = 'button';
74
- cancelBtn.className = 'btn btn-ghost';
75
- cancelBtn.textContent = 'Cancel';
76
- const saveBtn = document.createElement('button');
77
- saveBtn.type = 'button';
78
- saveBtn.className = 'btn btn-primary';
79
- saveBtn.textContent = 'Record payment';
80
- actions.appendChild(cancelBtn);
81
- actions.appendChild(saveBtn);
82
- wrapper.appendChild(actions);
83
-
84
- const slideover = E.slideover({
85
- title: 'Record Payment',
86
- size: 'md',
87
- position: 'right',
88
- onClose: () => setTimeout(() => {
89
- datePicker?.destroy?.();
90
- slideover.destroy();
91
- slideover.element?.remove();
92
- }, 350)
93
- });
94
- slideover.element.appendChild(wrapper);
95
-
96
- cancelBtn.addEventListener('click', () => slideover.close());
97
-
98
- saveBtn.addEventListener('click', async () => {
99
- if (!form.validate()) return;
100
- const values = form.getData();
101
- if (!(Number(values.amount) > 0)) {
102
- E.toast('The amount has to be more than zero.', { type: 'error' });
103
- return;
104
- }
105
- saveBtn.disabled = true;
106
- try {
107
- const res = await H.post(`${BASE}/invoices/${id}/payments`, {
108
- amount: Number(values.amount),
109
- date: values.date,
110
- method: values.method,
111
- reference: values.reference
112
- });
113
- const out = res?.data ?? res;
114
- if (out?.error) throw new Error(out.error);
115
- slideover.close();
116
- E.toast(out.totals?.balance <= 0 ? 'Payment recorded - this invoice is settled.' : 'Payment recorded.',
117
- { type: 'success' });
118
- if (typeof onSaved === 'function') onSaved(out);
119
- } catch (err) {
120
- E.toast(err?.message ?? 'Could not record the payment.', { type: 'error' });
121
- saveBtn.disabled = false;
122
- }
123
- });
124
-
125
- slideover.open();
126
- Domma.icons.scan(wrapper);
127
- }