domma-cms 0.66.0 → 0.69.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +64 -1
- package/README.md +14 -10
- package/admin/css/admin.css +1 -1
- package/admin/index.html +1 -0
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/analytics-shape.js +1 -0
- package/admin/js/lib/contacts-arrange.js +1 -0
- package/admin/js/lib/help-popover.js +1 -0
- package/admin/js/lib/notes-arrange.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -1
- package/admin/js/lib/plugin-chrome.js +1 -1
- 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 +3 -3
- package/admin/js/lib/todo-arrange.js +1 -0
- package/admin/js/lib/tool-kit.js +1 -0
- package/admin/js/templates/analytics.html +138 -0
- package/admin/js/templates/contacts.html +156 -0
- package/admin/js/templates/notes.html +137 -0
- package/admin/js/templates/plugin-marketplace.html +17 -4
- package/admin/js/templates/plugins.html +13 -7
- package/admin/js/templates/theme.html +38 -0
- package/admin/js/templates/todo.html +113 -0
- package/admin/js/views/analytics.js +62 -0
- package/admin/js/views/contacts.js +174 -0
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/notes.js +104 -0
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +24 -22
- package/admin/js/views/theme.js +1 -1
- package/admin/js/views/todo.js +96 -0
- package/bin/cli.js +4 -10
- package/bin/lib/plugin-version.js +28 -0
- package/bin/update.js +14 -0
- package/config/menus/admin-sidebar.json +1 -1
- package/config/server.json +3 -1
- package/package.json +3 -7
- package/plugins/_lib/admin/mail/reader-view.js +20 -0
- package/plugins/_lib/mail/store.js +21 -1
- package/plugins/blog/CLAUDE.md +237 -0
- package/plugins/blog/admin/css/index.css +178 -1
- package/plugins/blog/admin/templates/blog.html +35 -53
- package/plugins/blog/admin/templates/categories.html +19 -6
- package/plugins/blog/admin/templates/comments.html +26 -9
- package/plugins/blog/admin/templates/post-editor.html +36 -13
- package/plugins/blog/admin/templates/settings.html +2 -11
- package/plugins/blog/admin/views/blog.js +288 -162
- package/plugins/blog/admin/views/categories.js +212 -201
- package/plugins/blog/admin/views/comments.js +207 -158
- package/plugins/blog/admin/views/kit.js +372 -0
- package/plugins/blog/admin/views/post-editor.js +269 -21
- package/plugins/blog/admin/views/settings-cog.js +56 -0
- package/plugins/blog/admin/views/settings.js +16 -86
- package/plugins/blog/blocks/blog-card-compact.css +17 -0
- package/plugins/blog/blocks/blog-card-compact.html +8 -0
- package/plugins/blog/blocks/blog-card-row.css +22 -0
- package/plugins/blog/blocks/blog-card-row.html +11 -0
- package/plugins/blog/blocks/blog-card.css +35 -0
- package/plugins/blog/blocks/blog-card.html +16 -0
- package/plugins/blog/blocks/blog-post-classic.css +15 -0
- package/plugins/blog/blocks/blog-post-classic.html +28 -0
- package/plugins/blog/blocks/blog-post-feature.css +20 -0
- package/plugins/blog/blocks/blog-post-feature.html +30 -0
- package/plugins/blog/blocks/blog-post-minimal.css +10 -0
- package/plugins/blog/blocks/blog-post-minimal.html +23 -0
- package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
- package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
- package/plugins/blog/collections/posts/schema.json +3 -1
- package/plugins/blog/config.js +6 -0
- package/plugins/blog/lib/layouts.js +304 -0
- package/plugins/blog/lib/page.js +158 -0
- package/plugins/blog/lib/render.js +119 -0
- package/plugins/blog/lib/samples.js +234 -0
- package/plugins/blog/plugin.js +225 -15
- package/plugins/blog/plugin.json +21 -81
- package/plugins/blog/plugin.public.js +115 -210
- package/plugins/blog/public/blog.css +177 -0
- package/plugins/blog/public/blog.js +586 -0
- package/plugins/blog/public/samples/aurora.svg +7 -0
- package/plugins/blog/public/samples/dusk.svg +7 -0
- package/plugins/blog/public/samples/ember.svg +7 -0
- package/plugins/blog/public/samples/harbour.svg +7 -0
- package/plugins/blog/public/samples/meadow.svg +7 -0
- package/plugins/blog/public/samples/rosewood.svg +7 -0
- package/plugins/blog/templates/index.html +9 -8
- package/plugins/blog/templates/post.html +4 -17
- package/plugins/blog/tests/layouts.test.js +97 -0
- package/plugins/blog/tests/public.test.js +71 -0
- package/plugins/free-tier.lock.json +74 -0
- package/plugins/mail-reader/CLAUDE.md +78 -0
- package/plugins/mail-reader/plugin.json +2 -1
- package/public/css/theme-switcher.css +1 -0
- package/public/js/analytics.js +1 -0
- package/public/js/theme-switcher.js +1 -0
- package/scripts/build.js +41 -2
- package/scripts/run-tests.mjs +94 -0
- package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
- package/{plugins/contacts/plugin.js → server/routes/api/contacts.js} +71 -43
- package/server/routes/api/dashboard.js +8 -2
- package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
- package/server/routes/api/plugin-marketplace.js +78 -23
- package/server/routes/api/plugins.js +148 -3
- package/server/routes/api/todo.js +178 -0
- package/server/server.js +61 -1
- package/server/services/collections.js +13 -3
- package/server/services/managerClient.js +72 -136
- package/server/services/markdown.js +54 -1
- package/server/services/permissionRegistry.js +50 -0
- package/server/services/pluginEntitlement.js +171 -0
- package/server/services/pluginEnvelope.js +242 -0
- package/server/services/pluginInstaller.js +246 -26
- package/server/services/pluginScaffold.js +5 -0
- package/server/services/plugins.js +117 -7
- package/server/services/presetCollections.js +344 -0
- package/server/services/promoted-plugins-migration.js +254 -0
- package/server/services/publicCors.js +46 -0
- package/server/services/renderer.js +83 -3
- package/server/services/sidebarBadges.js +37 -0
- package/server/services/siteGitignore.js +310 -0
- package/server/services/themeSettings.js +39 -0
- package/config/theme.json +0 -18
- package/plugins/analytics/admin/css/index.css +0 -1
- package/plugins/analytics/admin/templates/analytics.html +0 -70
- package/plugins/analytics/admin/views/analytics.js +0 -176
- package/plugins/analytics/config.js +0 -14
- package/plugins/analytics/plugin.json +0 -60
- package/plugins/analytics/public/inject-body.html +0 -60
- package/plugins/analytics/public/inject-head.html +0 -1
- package/plugins/blog/templates/author.html +0 -9
- package/plugins/blog/templates/category.html +0 -9
- package/plugins/blog/templates/tag.html +0 -9
- package/plugins/contacts/admin/templates/contacts.html +0 -254
- package/plugins/contacts/admin/views/contacts.js +0 -943
- package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
- package/plugins/contacts/collections/user-contacts/schema.json +0 -179
- package/plugins/contacts/config.js +0 -6
- package/plugins/contacts/plugin.json +0 -36
- package/plugins/demo-viewer/config.js +0 -4
- package/plugins/demo-viewer/plugin.js +0 -36
- package/plugins/demo-viewer/plugin.json +0 -9
- package/plugins/invoice/admin/css/index.css +0 -1
- package/plugins/invoice/admin/templates/editor.html +0 -220
- package/plugins/invoice/admin/templates/index.html +0 -173
- package/plugins/invoice/admin/templates/issuers.html +0 -2
- package/plugins/invoice/admin/templates/receivers.html +0 -2
- package/plugins/invoice/admin/views/contacts-source.js +0 -266
- package/plugins/invoice/admin/views/editor.js +0 -434
- package/plugins/invoice/admin/views/export.js +0 -199
- package/plugins/invoice/admin/views/help-content.js +0 -61
- package/plugins/invoice/admin/views/index.js +0 -643
- package/plugins/invoice/admin/views/issuers.js +0 -30
- package/plugins/invoice/admin/views/media.js +0 -172
- package/plugins/invoice/admin/views/party-view.js +0 -386
- package/plugins/invoice/admin/views/payments.js +0 -127
- package/plugins/invoice/admin/views/print.js +0 -130
- package/plugins/invoice/admin/views/receivers.js +0 -55
- package/plugins/invoice/admin/views/send.js +0 -212
- package/plugins/invoice/admin/views/settings.js +0 -594
- package/plugins/invoice/admin/views/view-lifecycle.js +0 -33
- package/plugins/invoice/collections/invoice-issuers/schema.json +0 -82
- package/plugins/invoice/collections/invoice-receivers/schema.json +0 -16
- package/plugins/invoice/collections/invoices/schema.json +0 -33
- package/plugins/invoice/config.js +0 -37
- package/plugins/invoice/pdf.js +0 -164
- package/plugins/invoice/plugin.js +0 -1456
- package/plugins/invoice/plugin.json +0 -86
- package/plugins/invoice/templates/_base.css +0 -1
- package/plugins/invoice/templates/classic-nologo.html +0 -100
- package/plugins/invoice/templates/classic.html +0 -91
- package/plugins/invoice/templates/invoice-print.html +0 -237
- package/plugins/invoice/templates/minimal.html +0 -99
- package/plugins/invoice/templates/modern-nologo.html +0 -114
- package/plugins/invoice/templates/modern.html +0 -113
- package/plugins/invoice/templates/templates.json +0 -11
- package/plugins/notes/admin/templates/notes.html +0 -83
- package/plugins/notes/admin/views/notes.js +0 -281
- package/plugins/notes/collections/user-notes/schema.json +0 -54
- package/plugins/notes/config.js +0 -6
- package/plugins/notes/plugin.json +0 -33
- package/plugins/surveys/admin/templates/audience.html +0 -47
- package/plugins/surveys/admin/templates/results.html +0 -58
- package/plugins/surveys/admin/templates/survey-editor.html +0 -141
- package/plugins/surveys/admin/templates/surveys.html +0 -25
- package/plugins/surveys/admin/views/audience.js +0 -301
- package/plugins/surveys/admin/views/results.js +0 -172
- package/plugins/surveys/admin/views/survey-editor.js +0 -211
- package/plugins/surveys/admin/views/surveys.js +0 -161
- package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
- package/plugins/surveys/collections/survey-groups/schema.json +0 -11
- package/plugins/surveys/collections/survey-invites/schema.json +0 -16
- package/plugins/surveys/collections/surveys/schema.json +0 -23
- package/plugins/surveys/config.js +0 -8
- package/plugins/surveys/lib/audience.js +0 -37
- package/plugins/surveys/lib/campaigns.js +0 -43
- package/plugins/surveys/lib/ledger.js +0 -110
- package/plugins/surveys/lib/sending.js +0 -106
- package/plugins/surveys/lib/stats.js +0 -62
- package/plugins/surveys/lib/submit.js +0 -95
- package/plugins/surveys/lib/tokens.js +0 -28
- package/plugins/surveys/plugin.js +0 -174
- package/plugins/surveys/plugin.json +0 -65
- package/plugins/surveys/plugin.public.js +0 -149
- package/plugins/surveys/public/dist-shared.mjs +0 -1
- package/plugins/surveys/public/survey.css +0 -1
- package/plugins/surveys/public/survey.mjs +0 -1
- package/plugins/surveys/templates/survey-page.html +0 -63
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
- package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
- package/plugins/theme-switcher/config.js +0 -10
- package/plugins/theme-switcher/plugin.js +0 -26
- package/plugins/theme-switcher/plugin.json +0 -74
- package/plugins/theme-switcher/public/inject-body.html +0 -153
- package/plugins/theme-switcher/public/inject-head.html +0 -260
- package/plugins/todo/admin/templates/todo.html +0 -158
- package/plugins/todo/admin/views/todo.js +0 -343
- package/plugins/todo/collections/todos/schema.json +0 -60
- package/plugins/todo/config.js +0 -7
- package/plugins/todo/plugin.js +0 -102
- package/plugins/todo/plugin.json +0 -33
- /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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
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
|
-
}
|