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
|
@@ -1,51 +1,213 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Invoice plugin - editor view. Create or edit a single invoice.
|
|
3
|
+
*
|
|
4
|
+
* The screen is a reactive graph rather than a render function: one observable
|
|
5
|
+
* per field, one computed per derived number, and a single M.applyBindings to
|
|
6
|
+
* connect them to the markup. Nothing here writes to the DOM.
|
|
7
|
+
*
|
|
8
|
+
* Line items are objects of observables rather than plain objects in a Model
|
|
9
|
+
* field, because `data-model="lineItems[0].quantity"` would mutate that object
|
|
10
|
+
* in place and the field's observable would never fire - the documented trap in
|
|
11
|
+
* docs/Bindings.md. Per-field observables make every keystroke a tracked write,
|
|
12
|
+
* so the line total, the subtotal, the VAT and the total all follow it.
|
|
3
13
|
*/
|
|
4
|
-
|
|
5
|
-
|
|
14
|
+
import {canDownloadPdf, downloadPdf, openPrintWindow} from './print.js?v=9';
|
|
15
|
+
import {disposeOnLeave} from './view-lifecycle.js?v=8';
|
|
16
|
+
import {openSendDialog} from './send.js?v=8';
|
|
17
|
+
import {openPaymentDialog, PAYMENT_METHODS} from './payments.js?v=8';
|
|
18
|
+
import {billingProfile, contactsAvailable, openContactPicker} from './contacts-source.js?v=8';
|
|
19
|
+
import {INVOICE_HELP} from './help-content.js?v=8';
|
|
20
|
+
import {openHelpPanel, scanHelp} from '/plugins/_lib/admin/ui/help.js?v=8';
|
|
21
|
+
|
|
22
|
+
const BASE = '/api/plugins/invoice';
|
|
23
|
+
|
|
24
|
+
function round2(n) {
|
|
25
|
+
return Math.round((Number(n) + Number.EPSILON) * 100) / 100;
|
|
6
26
|
}
|
|
7
27
|
|
|
8
|
-
function
|
|
28
|
+
function money(amount, currency) {
|
|
9
29
|
try {
|
|
10
30
|
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: currency || 'GBP' }).format(Number(amount) || 0);
|
|
11
31
|
} catch {
|
|
12
|
-
return `${currency || 'GBP'} ${Number(amount).toFixed(2)}`;
|
|
32
|
+
return `${currency || 'GBP'} ${(Number(amount) || 0).toFixed(2)}`;
|
|
13
33
|
}
|
|
14
34
|
}
|
|
15
35
|
|
|
16
|
-
|
|
17
|
-
|
|
36
|
+
/** Ids only have to be unique within this mount - they key the rendered rows. */
|
|
37
|
+
let lineSeq = 0;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One line item as a small reactive record.
|
|
41
|
+
*
|
|
42
|
+
* @param {object} item `{description, quantity, unitPrice}`
|
|
43
|
+
* @param {object} currency the form's currency observable
|
|
44
|
+
* @returns {object} observables plus the two computeds the row renders
|
|
45
|
+
*/
|
|
46
|
+
function makeLine(item, currency) {
|
|
47
|
+
const line = {
|
|
48
|
+
id: `line-${++lineSeq}`,
|
|
49
|
+
description: M.observable(item?.description ?? ''),
|
|
50
|
+
quantity: M.observable(Number(item?.quantity) || 0),
|
|
51
|
+
unitPrice: M.observable(Number(item?.unitPrice) || 0)
|
|
52
|
+
};
|
|
53
|
+
line.lineTotal = M.computed(() => round2((Number(line.quantity.value) || 0) * (Number(line.unitPrice.value) || 0)));
|
|
54
|
+
line.lineTotalText = M.computed(() => money(line.lineTotal.value, currency.value));
|
|
55
|
+
return line;
|
|
18
56
|
}
|
|
19
57
|
|
|
20
|
-
|
|
58
|
+
/**
|
|
59
|
+
* One recorded payment as a row. The stored fields never change - the ledger is
|
|
60
|
+
* append/remove only - so the computeds exist for the one thing that can move
|
|
61
|
+
* underneath them: the invoice's currency.
|
|
62
|
+
*
|
|
63
|
+
* @param {object} payment
|
|
64
|
+
* @param {object} currency the form's currency observable
|
|
65
|
+
* @returns {object}
|
|
66
|
+
*/
|
|
67
|
+
function makePaymentRow(payment, currency) {
|
|
68
|
+
const row = {
|
|
69
|
+
id: payment.id,
|
|
70
|
+
reference: payment.reference || '-',
|
|
71
|
+
amount: Number(payment.amount) || 0,
|
|
72
|
+
date: payment.date || '',
|
|
73
|
+
method: payment.method || 'other'
|
|
74
|
+
};
|
|
75
|
+
row.amountText = M.computed(() => money(row.amount, currency.value));
|
|
76
|
+
row.dateText = row.date ? D(row.date).format('DD MMM YYYY') : '-';
|
|
77
|
+
row.methodText = PAYMENT_METHODS[row.method] || row.method;
|
|
78
|
+
return row;
|
|
79
|
+
}
|
|
21
80
|
|
|
22
81
|
export const editorView = {
|
|
23
82
|
templateUrl: '/plugins/invoice/admin/templates/editor.html',
|
|
24
83
|
|
|
25
84
|
async onMount($container) {
|
|
26
|
-
|
|
85
|
+
// Domma's router hands onMount ONE argument and never the route params,
|
|
86
|
+
// and `R.current` is a METHOD - `R.current?.params` is undefined on the
|
|
87
|
+
// function object. Read the slug off the hash, as every core editor does.
|
|
88
|
+
const routeMatch = location.hash.match(/^#\/plugins\/invoice\/edit\/([^?#]+)/);
|
|
89
|
+
const id = routeMatch ? decodeURIComponent(routeMatch[1]) : null;
|
|
27
90
|
const isEdit = Boolean(id);
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
91
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
92
|
+
|
|
93
|
+
const form = {
|
|
94
|
+
isEdit: M.observable(isEdit),
|
|
95
|
+
canPdf: M.observable(false),
|
|
96
|
+
canImport: M.observable(false),
|
|
97
|
+
number: M.observable(''),
|
|
98
|
+
status: M.observable('draft'),
|
|
99
|
+
issueDate: M.observable(today),
|
|
100
|
+
dueDate: M.observable(''),
|
|
101
|
+
issuerId: M.observable(''),
|
|
102
|
+
receiverId: M.observable(''),
|
|
103
|
+
currency: M.observable('GBP'),
|
|
104
|
+
vatEnabled: M.observable(true),
|
|
105
|
+
vatRate: M.observable(20),
|
|
106
|
+
notes: M.observable(''),
|
|
107
|
+
issuers: M.observable([]),
|
|
108
|
+
receivers: M.observable([]),
|
|
109
|
+
lineItems: M.observableArray([]),
|
|
110
|
+
payments: M.observableArray([])
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Derived money. Each reads only what it needs, so a keystroke in one
|
|
114
|
+
// row's quantity re-runs that row's total and the three below it - not
|
|
115
|
+
// the other rows, and not the selects.
|
|
116
|
+
form.subtotal = M.computed(() => round2(form.lineItems.value.reduce((sum, line) => sum + line.lineTotal.value, 0)));
|
|
117
|
+
form.vat = M.computed(() => (form.vatEnabled.value ? round2(form.subtotal.value * (Number(form.vatRate.value) || 0) / 100) : 0));
|
|
118
|
+
form.total = M.computed(() => round2(form.subtotal.value + form.vat.value));
|
|
119
|
+
form.subtotalText = M.computed(() => money(form.subtotal.value, form.currency.value));
|
|
120
|
+
form.vatText = M.computed(() => money(form.vat.value, form.currency.value));
|
|
121
|
+
form.totalText = M.computed(() => money(form.total.value, form.currency.value));
|
|
122
|
+
|
|
123
|
+
// The ledger. `paid` is summed here rather than taken from the server's
|
|
124
|
+
// totals so the balance follows a line item being edited, live, before
|
|
125
|
+
// anything is saved.
|
|
126
|
+
form.paid = M.computed(() => round2(form.payments.value.reduce((sum, p) => sum + p.amount, 0)));
|
|
127
|
+
form.balance = M.computed(() => round2(form.total.value - form.paid.value));
|
|
128
|
+
form.paidText = M.computed(() => money(form.paid.value, form.currency.value));
|
|
129
|
+
form.balanceText = M.computed(() => money(Math.max(0, form.balance.value), form.currency.value));
|
|
130
|
+
form.hasPayments = M.computed(() => form.payments.value.length > 0);
|
|
131
|
+
form.noPayments = M.computed(() => form.payments.value.length === 0);
|
|
132
|
+
form.settled = M.computed(() => form.total.value > 0 && form.balance.value <= 0);
|
|
133
|
+
form.balanceLabel = M.computed(() => (form.settled.value ? 'Paid in full' : 'Balance due'));
|
|
134
|
+
|
|
135
|
+
// ---- data -----------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Reload the receiver list and keep the current selection.
|
|
139
|
+
*
|
|
140
|
+
* Rebuilding a select's options resets it, so the value is put back -
|
|
141
|
+
* which is also how a freshly imported receiver becomes the selected
|
|
142
|
+
* one without a second click.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} [select] id to select afterwards
|
|
145
|
+
* @returns {Promise<void>}
|
|
146
|
+
*/
|
|
147
|
+
async function loadReceivers(select) {
|
|
148
|
+
const res = await H.get(`${BASE}/receivers`);
|
|
149
|
+
const receivers = res.data ?? res ?? [];
|
|
150
|
+
form.receivers.value = receivers.map((r) => ({ id: r.id, label: r.company || r.name || '(unnamed)' }));
|
|
151
|
+
if (select) form.receiverId.value = select;
|
|
152
|
+
}
|
|
32
153
|
|
|
33
154
|
try {
|
|
34
155
|
const [issRes, recRes] = await Promise.all([
|
|
35
156
|
H.get(`${BASE}/issuers`),
|
|
36
157
|
H.get(`${BASE}/receivers`)
|
|
37
158
|
]);
|
|
38
|
-
issuers = issRes.data ?? issRes ?? [];
|
|
39
|
-
receivers = recRes.data ?? recRes ?? [];
|
|
159
|
+
const issuers = (issRes.data ?? issRes ?? []);
|
|
160
|
+
const receivers = (recRes.data ?? recRes ?? []);
|
|
161
|
+
// data-options-text takes an expression against the item, so the
|
|
162
|
+
// label is resolved here rather than spelled out in the attribute.
|
|
163
|
+
form.issuers.value = issuers.map((i) => ({ id: i.id, label: i.name || '(unnamed)' }));
|
|
164
|
+
form.receivers.value = receivers.map((r) => ({ id: r.id, label: r.company || r.name || '(unnamed)' }));
|
|
40
165
|
} catch {
|
|
41
166
|
E.toast('Could not load issuers / receivers.', { type: 'error' });
|
|
42
167
|
}
|
|
43
168
|
|
|
169
|
+
// A new invoice opens on the house defaults - the issuer we trade as,
|
|
170
|
+
// the currency, the VAT rate and the payment terms. An existing one
|
|
171
|
+
// opens on what it was saved with, so changing a default never edits
|
|
172
|
+
// an invoice that already exists.
|
|
173
|
+
if (!isEdit) {
|
|
174
|
+
try {
|
|
175
|
+
const res = await H.get(`${BASE}/settings`);
|
|
176
|
+
const settings = res.data ?? res;
|
|
177
|
+
form.currency.value = settings.defaultCurrency ?? 'GBP';
|
|
178
|
+
form.vatEnabled.value = settings.vatEnabledByDefault !== false;
|
|
179
|
+
form.vatRate.value = settings.defaultVatRate ?? 20;
|
|
180
|
+
if (settings.defaultIssuerId) form.issuerId.value = settings.defaultIssuerId;
|
|
181
|
+
|
|
182
|
+
const terms = Number(settings.defaultPaymentTermsDays);
|
|
183
|
+
if (Number.isFinite(terms) && terms > 0) {
|
|
184
|
+
form.dueDate.value = D(today).add(terms, 'days').format('YYYY-MM-DD');
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
// The shipped defaults already in the observables are fine.
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
44
191
|
if (isEdit) {
|
|
45
192
|
try {
|
|
46
193
|
const res = await H.get(`${BASE}/invoices/${id}`);
|
|
47
|
-
invoice = res.data ?? res;
|
|
48
|
-
|
|
194
|
+
const invoice = res.data ?? res;
|
|
195
|
+
form.number.value = invoice.number ?? '';
|
|
196
|
+
form.status.value = invoice.status ?? 'draft';
|
|
197
|
+
form.issueDate.value = invoice.issueDate ?? today;
|
|
198
|
+
form.dueDate.value = invoice.dueDate ?? '';
|
|
199
|
+
form.issuerId.value = invoice.issuerId ?? '';
|
|
200
|
+
form.receiverId.value = invoice.receiverId ?? '';
|
|
201
|
+
form.currency.value = invoice.currency ?? 'GBP';
|
|
202
|
+
form.vatEnabled.value = invoice.vatEnabled !== false;
|
|
203
|
+
form.vatRate.value = invoice.vatRate ?? 20;
|
|
204
|
+
form.notes.value = invoice.notes ?? '';
|
|
205
|
+
if (Array.isArray(invoice.lineItems)) {
|
|
206
|
+
form.lineItems.value = invoice.lineItems.map((li) => makeLine(li, form.currency));
|
|
207
|
+
}
|
|
208
|
+
if (Array.isArray(invoice.payments)) {
|
|
209
|
+
form.payments.value = invoice.payments.map((p) => makePaymentRow(p, form.currency));
|
|
210
|
+
}
|
|
49
211
|
} catch {
|
|
50
212
|
E.toast('Invoice not found.', { type: 'error' });
|
|
51
213
|
R.navigate('#/plugins/invoice');
|
|
@@ -53,201 +215,156 @@ export const editorView = {
|
|
|
53
215
|
}
|
|
54
216
|
}
|
|
55
217
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const $status = $container.find('#f-status');
|
|
59
|
-
const $issueDate = $container.find('#f-issue-date');
|
|
60
|
-
const $dueDate = $container.find('#f-due-date');
|
|
61
|
-
const $issuer = $container.find('#f-issuer');
|
|
62
|
-
const $receiver = $container.find('#f-receiver');
|
|
63
|
-
const $vatEnabled = $container.find('#f-vat-enabled');
|
|
64
|
-
const $vatRate = $container.find('#f-vat-rate');
|
|
65
|
-
const $currency = $container.find('#f-currency');
|
|
66
|
-
const $notes = $container.find('#f-notes');
|
|
67
|
-
const $print = $container.find('#btn-print');
|
|
68
|
-
const $body = $container.find('#line-items-body');
|
|
69
|
-
const $vatRow = $container.find('#vat-row');
|
|
70
|
-
const $tSubtotal = $container.find('#t-subtotal');
|
|
71
|
-
const $tVat = $container.find('#t-vat');
|
|
72
|
-
const $tVatRate = $container.find('#t-vat-rate');
|
|
73
|
-
const $tTotal = $container.find('#t-total');
|
|
74
|
-
|
|
75
|
-
function populateSelect($select, list, valueKey, labelFn, selected) {
|
|
76
|
-
const el = $select.get(0);
|
|
77
|
-
while (el.firstChild) el.removeChild(el.firstChild);
|
|
78
|
-
const placeholder = document.createElement('option');
|
|
79
|
-
placeholder.value = '';
|
|
80
|
-
placeholder.textContent = '- select -';
|
|
81
|
-
el.appendChild(placeholder);
|
|
82
|
-
for (const item of list) {
|
|
83
|
-
const opt = document.createElement('option');
|
|
84
|
-
opt.value = item[valueKey];
|
|
85
|
-
opt.textContent = labelFn(item);
|
|
86
|
-
if (item[valueKey] === selected) opt.selected = true;
|
|
87
|
-
el.appendChild(opt);
|
|
88
|
-
}
|
|
218
|
+
if (!form.lineItems.value.length) {
|
|
219
|
+
form.lineItems.push(makeLine({ quantity: 1, unitPrice: 0 }, form.currency));
|
|
89
220
|
}
|
|
90
221
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
body.appendChild(tr);
|
|
169
|
-
});
|
|
222
|
+
// ---- behaviour ------------------------------------------------------
|
|
223
|
+
//
|
|
224
|
+
// These hang off the data rather than being passed as `methods`, because
|
|
225
|
+
// inside a data-each a bare handler name does not resolve at all - a row
|
|
226
|
+
// reaches them through `$parent.removeLine($data)`, and $parent is the
|
|
227
|
+
// data object.
|
|
228
|
+
|
|
229
|
+
form.addLine = () => {
|
|
230
|
+
form.lineItems.push(makeLine({ quantity: 1, unitPrice: 0 }, form.currency));
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
form.removeLine = (line) => {
|
|
234
|
+
form.lineItems.remove(line);
|
|
235
|
+
if (!form.lineItems.value.length) form.addLine();
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Move a line by one position.
|
|
240
|
+
*
|
|
241
|
+
* Through `.value =` rather than splicing in place: an observableArray
|
|
242
|
+
* notifies on assignment, and a mutated array is the same array, so
|
|
243
|
+
* `data-each` would never hear about it.
|
|
244
|
+
*
|
|
245
|
+
* @param {object} line
|
|
246
|
+
* @param {number} delta
|
|
247
|
+
* @returns {void}
|
|
248
|
+
*/
|
|
249
|
+
form.moveLine = (line, delta) => {
|
|
250
|
+
const lines = [...form.lineItems.value];
|
|
251
|
+
const from = lines.indexOf(line);
|
|
252
|
+
const to = from + delta;
|
|
253
|
+
if (from < 0 || to < 0 || to >= lines.length) return;
|
|
254
|
+
lines.splice(to, 0, ...lines.splice(from, 1));
|
|
255
|
+
form.lineItems.value = lines;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* @param {object} line
|
|
260
|
+
* @returns {void}
|
|
261
|
+
*/
|
|
262
|
+
form.duplicateLine = (line) => {
|
|
263
|
+
const lines = [...form.lineItems.value];
|
|
264
|
+
const at = lines.indexOf(line);
|
|
265
|
+
const copy = makeLine({
|
|
266
|
+
description: line.description.value,
|
|
267
|
+
quantity: line.quantity.value,
|
|
268
|
+
unitPrice: line.unitPrice.value
|
|
269
|
+
}, form.currency);
|
|
270
|
+
lines.splice(at + 1, 0, copy);
|
|
271
|
+
form.lineItems.value = lines;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
form.showHelp = () => openHelpPanel(INVOICE_HELP);
|
|
275
|
+
|
|
276
|
+
// Contacts is optional - disabled, or not installed - so the button is
|
|
277
|
+
// only offered once the probe has answered. data-if takes the element
|
|
278
|
+
// out of the document while the answer is false, which is why its icon
|
|
279
|
+
// (if it ever gains one) would have to be scanned afterwards.
|
|
280
|
+
contactsAvailable().then((ok) => { form.canImport.value = ok; });
|
|
281
|
+
|
|
282
|
+
form.importReceiver = () => openContactPicker({
|
|
283
|
+
title: 'Bill a contact',
|
|
284
|
+
onPick: async (contact) => {
|
|
285
|
+
try {
|
|
286
|
+
// The billing profile uses the receiver's own field names,
|
|
287
|
+
// so it posts straight through with nothing to translate.
|
|
288
|
+
const profile = await billingProfile(contact.id);
|
|
289
|
+
const res = await H.post(`${BASE}/receivers`, profile);
|
|
290
|
+
const created = res?.data ?? res;
|
|
291
|
+
if (created?.error) throw new Error(created.error);
|
|
292
|
+
await loadReceivers(created.id);
|
|
293
|
+
E.toast(`${profile.company || profile.name} added as a receiver.`, { type: 'success' });
|
|
294
|
+
} catch (err) {
|
|
295
|
+
E.toast(err?.message ?? 'Could not import that contact.', { type: 'error' });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
});
|
|
170
299
|
|
|
171
|
-
|
|
172
|
-
|
|
300
|
+
form.openPrint = () => openPrintWindow(id);
|
|
301
|
+
form.downloadPdf = () => downloadPdf(id);
|
|
173
302
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
});
|
|
181
|
-
}
|
|
303
|
+
/** One place to take an invoice the server just changed and follow it. */
|
|
304
|
+
const applyServerInvoice = (updated) => {
|
|
305
|
+
if (!updated) return;
|
|
306
|
+
if (updated.status) form.status.value = updated.status;
|
|
307
|
+
form.payments.value = (updated.payments ?? []).map((p) => makePaymentRow(p, form.currency));
|
|
308
|
+
};
|
|
182
309
|
|
|
183
|
-
|
|
184
|
-
const currency = $currency.val();
|
|
185
|
-
const vatEnabled = $vatEnabled.prop('checked');
|
|
186
|
-
const vatRate = Number($vatRate.val()) || 0;
|
|
310
|
+
form.recordPayment = () => openPaymentDialog(id, form.balance.value, applyServerInvoice);
|
|
187
311
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
312
|
+
form.removePayment = async (row) => {
|
|
313
|
+
const ok = await E.confirm(`Remove the ${money(row.amount, form.currency.value)} payment?`);
|
|
314
|
+
if (!ok) return;
|
|
315
|
+
try {
|
|
316
|
+
const res = await H.delete(`${BASE}/invoices/${id}/payments/${row.id}`);
|
|
317
|
+
const out = res?.data ?? res;
|
|
318
|
+
if (out?.error) throw new Error(out.error);
|
|
319
|
+
applyServerInvoice(out);
|
|
320
|
+
E.toast('Payment removed.', { type: 'success' });
|
|
321
|
+
} catch (err) {
|
|
322
|
+
E.toast(err?.message ?? 'Could not remove the payment.', { type: 'error' });
|
|
191
323
|
}
|
|
192
|
-
|
|
193
|
-
const vat = vatEnabled ? round2(subtotal * (vatRate / 100)) : 0;
|
|
194
|
-
const total = round2(subtotal + vat);
|
|
195
|
-
|
|
196
|
-
$tSubtotal.text(fmt(subtotal, currency));
|
|
197
|
-
$tVat.text(fmt(vat, currency));
|
|
198
|
-
$tVatRate.text(vatRate);
|
|
199
|
-
$tTotal.text(fmt(total, currency));
|
|
200
|
-
$vatRow.css('display', vatEnabled ? '' : 'none');
|
|
201
|
-
updateLineTotals();
|
|
202
|
-
}
|
|
324
|
+
};
|
|
203
325
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
$body.on('input', '.li-qty', function () {
|
|
208
|
-
lineItems[Number(this.dataset.idx)].quantity = Number(this.value) || 0;
|
|
209
|
-
recomputeTotals();
|
|
210
|
-
});
|
|
211
|
-
$body.on('input', '.li-price', function () {
|
|
212
|
-
lineItems[Number(this.dataset.idx)].unitPrice = Number(this.value) || 0;
|
|
213
|
-
recomputeTotals();
|
|
214
|
-
});
|
|
215
|
-
$body.on('click', '.li-remove', function () {
|
|
216
|
-
const idx = Number(this.dataset.idx);
|
|
217
|
-
lineItems.splice(idx, 1);
|
|
218
|
-
if (!lineItems.length) lineItems.push({ description: '', quantity: 1, unitPrice: 0 });
|
|
219
|
-
renderLineItems();
|
|
220
|
-
recomputeTotals();
|
|
326
|
+
form.sendInvoice = () => openSendDialog(id, (updated) => {
|
|
327
|
+
// Sending can move a draft to sent, so the screen has to follow.
|
|
328
|
+
if (updated?.status) form.status.value = updated.status;
|
|
221
329
|
});
|
|
222
330
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
331
|
+
// The button only exists where the host can actually render one; a
|
|
332
|
+
// button that always answers "not installed" is worse than no button.
|
|
333
|
+
//
|
|
334
|
+
// Its icon has to be scanned AFTER it appears: data-if takes the element
|
|
335
|
+
// out of the document while the answer is false, so the scan at the end
|
|
336
|
+
// of mount would run against a detached node and the span would come
|
|
337
|
+
// back empty. M.flush() settles the binding first - reading the DOM
|
|
338
|
+
// before the microtask has run would scan the old shape.
|
|
339
|
+
if (isEdit) {
|
|
340
|
+
canDownloadPdf().then((ok) => {
|
|
341
|
+
if (!ok) return;
|
|
342
|
+
form.canPdf.value = true;
|
|
343
|
+
M.flush();
|
|
344
|
+
Domma.icons.scan($container.get(0));
|
|
345
|
+
});
|
|
346
|
+
}
|
|
232
347
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if (!issuerId) { E.toast('Pick an issuer.', { type: 'error' }); return; }
|
|
237
|
-
if (!receiverId) { E.toast('Pick a receiver.', { type: 'error' }); return; }
|
|
348
|
+
form.save = async () => {
|
|
349
|
+
if (!form.issuerId.value) { E.toast('Pick an issuer.', { type: 'error' }); return; }
|
|
350
|
+
if (!form.receiverId.value) { E.toast('Pick a receiver.', { type: 'error' }); return; }
|
|
238
351
|
|
|
239
352
|
const body = {
|
|
240
|
-
number:
|
|
241
|
-
status:
|
|
242
|
-
issueDate:
|
|
243
|
-
dueDate:
|
|
244
|
-
issuerId,
|
|
245
|
-
receiverId,
|
|
246
|
-
vatEnabled:
|
|
247
|
-
vatRate: Number(
|
|
248
|
-
currency:
|
|
249
|
-
notes:
|
|
250
|
-
lineItems
|
|
353
|
+
number: String(form.number.value ?? '').trim() || undefined,
|
|
354
|
+
status: form.status.value,
|
|
355
|
+
issueDate: form.issueDate.value,
|
|
356
|
+
dueDate: form.dueDate.value || '',
|
|
357
|
+
issuerId: form.issuerId.value,
|
|
358
|
+
receiverId: form.receiverId.value,
|
|
359
|
+
vatEnabled: Boolean(form.vatEnabled.value),
|
|
360
|
+
vatRate: Number(form.vatRate.value) || 0,
|
|
361
|
+
currency: form.currency.value,
|
|
362
|
+
notes: form.notes.value ?? '',
|
|
363
|
+
lineItems: form.lineItems.value.map((line) => ({
|
|
364
|
+
description: line.description.value,
|
|
365
|
+
quantity: Number(line.quantity.value) || 0,
|
|
366
|
+
unitPrice: Number(line.unitPrice.value) || 0
|
|
367
|
+
}))
|
|
251
368
|
};
|
|
252
369
|
|
|
253
370
|
try {
|
|
@@ -260,8 +377,58 @@ export const editorView = {
|
|
|
260
377
|
} catch (err) {
|
|
261
378
|
E.toast(err?.message ?? 'Save failed.', { type: 'error' });
|
|
262
379
|
}
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const handle = M.applyBindings(form, $container.get(0));
|
|
383
|
+
|
|
384
|
+
// A line is a row, so its actions belong on the row. The item is found
|
|
385
|
+
// by POSITION rather than by an id attribute: data-each owns this
|
|
386
|
+
// markup and stamps no id on the <tr>, and the rendered order is the
|
|
387
|
+
// array order.
|
|
388
|
+
const linesBody = $container.find('#line-items-body').get(0);
|
|
389
|
+
const lineMenu = linesBody && E.contextMenu(linesBody, {
|
|
390
|
+
match: 'tr',
|
|
391
|
+
inherit: false,
|
|
392
|
+
items: (target) => {
|
|
393
|
+
const index = [...linesBody.children].indexOf(target);
|
|
394
|
+
const line = form.lineItems.value[index];
|
|
395
|
+
if (!line) return [];
|
|
396
|
+
const last = form.lineItems.value.length - 1;
|
|
397
|
+
return [
|
|
398
|
+
{ header: line.description.value || `Line ${index + 1}` },
|
|
399
|
+
{ label: 'Duplicate', icon: 'copy', action: () => form.duplicateLine(line) },
|
|
400
|
+
{ label: 'Add a line below', icon: 'plus', action: () => form.addLine() },
|
|
401
|
+
{ type: 'divider' },
|
|
402
|
+
{ label: 'Move up', icon: 'chevron-up', disabled: index === 0,
|
|
403
|
+
action: () => form.moveLine(line, -1) },
|
|
404
|
+
{ label: 'Move down', icon: 'chevron-down', disabled: index === last,
|
|
405
|
+
action: () => form.moveLine(line, 1) },
|
|
406
|
+
{ type: 'divider' },
|
|
407
|
+
{ label: 'Remove', icon: 'trash', danger: true,
|
|
408
|
+
action: () => form.removeLine(line) }
|
|
409
|
+
];
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// Domma's calendar rather than `<input type="date">`: one control in
|
|
414
|
+
// every browser, themed from --dm-* tokens, and it writes ISO into the
|
|
415
|
+
// input and fires input/change, so `data-model` hears it exactly as it
|
|
416
|
+
// hears typing. The due date cannot precede the issue date, and saying
|
|
417
|
+
// so in the picker beats validating it after the fact.
|
|
418
|
+
const issuePicker = E.datePicker($container.find('#f-issue-date').get(0), {
|
|
419
|
+
onChange: (iso) => duePicker?.setOptions({min: iso || null})
|
|
420
|
+
});
|
|
421
|
+
const duePicker = E.datePicker($container.find('#f-due-date').get(0), {
|
|
422
|
+
min: form.issueDate.value || null
|
|
263
423
|
});
|
|
264
424
|
|
|
265
|
-
|
|
425
|
+
disposeOnLeave($container.find('#line-items-table').get(0), handle,
|
|
426
|
+
() => lineMenu?.destroy?.(),
|
|
427
|
+
() => issuePicker?.destroy?.(),
|
|
428
|
+
() => duePicker?.destroy?.());
|
|
429
|
+
|
|
430
|
+
Domma.icons.scan($container.get(0));
|
|
431
|
+
M.flush();
|
|
432
|
+
scanHelp($container.get(0));
|
|
266
433
|
}
|
|
267
434
|
};
|