domma-cms 0.54.2 → 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 +151 -77
- 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/menu-editor.html +19 -25
- 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 +27 -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/menu-editor.js +13 -13
- 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 +11 -2
- package/bin/lib/smtp-defaults.js +53 -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/menu-highlight.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/menu-decor.mjs +1 -1
- 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/scripts/setup.js +8 -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/email.js +29 -3
- package/server/services/health.js +23 -2
- package/server/services/markdown.js +70 -9
- package/server/services/menuRender.js +28 -4
- package/server/services/menus.js +25 -1
- 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 +148 -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
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invoice plugin - opening the printable invoice.
|
|
3
|
+
*
|
|
4
|
+
* `authenticate` is Bearer-only (see server/middleware/auth.js), so a plain
|
|
5
|
+
* <a href target="_blank"> to /invoices/:id/print arrives with no Authorization
|
|
6
|
+
* header and comes back 401 every time. The markup is fetched through H, which
|
|
7
|
+
* the admin's http-interceptor tokenises, and written into a window opened
|
|
8
|
+
* SYNCHRONOUSLY on the click - a window opened after an await counts as
|
|
9
|
+
* unsolicited and the pop-up blocker eats it.
|
|
10
|
+
*/
|
|
11
|
+
const BASE = '/api/plugins/invoice';
|
|
12
|
+
|
|
13
|
+
const PLACEHOLDER = '<!doctype html><html><head><title>Invoice</title></head>' +
|
|
14
|
+
'<body style="font:14px system-ui,sans-serif;padding:2rem;color:#555;">Preparing invoice…</body></html>';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} id invoice id
|
|
18
|
+
* @param {boolean} [auto] have the printout raise the print dialog itself
|
|
19
|
+
* @returns {Promise<void>}
|
|
20
|
+
*/
|
|
21
|
+
export async function openPrintWindow(id, auto = false) {
|
|
22
|
+
const win = window.open('', '_blank');
|
|
23
|
+
if (!win) {
|
|
24
|
+
E.toast('Allow pop-ups for this site to print an invoice.', { type: 'error' });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
win.document.write(PLACEHOLDER);
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const res = await H.get(`${BASE}/invoices/${id}/print-html${auto ? '?print=1' : ''}`);
|
|
31
|
+
const html = (res?.data ?? res)?.html;
|
|
32
|
+
if (!html) throw new Error('The server returned an empty invoice.');
|
|
33
|
+
win.document.open();
|
|
34
|
+
win.document.write(html);
|
|
35
|
+
win.document.close();
|
|
36
|
+
} catch (err) {
|
|
37
|
+
win.close();
|
|
38
|
+
E.toast(err?.message ?? 'Could not open the printout.', { type: 'error' });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Does this host have a renderer? Asked once and remembered - the answer cannot
|
|
44
|
+
* change without a server restart, and both views ask on every mount.
|
|
45
|
+
*
|
|
46
|
+
* @returns {Promise<boolean>}
|
|
47
|
+
*/
|
|
48
|
+
let pdfAvailable = null;
|
|
49
|
+
|
|
50
|
+
export async function canDownloadPdf() {
|
|
51
|
+
if (pdfAvailable !== null) return pdfAvailable;
|
|
52
|
+
try {
|
|
53
|
+
const res = await H.get(`${BASE}/pdf/status`);
|
|
54
|
+
pdfAvailable = Boolean((res?.data ?? res)?.available);
|
|
55
|
+
} catch {
|
|
56
|
+
pdfAvailable = false;
|
|
57
|
+
}
|
|
58
|
+
return pdfAvailable;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Download the server-rendered PDF.
|
|
63
|
+
*
|
|
64
|
+
* A download is a NAVIGATION, so it cannot carry the admin's bearer token - the
|
|
65
|
+
* same reason the Print button could not link straight at /print. The server
|
|
66
|
+
* mints a two-minute token bound to this one invoice and puts it in the query
|
|
67
|
+
* string, and an <a download> click is what turns that URL into a file without
|
|
68
|
+
* taking the SPA off its route.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} id
|
|
71
|
+
* @returns {Promise<void>}
|
|
72
|
+
*/
|
|
73
|
+
export async function downloadPdf(id) {
|
|
74
|
+
try {
|
|
75
|
+
const res = await H.post(`${BASE}/invoices/${id}/pdf-link`, {});
|
|
76
|
+
const link = res?.data ?? res;
|
|
77
|
+
if (!link?.url) throw new Error(link?.error || 'No download link was returned.');
|
|
78
|
+
|
|
79
|
+
const a = document.createElement('a');
|
|
80
|
+
a.href = link.url;
|
|
81
|
+
a.download = link.filename || 'invoice.pdf';
|
|
82
|
+
a.rel = 'noopener';
|
|
83
|
+
document.body.appendChild(a);
|
|
84
|
+
a.click();
|
|
85
|
+
a.remove();
|
|
86
|
+
E.toast('Preparing your PDF…', { type: 'info' });
|
|
87
|
+
} catch (err) {
|
|
88
|
+
E.toast(err?.message ?? 'Could not build the PDF.', { type: 'error' });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Download one PDF holding every invoice given, a page each.
|
|
94
|
+
*
|
|
95
|
+
* Same two-minute token dance as the single download, for the same reason - a
|
|
96
|
+
* download is a navigation and carries no bearer token. The ids are POSTed
|
|
97
|
+
* rather than put in the query string: the server keeps the list and the URL
|
|
98
|
+
* carries only a claim ticket, so the link stays short whether it is three
|
|
99
|
+
* invoices or a hundred.
|
|
100
|
+
*
|
|
101
|
+
* The toast is not decoration. Chrome lays out every sheet before the first
|
|
102
|
+
* byte arrives, so a large batch is several seconds during which an <a> click
|
|
103
|
+
* looks exactly like a button that did nothing.
|
|
104
|
+
*
|
|
105
|
+
* @param {string[]} ids
|
|
106
|
+
* @returns {Promise<void>}
|
|
107
|
+
*/
|
|
108
|
+
export async function downloadPdfBatch(ids) {
|
|
109
|
+
if (!ids.length) {
|
|
110
|
+
E.toast('There are no invoices to export.', { type: 'info' });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
E.toast(`Building a PDF of ${ids.length} invoice${ids.length === 1 ? '' : 's'}…`, { type: 'info' });
|
|
116
|
+
const res = await H.post(`${BASE}/invoices/batch-pdf-link`, { ids });
|
|
117
|
+
const link = res?.data ?? res;
|
|
118
|
+
if (!link?.url) throw new Error(link?.error || 'No download link was returned.');
|
|
119
|
+
|
|
120
|
+
const a = document.createElement('a');
|
|
121
|
+
a.href = link.url;
|
|
122
|
+
a.download = link.filename || 'invoices.pdf';
|
|
123
|
+
a.rel = 'noopener';
|
|
124
|
+
document.body.appendChild(a);
|
|
125
|
+
a.click();
|
|
126
|
+
a.remove();
|
|
127
|
+
} catch (err) {
|
|
128
|
+
E.toast(err?.message ?? 'Could not build the PDF.', { type: 'error' });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -1,22 +1,55 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Invoice plugin - receivers
|
|
2
|
+
* Invoice plugin - receivers screen.
|
|
3
|
+
*
|
|
4
|
+
* The same panel the settings slideover puts in its Receivers tab, plus the
|
|
5
|
+
* Contacts import - a receiver is a contact's billing side, and Contacts
|
|
6
|
+
* already holds the company name, the billing address and the VAT number.
|
|
3
7
|
*/
|
|
4
|
-
import {
|
|
8
|
+
import {RECEIVER_FIELDS, mountPartyPanel} from './party-view.js?v=8';
|
|
9
|
+
import {billingProfile, contactsAvailable, openContactPicker} from './contacts-source.js?v=8';
|
|
10
|
+
import {disposeOnLeave} from './view-lifecycle.js?v=8';
|
|
5
11
|
|
|
6
12
|
export const receiversView = {
|
|
7
13
|
templateUrl: '/plugins/invoice/admin/templates/receivers.html',
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
14
|
+
|
|
15
|
+
async onMount($container) {
|
|
16
|
+
const host = $container.find('#party-host').get(0);
|
|
17
|
+
if (!host) return;
|
|
18
|
+
|
|
19
|
+
const panel = mountPartyPanel(host, {
|
|
20
|
+
kind: 'receiver',
|
|
21
|
+
endpoint: '/api/plugins/invoice/receivers',
|
|
22
|
+
label: 'Receiver',
|
|
23
|
+
fields: RECEIVER_FIELDS
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
disposeOnLeave(host, panel.dispose);
|
|
27
|
+
|
|
28
|
+
if (await contactsAvailable()) {
|
|
29
|
+
const importBtn = document.createElement('button');
|
|
30
|
+
importBtn.type = 'button';
|
|
31
|
+
importBtn.className = 'btn btn-sm btn-outline';
|
|
32
|
+
importBtn.textContent = 'Import from Contacts';
|
|
33
|
+
importBtn.addEventListener('click', () => openContactPicker({
|
|
34
|
+
title: 'Import a receiver',
|
|
35
|
+
onPick: async (contact) => {
|
|
36
|
+
try {
|
|
37
|
+
// The billing profile uses the receiver's own field
|
|
38
|
+
// names, which is the point of that endpoint - it goes
|
|
39
|
+
// straight to POST /receivers with nothing to translate.
|
|
40
|
+
const profile = await billingProfile(contact.id);
|
|
41
|
+
const res = await H.post('/api/plugins/invoice/receivers', profile);
|
|
42
|
+
if (res?.error) throw new Error(res.error);
|
|
43
|
+
E.toast(`${profile.company || profile.name} imported.`, { type: 'success' });
|
|
44
|
+
await panel.reload();
|
|
45
|
+
} catch (err) {
|
|
46
|
+
E.toast(err?.message ?? 'Could not import that contact.', { type: 'error' });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}));
|
|
50
|
+
host.querySelector('.invoice-panel-head')?.prepend(importBtn);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
Domma.icons.scan($container.get(0));
|
|
54
|
+
}
|
|
22
55
|
};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invoice plugin - the Send dialog.
|
|
3
|
+
*
|
|
4
|
+
* The defaults (recipient, subject, covering note, whether a PDF can be
|
|
5
|
+
* attached) come from the server rather than being templated here, so the
|
|
6
|
+
* settings that shape the email have one reader.
|
|
7
|
+
*
|
|
8
|
+
* Recipients can also come from Contacts - one contact, or everybody in a
|
|
9
|
+
* group. They are appended to the To field as text rather than held in a
|
|
10
|
+
* parallel list, because the field has to stay typeable: half the time the
|
|
11
|
+
* address someone wants is an accounts inbox that is in nobody's contacts.
|
|
12
|
+
*
|
|
13
|
+
* The form is F.create - schema first, then .renderTo() - and the panel is
|
|
14
|
+
* projected into the slideover's light DOM, because setContent() sanitises and
|
|
15
|
+
* would strip the inputs.
|
|
16
|
+
*/
|
|
17
|
+
import {addressesFor, contactsAvailable, openContactPicker} from './contacts-source.js?v=8';
|
|
18
|
+
|
|
19
|
+
const BASE = '/api/plugins/invoice';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Merge new addresses into a comma-separated To field, without duplicates.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} current
|
|
25
|
+
* @param {string[]} extra
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
export function mergeRecipients(current, extra) {
|
|
29
|
+
const seen = new Set();
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const item of [...String(current ?? '').split(/[,;]/), ...extra]) {
|
|
32
|
+
const address = String(item ?? '').trim();
|
|
33
|
+
if (!address || seen.has(address.toLowerCase())) continue;
|
|
34
|
+
seen.add(address.toLowerCase());
|
|
35
|
+
out.push(address);
|
|
36
|
+
}
|
|
37
|
+
return out.join(', ');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} id invoice id
|
|
42
|
+
* @param {Function} [onSent] called with the updated invoice
|
|
43
|
+
* @returns {Promise<void>}
|
|
44
|
+
*/
|
|
45
|
+
export async function openSendDialog(id, onSent) {
|
|
46
|
+
let defaults;
|
|
47
|
+
try {
|
|
48
|
+
const res = await H.get(`${BASE}/invoices/${id}/send-defaults`);
|
|
49
|
+
defaults = res?.data ?? res;
|
|
50
|
+
if (defaults?.error) throw new Error(defaults.error);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
E.toast(err?.message ?? 'Could not prepare the email.', { type: 'error' });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const haveContacts = await contactsAvailable();
|
|
57
|
+
|
|
58
|
+
const schema = {
|
|
59
|
+
to: { type: 'string', label: 'To', required: true,
|
|
60
|
+
formConfig: { placeholder: 'name@example.com',
|
|
61
|
+
hint: defaults.to
|
|
62
|
+
? 'Separate several addresses with commas.'
|
|
63
|
+
: 'This receiver has no email address on file.' } },
|
|
64
|
+
subject: { type: 'string', label: 'Subject', required: true },
|
|
65
|
+
message: { type: 'textarea', label: 'Covering note',
|
|
66
|
+
formConfig: { rows: 3, hint: 'Sent above the invoice summary.' } }
|
|
67
|
+
};
|
|
68
|
+
if (defaults.canAttachPdf) {
|
|
69
|
+
schema.attachPdf = { type: 'boolean', label: 'Attach a PDF',
|
|
70
|
+
formConfig: { hint: 'Without it the invoice is sent inline instead.' } };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const form = F.create(schema, {
|
|
74
|
+
to: defaults.to || '',
|
|
75
|
+
subject: defaults.subject || '',
|
|
76
|
+
message: defaults.message || '',
|
|
77
|
+
attachPdf: Boolean(defaults.attachPdf)
|
|
78
|
+
}, { showSubmitButton: false });
|
|
79
|
+
|
|
80
|
+
const wrapper = document.createElement('div');
|
|
81
|
+
wrapper.className = 'invoice-party-form';
|
|
82
|
+
|
|
83
|
+
if (defaults.sendCount) {
|
|
84
|
+
const trail = document.createElement('p');
|
|
85
|
+
trail.className = 'text-muted text-sm mb-0';
|
|
86
|
+
const when = defaults.sentAt ? D(defaults.sentAt).format('DD MMM YYYY HH:mm') : 'previously';
|
|
87
|
+
trail.textContent = `Last sent ${when} (${defaults.sendCount} ${defaults.sendCount === 1 ? 'time' : 'times'}).`;
|
|
88
|
+
wrapper.appendChild(trail);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const fields = document.createElement('div');
|
|
92
|
+
form.renderTo(fields);
|
|
93
|
+
wrapper.appendChild(fields);
|
|
94
|
+
|
|
95
|
+
// Contacts, appended to whatever is already in To. The value goes back
|
|
96
|
+
// through the MODEL as well as the input: Forma reads getData() off the
|
|
97
|
+
// model, so an input written behind its back would send the old address.
|
|
98
|
+
if (haveContacts) {
|
|
99
|
+
const toInput = fields.querySelector('[name="to"]');
|
|
100
|
+
if (toInput) {
|
|
101
|
+
const row = document.createElement('div');
|
|
102
|
+
row.className = 'invoice-recipient-actions';
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param {object[]} contacts
|
|
106
|
+
* @returns {void}
|
|
107
|
+
*/
|
|
108
|
+
const add = (contacts) => {
|
|
109
|
+
const found = contacts.flatMap((c) => addressesFor(c).slice(0, 1));
|
|
110
|
+
const missing = contacts.filter((c) => addressesFor(c).length === 0);
|
|
111
|
+
if (!found.length) {
|
|
112
|
+
E.toast('None of those contacts has an email address.', { type: 'error' });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const merged = mergeRecipients(form.getData().to, found);
|
|
116
|
+
form.setData({ to: merged });
|
|
117
|
+
toInput.value = merged;
|
|
118
|
+
if (missing.length) {
|
|
119
|
+
E.toast(`${missing.length} contact(s) had no email address and were skipped.`,
|
|
120
|
+
{ type: 'warning' });
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const one = document.createElement('button');
|
|
125
|
+
one.type = 'button';
|
|
126
|
+
one.className = 'btn btn-sm btn-outline';
|
|
127
|
+
one.textContent = 'Add a contact';
|
|
128
|
+
one.addEventListener('click', () => openContactPicker({
|
|
129
|
+
title: 'Add a recipient',
|
|
130
|
+
onPick: (contact) => add([contact])
|
|
131
|
+
}));
|
|
132
|
+
row.appendChild(one);
|
|
133
|
+
|
|
134
|
+
const many = document.createElement('button');
|
|
135
|
+
many.type = 'button';
|
|
136
|
+
many.className = 'btn btn-sm btn-outline';
|
|
137
|
+
many.textContent = 'Add a group';
|
|
138
|
+
many.title = 'Filter by group, then Select all shown';
|
|
139
|
+
many.addEventListener('click', () => openContactPicker({
|
|
140
|
+
title: 'Add recipients',
|
|
141
|
+
multiple: true,
|
|
142
|
+
onPick: (contacts) => add(contacts)
|
|
143
|
+
}));
|
|
144
|
+
row.appendChild(many);
|
|
145
|
+
|
|
146
|
+
// Under the field it belongs to, whatever Forma wrapped it in.
|
|
147
|
+
(toInput.closest('.domma-form-field') || toInput.parentElement || wrapper).appendChild(row);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const actions = document.createElement('div');
|
|
152
|
+
actions.className = 'invoice-party-actions';
|
|
153
|
+
const cancelBtn = document.createElement('button');
|
|
154
|
+
cancelBtn.type = 'button';
|
|
155
|
+
cancelBtn.className = 'btn btn-ghost';
|
|
156
|
+
cancelBtn.textContent = 'Cancel';
|
|
157
|
+
const sendBtn = document.createElement('button');
|
|
158
|
+
sendBtn.type = 'button';
|
|
159
|
+
sendBtn.className = 'btn btn-primary';
|
|
160
|
+
sendBtn.textContent = 'Send';
|
|
161
|
+
actions.appendChild(cancelBtn);
|
|
162
|
+
actions.appendChild(sendBtn);
|
|
163
|
+
wrapper.appendChild(actions);
|
|
164
|
+
|
|
165
|
+
const slideover = E.slideover({
|
|
166
|
+
title: 'Send Invoice',
|
|
167
|
+
size: 'md',
|
|
168
|
+
position: 'right',
|
|
169
|
+
onClose: () => setTimeout(() => {
|
|
170
|
+
slideover.destroy();
|
|
171
|
+
slideover.element?.remove();
|
|
172
|
+
}, 350)
|
|
173
|
+
});
|
|
174
|
+
slideover.element.appendChild(wrapper);
|
|
175
|
+
|
|
176
|
+
cancelBtn.addEventListener('click', () => slideover.close());
|
|
177
|
+
|
|
178
|
+
sendBtn.addEventListener('click', async () => {
|
|
179
|
+
if (!form.validate()) return;
|
|
180
|
+
const values = form.getData();
|
|
181
|
+
|
|
182
|
+
// Sending is slow (rendering the PDF is about a second) and it is not
|
|
183
|
+
// repeatable - say so, and make a second click impossible.
|
|
184
|
+
sendBtn.disabled = true;
|
|
185
|
+
cancelBtn.disabled = true;
|
|
186
|
+
const original = sendBtn.textContent;
|
|
187
|
+
sendBtn.textContent = 'Sending…';
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const res = await H.post(`${BASE}/invoices/${id}/send`, {
|
|
191
|
+
to: values.to,
|
|
192
|
+
subject: values.subject,
|
|
193
|
+
message: values.message,
|
|
194
|
+
attachPdf: Boolean(values.attachPdf)
|
|
195
|
+
});
|
|
196
|
+
const out = res?.data ?? res;
|
|
197
|
+
if (out?.error) throw new Error(out.error);
|
|
198
|
+
slideover.close();
|
|
199
|
+
E.toast(out.attached ? `Sent to ${out.to} with the PDF attached.` : `Sent to ${out.to}.`,
|
|
200
|
+
{ type: 'success' });
|
|
201
|
+
if (typeof onSent === 'function') onSent(out.invoice);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
E.toast(err?.message ?? 'The invoice could not be sent.', { type: 'error' });
|
|
204
|
+
sendBtn.disabled = false;
|
|
205
|
+
cancelBtn.disabled = false;
|
|
206
|
+
sendBtn.textContent = original;
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
slideover.open();
|
|
211
|
+
Domma.icons.scan(wrapper);
|
|
212
|
+
}
|