domma-cms 0.55.1 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +130 -2
- package/README.md +4 -4
- package/admin/css/admin.css +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +3 -3
- package/admin/js/lib/page-picker.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -0
- package/admin/js/lib/plugin-chrome.js +1 -0
- package/admin/js/lib/shortcode-context-menu.js +2 -2
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-grouping.test.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/lib/slideover-resizable.js +1 -0
- package/admin/js/templates/context-menu-editor.html +212 -0
- package/admin/js/templates/context-menus.html +16 -0
- package/admin/js/templates/plugin-code.html +1 -1
- package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
- package/admin/js/templates/settings.html +16 -97
- package/admin/js/templates/theme.html +173 -0
- package/admin/js/views/context-menu-editor.js +55 -0
- package/admin/js/views/context-menus.js +5 -0
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +25 -23
- package/admin/js/views/search.js +1 -0
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/theme.js +1 -0
- package/bin/cli.js +2 -0
- package/bin/update.js +13 -2
- package/config/menus/admin-sidebar.json +129 -23
- package/config/plugins.json +5 -5
- package/config/search.json +13 -0
- package/config/theme.json +18 -0
- package/package.json +12 -4
- package/plugins/_lib/admin/mail/compose-window.js +914 -0
- package/plugins/_lib/admin/mail/contacts.js +301 -0
- package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
- package/plugins/_lib/admin/mail/folder-tree.js +254 -0
- package/plugins/_lib/admin/mail/identity.js +480 -0
- package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
- package/plugins/_lib/admin/mail/keyboard.js +136 -0
- package/plugins/_lib/admin/mail/mail.css +1 -0
- package/plugins/_lib/admin/mail/mail.html +71 -0
- package/plugins/_lib/admin/mail/panes.js +253 -0
- package/plugins/_lib/admin/mail/reader-view.js +4453 -0
- package/plugins/_lib/admin/mail/resizable.js +26 -0
- package/plugins/_lib/admin/mail/rules.js +343 -0
- package/plugins/_lib/admin/mail/scheduling.js +203 -0
- package/plugins/_lib/admin/mail/section-kit.js +277 -0
- package/plugins/_lib/admin/mail/templates.js +238 -0
- package/plugins/_lib/admin/mail/threads.js +200 -0
- package/plugins/_lib/admin/mail/vacation.js +269 -0
- package/plugins/_lib/admin/ui/help.css +1 -0
- package/plugins/_lib/admin/ui/help.js +174 -0
- package/plugins/_lib/admin/ui/resizable.js +151 -0
- package/plugins/_lib/dataStore.js +117 -0
- package/plugins/_lib/mail/accounts.js +919 -0
- package/plugins/_lib/mail/bodyTokens.js +101 -0
- package/plugins/_lib/mail/compose.js +256 -0
- package/plugins/_lib/mail/defaults.js +57 -0
- package/plugins/_lib/mail/diagnostics.js +154 -0
- package/plugins/_lib/mail/envelope.js +161 -0
- package/plugins/_lib/mail/folders.js +192 -0
- package/plugins/_lib/mail/handoff.js +274 -0
- package/plugins/_lib/mail/imapPool.js +293 -0
- package/plugins/_lib/mail/mbox.js +74 -0
- package/plugins/_lib/mail/pollSchedule.js +79 -0
- package/plugins/_lib/mail/poller.js +135 -0
- package/plugins/_lib/mail/priority.js +138 -0
- package/plugins/_lib/mail/readRoutes.js +680 -0
- package/plugins/_lib/mail/render.js +291 -0
- package/plugins/_lib/mail/ruleRunner.js +152 -0
- package/plugins/_lib/mail/scheduler.js +254 -0
- package/plugins/_lib/mail/secretbox.js +229 -0
- package/plugins/_lib/mail/send.js +396 -0
- package/plugins/_lib/mail/store.js +1002 -0
- package/plugins/_lib/mail/sync.js +292 -0
- package/plugins/_lib/mail/syncPlan.js +126 -0
- package/plugins/_lib/mail/syncSelection.js +82 -0
- package/plugins/_lib/mail/unsubscribe.js +183 -0
- package/plugins/_lib/mail/vacationRunner.js +114 -0
- package/plugins/_lib/mail/write.js +473 -0
- package/plugins/_lib/schemaSync.js +83 -0
- package/plugins/_template/admin/css/index.css +0 -0
- package/plugins/_template/admin/templates/index.html +4 -4
- package/plugins/_template/admin/views/index.js +7 -0
- package/plugins/analytics/admin/css/index.css +1 -0
- package/plugins/analytics/admin/templates/analytics.html +22 -13
- package/plugins/analytics/plugin.json +3 -0
- package/plugins/blog/admin/css/index.css +1 -0
- package/plugins/blog/admin/templates/blog.html +30 -18
- package/plugins/blog/admin/templates/categories.html +2 -2
- package/plugins/blog/admin/templates/comments.html +2 -2
- package/plugins/blog/admin/templates/post-editor.html +34 -34
- package/plugins/blog/admin/templates/settings.html +6 -3
- package/plugins/blog/admin/views/blog.js +8 -5
- package/plugins/blog/admin/views/categories.js +5 -10
- package/plugins/blog/admin/views/comments.js +5 -5
- package/plugins/blog/admin/views/post-editor.js +39 -20
- package/plugins/blog/admin/views/settings.js +52 -50
- package/plugins/blog/collections/categories/schema.json +7 -6
- package/plugins/blog/collections/comments/schema.json +11 -10
- package/plugins/blog/collections/posts/schema.json +14 -13
- package/plugins/blog/plugin.js +36 -13
- package/plugins/blog/plugin.json +13 -5
- package/plugins/blog/plugin.public.js +312 -0
- package/plugins/contacts/admin/css/index.css +1 -0
- package/plugins/contacts/admin/templates/contacts.html +128 -0
- package/plugins/contacts/admin/views/contacts.js +237 -4
- package/plugins/contacts/collections/user-contacts/schema.json +108 -0
- package/plugins/contacts/plugin.js +214 -27
- package/plugins/contacts/plugin.json +4 -1
- package/plugins/invoice/admin/css/index.css +1 -0
- package/plugins/invoice/admin/templates/editor.html +140 -49
- package/plugins/invoice/admin/templates/index.html +153 -23
- package/plugins/invoice/admin/templates/issuers.html +2 -5
- package/plugins/invoice/admin/templates/receivers.html +2 -5
- package/plugins/invoice/admin/views/contacts-source.js +266 -0
- package/plugins/invoice/admin/views/editor.js +366 -199
- package/plugins/invoice/admin/views/export.js +199 -0
- package/plugins/invoice/admin/views/help-content.js +61 -0
- package/plugins/invoice/admin/views/index.js +582 -94
- package/plugins/invoice/admin/views/issuers.js +24 -17
- package/plugins/invoice/admin/views/media.js +172 -0
- package/plugins/invoice/admin/views/party-view.js +305 -67
- package/plugins/invoice/admin/views/payments.js +127 -0
- package/plugins/invoice/admin/views/print.js +130 -0
- package/plugins/invoice/admin/views/receivers.js +49 -16
- package/plugins/invoice/admin/views/send.js +212 -0
- package/plugins/invoice/admin/views/settings.js +594 -0
- package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
- package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
- package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
- package/plugins/invoice/collections/invoices/schema.json +19 -13
- package/plugins/invoice/config.js +27 -6
- package/plugins/invoice/pdf.js +164 -0
- package/plugins/invoice/plugin.js +1217 -44
- package/plugins/invoice/plugin.json +10 -9
- package/plugins/invoice/templates/_base.css +1 -0
- package/plugins/invoice/templates/classic-nologo.html +100 -0
- package/plugins/invoice/templates/classic.html +91 -0
- package/plugins/invoice/templates/invoice-print.html +24 -0
- package/plugins/invoice/templates/minimal.html +99 -0
- package/plugins/invoice/templates/modern-nologo.html +114 -0
- package/plugins/invoice/templates/modern.html +113 -0
- package/plugins/invoice/templates/templates.json +11 -0
- package/plugins/mail-reader/admin/views/mail.js +19 -0
- package/plugins/mail-reader/config.js +7 -0
- package/plugins/mail-reader/plugin.js +48 -0
- package/plugins/mail-reader/plugin.json +33 -0
- package/plugins/notes/admin/views/notes.js +1 -1
- package/plugins/notes/plugin.json +2 -2
- package/plugins/surveys/lib/audience.js +37 -0
- package/plugins/surveys/lib/campaigns.js +43 -0
- package/plugins/surveys/lib/ledger.js +110 -0
- package/plugins/surveys/lib/sending.js +106 -0
- package/plugins/surveys/lib/stats.js +62 -0
- package/plugins/surveys/lib/submit.js +95 -0
- package/plugins/surveys/lib/tokens.js +28 -0
- package/plugins/surveys/plugin.public.js +149 -0
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
- package/public/css/forms.css +1 -1
- package/public/css/search.css +1 -0
- package/public/css/site.css +1 -1
- package/public/js/collection-context.js +2 -2
- package/public/js/context-menus.js +1 -0
- package/public/js/form-logic-engine.js +1 -1
- package/public/js/forms.js +2 -2
- package/public/js/search.js +1 -0
- package/public/js/site.js +1 -1
- package/scripts/build.js +37 -3
- package/scripts/copy-domma.js +48 -0
- package/scripts/seed.js +1996 -0
- package/server/routes/api/collections.js +34 -0
- package/server/routes/api/context-menus.js +104 -0
- package/server/routes/api/forms.js +42 -3
- package/server/routes/api/notifications.js +69 -19
- package/server/routes/api/plugins.js +50 -6
- package/server/routes/api/search.js +43 -0
- package/server/routes/api/theme.js +69 -0
- package/server/routes/public.js +42 -7
- package/server/server.js +74 -0
- package/server/services/adapters/FileAdapter.js +6 -1
- package/server/services/content.js +26 -0
- package/server/services/contextMenus.js +477 -0
- package/server/services/markdown.js +70 -9
- package/server/services/permissionRegistry.js +24 -0
- package/server/services/pluginFiles.js +52 -11
- package/server/services/plugins.js +229 -6
- package/server/services/renderer.js +144 -22
- package/server/services/roles.js +1 -1
- package/server/services/search-migration.js +82 -0
- package/server/services/search.js +413 -0
- package/server/services/sidebar-migration.js +1 -0
- package/server/services/themeSettings.js +541 -0
- package/server/services/users.js +8 -0
- package/server/templates/page.html +4 -2
- package/plugins/contacts/data/contacts.json +0 -20
- package/plugins/notes/data/notes.json +0 -1
- package/plugins/site-search/admin/views/site-search.js +0 -116
- package/plugins/site-search/config.js +0 -15
- package/plugins/site-search/plugin.js +0 -188
- package/plugins/site-search/plugin.json +0 -40
- package/plugins/site-search/public/inject-body.html +0 -17
- package/plugins/site-search/public/inject-head.html +0 -1
- package/plugins/site-search/public/search.css +0 -1
- package/plugins/site-search/public/search.js +0 -1
- package/plugins/todo/data/todos.json +0 -1
|
@@ -1,23 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Invoice plugin - issuers
|
|
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.
|
|
3
8
|
*/
|
|
4
|
-
import {
|
|
9
|
+
import {ISSUER_FIELDS, mountPartyPanel} from './party-view.js?v=8';
|
|
10
|
+
import {disposeOnLeave} from './view-lifecycle.js?v=8';
|
|
5
11
|
|
|
6
12
|
export const issuersView = {
|
|
7
13
|
templateUrl: '/plugins/invoice/admin/templates/issuers.html',
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
+
}
|
|
23
30
|
};
|
|
@@ -0,0 +1,172 @@
|
|
|
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,27 +1,96 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared CRUD
|
|
3
|
-
*
|
|
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.
|
|
4
20
|
*/
|
|
21
|
+
import {openMediaPicker} from './media.js?v=8';
|
|
22
|
+
import {scanHelp} from '/plugins/_lib/admin/ui/help.js?v=8';
|
|
23
|
+
|
|
5
24
|
function escapeHtml(s) {
|
|
6
25
|
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
7
26
|
}
|
|
8
27
|
|
|
9
|
-
|
|
10
|
-
|
|
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([]);
|
|
11
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
|
+
}
|
|
12
81
|
|
|
13
82
|
function buildTable(data) {
|
|
14
|
-
|
|
15
|
-
if (!el) return;
|
|
83
|
+
if (!tableHost.isConnected && table) return;
|
|
16
84
|
if (table) {
|
|
17
85
|
table.setData(data);
|
|
18
|
-
|
|
86
|
+
paintEmptyLine(data);
|
|
87
|
+
Domma.icons.scan(tableHost);
|
|
19
88
|
return;
|
|
20
89
|
}
|
|
21
90
|
|
|
22
91
|
const cols = [
|
|
23
92
|
{ key: 'name', title: 'Name', sortable: true,
|
|
24
|
-
render: (v, row) => `<a href="#" data-id="${escapeHtml(row.id)}" class="party-edit
|
|
93
|
+
render: (v, row) => `<a href="#" data-id="${escapeHtml(row.id)}" class="party-edit invoice-number-link">${escapeHtml(v || '(unnamed)')}</a>`
|
|
25
94
|
}
|
|
26
95
|
];
|
|
27
96
|
if (config.fields.some((f) => f.key === 'company')) {
|
|
@@ -30,105 +99,230 @@ export async function mountPartyView($container, config) {
|
|
|
30
99
|
cols.push(
|
|
31
100
|
{ key: 'email', title: 'Email', sortable: true, render: (v) => v ? `<a href="mailto:${escapeHtml(v)}">${escapeHtml(v)}</a>` : '-' },
|
|
32
101
|
{ key: 'vatNumber', title: 'VAT', sortable: false, render: (v) => v ? `<code>${escapeHtml(v)}</code>` : '-' },
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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>
|
|
37
108
|
</div>` }
|
|
38
109
|
);
|
|
39
110
|
|
|
40
|
-
table = T.create(
|
|
111
|
+
table = T.create(tableHost, {
|
|
112
|
+
data,
|
|
113
|
+
columns: cols,
|
|
114
|
+
pagination: data.length > 10,
|
|
115
|
+
pageSize: 10
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
paintEmptyLine(data);
|
|
41
119
|
|
|
42
|
-
|
|
120
|
+
tableHost.addEventListener('click', async (e) => {
|
|
43
121
|
const editEl = e.target.closest('.party-edit');
|
|
44
122
|
const delEl = e.target.closest('.party-delete');
|
|
45
123
|
if (editEl) {
|
|
46
124
|
e.preventDefault();
|
|
47
|
-
|
|
48
|
-
openEditor(party);
|
|
125
|
+
openEditor(parties.value.find((p) => p.id === editEl.dataset.id));
|
|
49
126
|
}
|
|
50
127
|
if (delEl) {
|
|
51
|
-
const party = parties.find((p) => p.id === delEl.dataset.id);
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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));
|
|
60
155
|
}
|
|
156
|
+
items.push(
|
|
157
|
+
{ type: 'divider' },
|
|
158
|
+
{ label: 'Delete', icon: 'trash', danger: true, action: () => remove(party) }
|
|
159
|
+
);
|
|
160
|
+
return items;
|
|
61
161
|
}
|
|
62
162
|
});
|
|
63
163
|
|
|
64
|
-
Domma.icons.scan(
|
|
164
|
+
Domma.icons.scan(tableHost);
|
|
165
|
+
scanHelp(tableHost);
|
|
65
166
|
}
|
|
66
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
|
+
|
|
67
228
|
async function reload() {
|
|
68
229
|
try {
|
|
69
230
|
const res = await H.get(config.endpoint);
|
|
70
|
-
parties = res.data ?? res ?? [];
|
|
231
|
+
parties.value = res.data ?? res ?? [];
|
|
71
232
|
} catch {
|
|
72
|
-
parties = [];
|
|
233
|
+
parties.value = [];
|
|
73
234
|
E.toast(`Could not load ${config.label.toLowerCase()}s.`, { type: 'error' });
|
|
74
235
|
}
|
|
75
|
-
buildTable(parties);
|
|
76
236
|
}
|
|
77
237
|
|
|
78
238
|
function openEditor(existing) {
|
|
79
239
|
const isEdit = Boolean(existing);
|
|
80
|
-
const form = document.createElement('div');
|
|
81
|
-
form.style.cssText = 'display:flex;flex-direction:column;gap:.75rem;padding:.5rem;';
|
|
82
240
|
|
|
83
|
-
|
|
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 = {};
|
|
84
247
|
for (const field of config.fields) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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');
|
|
104
282
|
}
|
|
105
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';
|
|
106
290
|
const saveBtn = document.createElement('button');
|
|
107
291
|
saveBtn.type = 'button';
|
|
108
|
-
saveBtn.className = 'btn btn-primary
|
|
292
|
+
saveBtn.className = 'btn btn-primary';
|
|
109
293
|
saveBtn.textContent = isEdit ? 'Save changes' : 'Create';
|
|
110
|
-
|
|
294
|
+
actions.appendChild(cancelBtn);
|
|
295
|
+
actions.appendChild(saveBtn);
|
|
296
|
+
wrapper.appendChild(actions);
|
|
111
297
|
|
|
112
298
|
const slideover = E.slideover({
|
|
113
299
|
title: isEdit ? `Edit ${config.label}` : `New ${config.label}`,
|
|
114
300
|
size: 'md',
|
|
115
301
|
position: 'right',
|
|
116
|
-
|
|
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)
|
|
117
311
|
});
|
|
118
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
|
+
|
|
119
319
|
saveBtn.addEventListener('click', async () => {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
body[field.key] = inputs[field.key].value;
|
|
123
|
-
}
|
|
124
|
-
if (!body.name?.trim()) {
|
|
125
|
-
E.toast('Name is required.', { type: 'error' });
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
320
|
+
if (!form.validate()) return;
|
|
321
|
+
const values = form.getData();
|
|
128
322
|
try {
|
|
129
323
|
const res = isEdit
|
|
130
|
-
? await H.put(`${config.endpoint}/${existing.id}`,
|
|
131
|
-
: await H.post(config.endpoint,
|
|
324
|
+
? await H.put(`${config.endpoint}/${existing.id}`, values)
|
|
325
|
+
: await H.post(config.endpoint, values);
|
|
132
326
|
if (res.error) throw new Error(res.error);
|
|
133
327
|
slideover.close();
|
|
134
328
|
E.toast(isEdit ? 'Saved.' : 'Created.', { type: 'success' });
|
|
@@ -139,10 +333,54 @@ export async function mountPartyView($container, config) {
|
|
|
139
333
|
});
|
|
140
334
|
|
|
141
335
|
slideover.open();
|
|
336
|
+
Domma.icons.scan(wrapper);
|
|
142
337
|
}
|
|
143
338
|
|
|
144
|
-
|
|
339
|
+
newBtn.addEventListener('click', () => openEditor(null));
|
|
340
|
+
|
|
341
|
+
reload();
|
|
145
342
|
|
|
146
|
-
|
|
147
|
-
|
|
343
|
+
return {
|
|
344
|
+
parties,
|
|
345
|
+
reload,
|
|
346
|
+
dispose: () => {
|
|
347
|
+
stopFeed();
|
|
348
|
+
menu?.destroy?.();
|
|
349
|
+
menu = null;
|
|
350
|
+
table?.destroy?.();
|
|
351
|
+
table = null;
|
|
352
|
+
}
|
|
353
|
+
};
|
|
148
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
|
+
];
|