domma-cms 0.66.0 → 0.69.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +92 -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 +307 -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,434 +0,0 @@
|
|
|
1
|
-
/**
|
|
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.
|
|
13
|
-
*/
|
|
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;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function money(amount, currency) {
|
|
29
|
-
try {
|
|
30
|
-
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: currency || 'GBP' }).format(Number(amount) || 0);
|
|
31
|
-
} catch {
|
|
32
|
-
return `${currency || 'GBP'} ${(Number(amount) || 0).toFixed(2)}`;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
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;
|
|
56
|
-
}
|
|
57
|
-
|
|
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
|
-
}
|
|
80
|
-
|
|
81
|
-
export const editorView = {
|
|
82
|
-
templateUrl: '/plugins/invoice/admin/templates/editor.html',
|
|
83
|
-
|
|
84
|
-
async onMount($container) {
|
|
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;
|
|
90
|
-
const isEdit = Boolean(id);
|
|
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
|
-
}
|
|
153
|
-
|
|
154
|
-
try {
|
|
155
|
-
const [issRes, recRes] = await Promise.all([
|
|
156
|
-
H.get(`${BASE}/issuers`),
|
|
157
|
-
H.get(`${BASE}/receivers`)
|
|
158
|
-
]);
|
|
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)' }));
|
|
165
|
-
} catch {
|
|
166
|
-
E.toast('Could not load issuers / receivers.', { type: 'error' });
|
|
167
|
-
}
|
|
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
|
-
|
|
191
|
-
if (isEdit) {
|
|
192
|
-
try {
|
|
193
|
-
const res = await H.get(`${BASE}/invoices/${id}`);
|
|
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
|
-
}
|
|
211
|
-
} catch {
|
|
212
|
-
E.toast('Invoice not found.', { type: 'error' });
|
|
213
|
-
R.navigate('#/plugins/invoice');
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
if (!form.lineItems.value.length) {
|
|
219
|
-
form.lineItems.push(makeLine({ quantity: 1, unitPrice: 0 }, form.currency));
|
|
220
|
-
}
|
|
221
|
-
|
|
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
|
-
});
|
|
299
|
-
|
|
300
|
-
form.openPrint = () => openPrintWindow(id);
|
|
301
|
-
form.downloadPdf = () => downloadPdf(id);
|
|
302
|
-
|
|
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
|
-
};
|
|
309
|
-
|
|
310
|
-
form.recordPayment = () => openPaymentDialog(id, form.balance.value, applyServerInvoice);
|
|
311
|
-
|
|
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' });
|
|
323
|
-
}
|
|
324
|
-
};
|
|
325
|
-
|
|
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;
|
|
329
|
-
});
|
|
330
|
-
|
|
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
|
-
}
|
|
347
|
-
|
|
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; }
|
|
351
|
-
|
|
352
|
-
const body = {
|
|
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
|
-
}))
|
|
368
|
-
};
|
|
369
|
-
|
|
370
|
-
try {
|
|
371
|
-
const res = isEdit
|
|
372
|
-
? await H.put(`${BASE}/invoices/${id}`, body)
|
|
373
|
-
: await H.post(`${BASE}/invoices`, body);
|
|
374
|
-
if (res.error) throw new Error(res.error);
|
|
375
|
-
E.toast(isEdit ? 'Invoice saved.' : 'Invoice created.', { type: 'success' });
|
|
376
|
-
R.navigate('#/plugins/invoice');
|
|
377
|
-
} catch (err) {
|
|
378
|
-
E.toast(err?.message ?? 'Save failed.', { type: 'error' });
|
|
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
|
|
423
|
-
});
|
|
424
|
-
|
|
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));
|
|
433
|
-
}
|
|
434
|
-
};
|
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Invoice plugin - saving invoices as a file.
|
|
3
|
-
*
|
|
4
|
-
* Domma's table can export what it is showing, but it exports the RENDERED
|
|
5
|
-
* columns: a receiver id instead of a name, an HTML badge instead of a status,
|
|
6
|
-
* and no line items at all, because they are not in the table. An accountant
|
|
7
|
-
* asking for "the invoices as a spreadsheet" wants neither of those, so the
|
|
8
|
-
* rows are built here from the data and the table's own exporter is left off.
|
|
9
|
-
*
|
|
10
|
-
* Excel is an HTML table served as `application/vnd.ms-excel`, which is what
|
|
11
|
-
* Domma's own exporter does and what Excel and LibreOffice both open without
|
|
12
|
-
* a converter. It is not a real .xlsx, and it does not need to be - nothing
|
|
13
|
-
* here has formulas, merged cells or more than one sheet.
|
|
14
|
-
*/
|
|
15
|
-
function escapeHtml(s) {
|
|
16
|
-
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* @param {Array<Array<string|number>>} rows
|
|
21
|
-
* @returns {string}
|
|
22
|
-
*/
|
|
23
|
-
function toCsv(rows) {
|
|
24
|
-
return rows.map((row) => row.map((cell) => {
|
|
25
|
-
const text = cell === null || cell === undefined ? '' : String(cell);
|
|
26
|
-
return `"${text.replace(/"/g, '""')}"`;
|
|
27
|
-
}).join(',')).join('\r\n');
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* @param {string} title
|
|
32
|
-
* @param {Array<Array<string|number>>} rows
|
|
33
|
-
* @param {number} headerRows - how many leading rows to render as <th>
|
|
34
|
-
* @returns {string}
|
|
35
|
-
*/
|
|
36
|
-
function toExcel(title, rows, headerRows = 1) {
|
|
37
|
-
const body = rows.map((row, index) => {
|
|
38
|
-
const tag = index < headerRows ? 'th' : 'td';
|
|
39
|
-
return '<tr>' + row.map((cell) => `<${tag}>${escapeHtml(cell)}</${tag}>`).join('') + '</tr>';
|
|
40
|
-
}).join('');
|
|
41
|
-
|
|
42
|
-
return `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel">
|
|
43
|
-
<head><meta charset="UTF-8">
|
|
44
|
-
<!--[if gte mso 9]><xml><x:ExcelWorkbook><x:ExcelWorksheets><x:ExcelWorksheet>
|
|
45
|
-
<x:Name>${escapeHtml(title).slice(0, 31)}</x:Name>
|
|
46
|
-
<x:WorksheetOptions><x:DisplayGridlines/></x:WorksheetOptions>
|
|
47
|
-
</x:ExcelWorksheet></x:ExcelWorksheets></x:ExcelWorkbook></xml><![endif]-->
|
|
48
|
-
<style>table{border-collapse:collapse}th,td{border:1px solid #999;padding:6px}th{background:#eee;font-weight:bold;text-align:left}</style>
|
|
49
|
-
</head><body><table>${body}</table></body></html>`;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* @param {string} content
|
|
54
|
-
* @param {string} mime
|
|
55
|
-
* @param {string} filename
|
|
56
|
-
* @returns {void}
|
|
57
|
-
*/
|
|
58
|
-
function save(content, mime, filename) {
|
|
59
|
-
// A BOM, because Excel reads a CSV without one as the system codepage and
|
|
60
|
-
// turns every pound sign into £.
|
|
61
|
-
const blob = new Blob([mime.startsWith('text/csv') ? '' + content : content], { type: mime });
|
|
62
|
-
const url = URL.createObjectURL(blob);
|
|
63
|
-
const a = document.createElement('a');
|
|
64
|
-
a.href = url;
|
|
65
|
-
a.download = filename;
|
|
66
|
-
a.rel = 'noopener';
|
|
67
|
-
document.body.appendChild(a);
|
|
68
|
-
a.click();
|
|
69
|
-
a.remove();
|
|
70
|
-
// Revoked on the next tick: revoking synchronously races the download in
|
|
71
|
-
// Firefox, which has not necessarily read the blob by the time click()
|
|
72
|
-
// returns.
|
|
73
|
-
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** A filename component that no filesystem will argue about. */
|
|
77
|
-
function safeName(text, fallback) {
|
|
78
|
-
const cleaned = String(text ?? '').replace(/[^\w.-]+/g, '_').replace(/^_+|_+$/g, '');
|
|
79
|
-
return cleaned || fallback;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function money(amount) {
|
|
83
|
-
return (Number(amount) || 0).toFixed(2);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function dateText(value) {
|
|
87
|
-
return value ? D(value).format('DD MMM YYYY') : '';
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* One invoice as a small worksheet: the facts, then the lines, then the totals.
|
|
92
|
-
*
|
|
93
|
-
* @param {object} invoice
|
|
94
|
-
* @param {{issuerName: string, receiverName: string}} names
|
|
95
|
-
* @returns {Array<Array<string|number>>}
|
|
96
|
-
*/
|
|
97
|
-
export function invoiceRows(invoice, { issuerName = '', receiverName = '' } = {}) {
|
|
98
|
-
const currency = invoice.currency || 'GBP';
|
|
99
|
-
const totals = invoice.totals ?? {};
|
|
100
|
-
const rows = [
|
|
101
|
-
['Invoice', invoice.number || ''],
|
|
102
|
-
['Status', invoice.overdue ? 'overdue' : (invoice.status || 'draft')],
|
|
103
|
-
['From', issuerName],
|
|
104
|
-
['To', receiverName],
|
|
105
|
-
['Issued', dateText(invoice.issueDate)],
|
|
106
|
-
['Due', dateText(invoice.dueDate)],
|
|
107
|
-
['Currency', currency],
|
|
108
|
-
[],
|
|
109
|
-
['Description', 'Quantity', 'Unit price', 'Line total']
|
|
110
|
-
];
|
|
111
|
-
|
|
112
|
-
for (const line of invoice.lineItems ?? []) {
|
|
113
|
-
const quantity = Number(line.quantity) || 0;
|
|
114
|
-
const unit = Number(line.unitPrice) || 0;
|
|
115
|
-
rows.push([line.description || '', quantity, money(unit), money(quantity * unit)]);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
rows.push([]);
|
|
119
|
-
rows.push(['', '', 'Subtotal', money(totals.subtotal)]);
|
|
120
|
-
if (invoice.vatEnabled) rows.push(['', '', `VAT (${totals.vatRate ?? invoice.vatRate ?? 0}%)`, money(totals.vat)]);
|
|
121
|
-
rows.push(['', '', 'Total', money(totals.total)]);
|
|
122
|
-
if (Number(totals.paid) > 0) {
|
|
123
|
-
rows.push(['', '', 'Paid', money(totals.paid)]);
|
|
124
|
-
rows.push(['', '', 'Balance', money(Math.max(0, Number(totals.balance) || 0))]);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (invoice.notes) {
|
|
128
|
-
rows.push([]);
|
|
129
|
-
rows.push(['Notes', invoice.notes]);
|
|
130
|
-
}
|
|
131
|
-
return rows;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* A list of invoices, one per row.
|
|
136
|
-
*
|
|
137
|
-
* @param {object[]} invoices
|
|
138
|
-
* @param {(invoice: object) => {issuerName: string, receiverName: string}} names
|
|
139
|
-
* @returns {Array<Array<string|number>>}
|
|
140
|
-
*/
|
|
141
|
-
export function listRows(invoices, names) {
|
|
142
|
-
const rows = [[
|
|
143
|
-
'Number', 'Status', 'From', 'To', 'Issued', 'Due',
|
|
144
|
-
'Currency', 'Subtotal', 'VAT', 'Total', 'Paid', 'Balance'
|
|
145
|
-
]];
|
|
146
|
-
|
|
147
|
-
for (const invoice of invoices) {
|
|
148
|
-
const totals = invoice.totals ?? {};
|
|
149
|
-
const { issuerName = '', receiverName = '' } = names(invoice) ?? {};
|
|
150
|
-
rows.push([
|
|
151
|
-
invoice.number || '',
|
|
152
|
-
invoice.overdue ? 'overdue' : (invoice.status || 'draft'),
|
|
153
|
-
issuerName,
|
|
154
|
-
receiverName,
|
|
155
|
-
dateText(invoice.issueDate),
|
|
156
|
-
dateText(invoice.dueDate),
|
|
157
|
-
invoice.currency || 'GBP',
|
|
158
|
-
money(totals.subtotal),
|
|
159
|
-
money(totals.vat),
|
|
160
|
-
money(totals.total),
|
|
161
|
-
money(totals.paid),
|
|
162
|
-
money(totals.balance)
|
|
163
|
-
]);
|
|
164
|
-
}
|
|
165
|
-
return rows;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* @param {object} invoice
|
|
170
|
-
* @param {'csv'|'excel'} format
|
|
171
|
-
* @param {object} names
|
|
172
|
-
* @returns {void}
|
|
173
|
-
*/
|
|
174
|
-
export function exportInvoice(invoice, format, names) {
|
|
175
|
-
const rows = invoiceRows(invoice, names);
|
|
176
|
-
const stem = safeName(invoice.number, 'invoice');
|
|
177
|
-
if (format === 'excel') {
|
|
178
|
-
save(toExcel(stem, rows, 0), 'application/vnd.ms-excel', `${stem}.xls`);
|
|
179
|
-
} else {
|
|
180
|
-
save(toCsv(rows), 'text/csv;charset=utf-8', `${stem}.csv`);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* @param {object[]} invoices
|
|
186
|
-
* @param {'csv'|'excel'} format
|
|
187
|
-
* @param {Function} names
|
|
188
|
-
* @param {string} [stem]
|
|
189
|
-
* @returns {void}
|
|
190
|
-
*/
|
|
191
|
-
export function exportList(invoices, format, names, stem = 'invoices') {
|
|
192
|
-
const rows = listRows(invoices, names);
|
|
193
|
-
const filename = `${safeName(stem, 'invoices')}-${D().format('YYYY-MM-DD')}`;
|
|
194
|
-
if (format === 'excel') {
|
|
195
|
-
save(toExcel('Invoices', rows, 1), 'application/vnd.ms-excel', `${filename}.xls`);
|
|
196
|
-
} else {
|
|
197
|
-
save(toCsv(rows), 'text/csv;charset=utf-8', `${filename}.csv`);
|
|
198
|
-
}
|
|
199
|
-
}
|