domma-cms 0.55.1 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +130 -2
- package/README.md +4 -4
- package/admin/css/admin.css +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +3 -3
- package/admin/js/lib/page-picker.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -0
- package/admin/js/lib/plugin-chrome.js +1 -0
- package/admin/js/lib/shortcode-context-menu.js +2 -2
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-grouping.test.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/lib/slideover-resizable.js +1 -0
- package/admin/js/templates/context-menu-editor.html +212 -0
- package/admin/js/templates/context-menus.html +16 -0
- package/admin/js/templates/plugin-code.html +1 -1
- package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
- package/admin/js/templates/settings.html +16 -97
- package/admin/js/templates/theme.html +173 -0
- package/admin/js/views/context-menu-editor.js +55 -0
- package/admin/js/views/context-menus.js +5 -0
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +25 -23
- package/admin/js/views/search.js +1 -0
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/theme.js +1 -0
- package/bin/cli.js +2 -0
- package/bin/update.js +13 -2
- package/config/menus/admin-sidebar.json +129 -23
- package/config/plugins.json +5 -5
- package/config/search.json +13 -0
- package/config/theme.json +18 -0
- package/package.json +12 -4
- package/plugins/_lib/admin/mail/compose-window.js +914 -0
- package/plugins/_lib/admin/mail/contacts.js +301 -0
- package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
- package/plugins/_lib/admin/mail/folder-tree.js +254 -0
- package/plugins/_lib/admin/mail/identity.js +480 -0
- package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
- package/plugins/_lib/admin/mail/keyboard.js +136 -0
- package/plugins/_lib/admin/mail/mail.css +1 -0
- package/plugins/_lib/admin/mail/mail.html +71 -0
- package/plugins/_lib/admin/mail/panes.js +253 -0
- package/plugins/_lib/admin/mail/reader-view.js +4453 -0
- package/plugins/_lib/admin/mail/resizable.js +26 -0
- package/plugins/_lib/admin/mail/rules.js +343 -0
- package/plugins/_lib/admin/mail/scheduling.js +203 -0
- package/plugins/_lib/admin/mail/section-kit.js +277 -0
- package/plugins/_lib/admin/mail/templates.js +238 -0
- package/plugins/_lib/admin/mail/threads.js +200 -0
- package/plugins/_lib/admin/mail/vacation.js +269 -0
- package/plugins/_lib/admin/ui/help.css +1 -0
- package/plugins/_lib/admin/ui/help.js +174 -0
- package/plugins/_lib/admin/ui/resizable.js +151 -0
- package/plugins/_lib/dataStore.js +117 -0
- package/plugins/_lib/mail/accounts.js +919 -0
- package/plugins/_lib/mail/bodyTokens.js +101 -0
- package/plugins/_lib/mail/compose.js +256 -0
- package/plugins/_lib/mail/defaults.js +57 -0
- package/plugins/_lib/mail/diagnostics.js +154 -0
- package/plugins/_lib/mail/envelope.js +161 -0
- package/plugins/_lib/mail/folders.js +192 -0
- package/plugins/_lib/mail/handoff.js +274 -0
- package/plugins/_lib/mail/imapPool.js +293 -0
- package/plugins/_lib/mail/mbox.js +74 -0
- package/plugins/_lib/mail/pollSchedule.js +79 -0
- package/plugins/_lib/mail/poller.js +135 -0
- package/plugins/_lib/mail/priority.js +138 -0
- package/plugins/_lib/mail/readRoutes.js +680 -0
- package/plugins/_lib/mail/render.js +291 -0
- package/plugins/_lib/mail/ruleRunner.js +152 -0
- package/plugins/_lib/mail/scheduler.js +254 -0
- package/plugins/_lib/mail/secretbox.js +229 -0
- package/plugins/_lib/mail/send.js +396 -0
- package/plugins/_lib/mail/store.js +1002 -0
- package/plugins/_lib/mail/sync.js +292 -0
- package/plugins/_lib/mail/syncPlan.js +126 -0
- package/plugins/_lib/mail/syncSelection.js +82 -0
- package/plugins/_lib/mail/unsubscribe.js +183 -0
- package/plugins/_lib/mail/vacationRunner.js +114 -0
- package/plugins/_lib/mail/write.js +473 -0
- package/plugins/_lib/schemaSync.js +83 -0
- package/plugins/_template/admin/css/index.css +0 -0
- package/plugins/_template/admin/templates/index.html +4 -4
- package/plugins/_template/admin/views/index.js +7 -0
- package/plugins/analytics/admin/css/index.css +1 -0
- package/plugins/analytics/admin/templates/analytics.html +22 -13
- package/plugins/analytics/plugin.json +3 -0
- package/plugins/blog/admin/css/index.css +1 -0
- package/plugins/blog/admin/templates/blog.html +30 -18
- package/plugins/blog/admin/templates/categories.html +2 -2
- package/plugins/blog/admin/templates/comments.html +2 -2
- package/plugins/blog/admin/templates/post-editor.html +34 -34
- package/plugins/blog/admin/templates/settings.html +6 -3
- package/plugins/blog/admin/views/blog.js +8 -5
- package/plugins/blog/admin/views/categories.js +5 -10
- package/plugins/blog/admin/views/comments.js +5 -5
- package/plugins/blog/admin/views/post-editor.js +39 -20
- package/plugins/blog/admin/views/settings.js +52 -50
- package/plugins/blog/collections/categories/schema.json +7 -6
- package/plugins/blog/collections/comments/schema.json +11 -10
- package/plugins/blog/collections/posts/schema.json +14 -13
- package/plugins/blog/plugin.js +36 -13
- package/plugins/blog/plugin.json +13 -5
- package/plugins/blog/plugin.public.js +312 -0
- package/plugins/contacts/admin/css/index.css +1 -0
- package/plugins/contacts/admin/templates/contacts.html +128 -0
- package/plugins/contacts/admin/views/contacts.js +237 -4
- package/plugins/contacts/collections/user-contacts/schema.json +108 -0
- package/plugins/contacts/plugin.js +214 -27
- package/plugins/contacts/plugin.json +4 -1
- package/plugins/invoice/admin/css/index.css +1 -0
- package/plugins/invoice/admin/templates/editor.html +140 -49
- package/plugins/invoice/admin/templates/index.html +153 -23
- package/plugins/invoice/admin/templates/issuers.html +2 -5
- package/plugins/invoice/admin/templates/receivers.html +2 -5
- package/plugins/invoice/admin/views/contacts-source.js +266 -0
- package/plugins/invoice/admin/views/editor.js +366 -199
- package/plugins/invoice/admin/views/export.js +199 -0
- package/plugins/invoice/admin/views/help-content.js +61 -0
- package/plugins/invoice/admin/views/index.js +582 -94
- package/plugins/invoice/admin/views/issuers.js +24 -17
- package/plugins/invoice/admin/views/media.js +172 -0
- package/plugins/invoice/admin/views/party-view.js +305 -67
- package/plugins/invoice/admin/views/payments.js +127 -0
- package/plugins/invoice/admin/views/print.js +130 -0
- package/plugins/invoice/admin/views/receivers.js +49 -16
- package/plugins/invoice/admin/views/send.js +212 -0
- package/plugins/invoice/admin/views/settings.js +594 -0
- package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
- package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
- package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
- package/plugins/invoice/collections/invoices/schema.json +19 -13
- package/plugins/invoice/config.js +27 -6
- package/plugins/invoice/pdf.js +164 -0
- package/plugins/invoice/plugin.js +1217 -44
- package/plugins/invoice/plugin.json +10 -9
- package/plugins/invoice/templates/_base.css +1 -0
- package/plugins/invoice/templates/classic-nologo.html +100 -0
- package/plugins/invoice/templates/classic.html +91 -0
- package/plugins/invoice/templates/invoice-print.html +24 -0
- package/plugins/invoice/templates/minimal.html +99 -0
- package/plugins/invoice/templates/modern-nologo.html +114 -0
- package/plugins/invoice/templates/modern.html +113 -0
- package/plugins/invoice/templates/templates.json +11 -0
- package/plugins/mail-reader/admin/views/mail.js +19 -0
- package/plugins/mail-reader/config.js +7 -0
- package/plugins/mail-reader/plugin.js +48 -0
- package/plugins/mail-reader/plugin.json +33 -0
- package/plugins/notes/admin/views/notes.js +1 -1
- package/plugins/notes/plugin.json +2 -2
- package/plugins/surveys/lib/audience.js +37 -0
- package/plugins/surveys/lib/campaigns.js +43 -0
- package/plugins/surveys/lib/ledger.js +110 -0
- package/plugins/surveys/lib/sending.js +106 -0
- package/plugins/surveys/lib/stats.js +62 -0
- package/plugins/surveys/lib/submit.js +95 -0
- package/plugins/surveys/lib/tokens.js +28 -0
- package/plugins/surveys/plugin.public.js +149 -0
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
- package/public/css/forms.css +1 -1
- package/public/css/search.css +1 -0
- package/public/css/site.css +1 -1
- package/public/js/collection-context.js +2 -2
- package/public/js/context-menus.js +1 -0
- package/public/js/form-logic-engine.js +1 -1
- package/public/js/forms.js +2 -2
- package/public/js/search.js +1 -0
- package/public/js/site.js +1 -1
- package/scripts/build.js +37 -3
- package/scripts/copy-domma.js +48 -0
- package/scripts/seed.js +1996 -0
- package/server/routes/api/collections.js +34 -0
- package/server/routes/api/context-menus.js +104 -0
- package/server/routes/api/forms.js +42 -3
- package/server/routes/api/notifications.js +69 -19
- package/server/routes/api/plugins.js +50 -6
- package/server/routes/api/search.js +43 -0
- package/server/routes/api/theme.js +69 -0
- package/server/routes/public.js +42 -7
- package/server/server.js +74 -0
- package/server/services/adapters/FileAdapter.js +6 -1
- package/server/services/content.js +26 -0
- package/server/services/contextMenus.js +477 -0
- package/server/services/markdown.js +70 -9
- package/server/services/permissionRegistry.js +24 -0
- package/server/services/pluginFiles.js +52 -11
- package/server/services/plugins.js +229 -6
- package/server/services/renderer.js +144 -22
- package/server/services/roles.js +1 -1
- package/server/services/search-migration.js +82 -0
- package/server/services/search.js +413 -0
- package/server/services/sidebar-migration.js +1 -0
- package/server/services/themeSettings.js +541 -0
- package/server/services/users.js +8 -0
- package/server/templates/page.html +4 -2
- package/plugins/contacts/data/contacts.json +0 -20
- package/plugins/notes/data/notes.json +0 -1
- package/plugins/site-search/admin/views/site-search.js +0 -116
- package/plugins/site-search/config.js +0 -15
- package/plugins/site-search/plugin.js +0 -188
- package/plugins/site-search/plugin.json +0 -40
- package/plugins/site-search/public/inject-body.html +0 -17
- package/plugins/site-search/public/inject-head.html +0 -1
- package/plugins/site-search/public/search.css +0 -1
- package/plugins/site-search/public/search.js +0 -1
- package/plugins/todo/data/todos.json +0 -1
|
@@ -1,9 +1,162 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import {fileURLToPath} from 'url';
|
|
3
|
+
|
|
1
4
|
import defaultConfig from './config.js';
|
|
2
|
-
import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from '../../server/services/collections.js';
|
|
5
|
+
import {createEntry, deleteEntry, getCollection, getEntry, listEntries, updateCollection, updateEntry} from '../../server/services/collections.js';
|
|
6
|
+
import {syncPluginSchemas} from '../_lib/schemaSync.js';
|
|
7
|
+
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
3
9
|
|
|
4
10
|
const CONTACTS_SLUG = 'contacts-contacts';
|
|
5
11
|
const GROUPS_SLUG = 'contacts-groups';
|
|
6
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The fields a contact carries beyond a name and a phone number.
|
|
15
|
+
*
|
|
16
|
+
* Kept as one list so the create, update and import paths cannot drift apart -
|
|
17
|
+
* they each used to spell the field set out by hand, which is how a field gets
|
|
18
|
+
* added to two of the three and quietly dropped by the other.
|
|
19
|
+
*/
|
|
20
|
+
const TEXT_FIELDS = [
|
|
21
|
+
'name', 'email', 'phone', 'notes',
|
|
22
|
+
'company', 'jobTitle', 'website',
|
|
23
|
+
'vatNumber', 'companyNumber', 'reference', 'billingEmail', 'currency',
|
|
24
|
+
'accountName', 'bankName', 'sortCode', 'accountNumber', 'iban', 'bic'
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/** Repeatable lists, each a array of small objects. */
|
|
28
|
+
const LIST_FIELDS = ['addresses', 'emails', 'phones'];
|
|
29
|
+
|
|
30
|
+
/** Parse a value that may have been stored as JSON text by an import or the API. */
|
|
31
|
+
function toArray(value) {
|
|
32
|
+
if (Array.isArray(value)) return value;
|
|
33
|
+
if (typeof value === 'string' && value.trim()) {
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(value);
|
|
36
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
37
|
+
} catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const str = (v) => (typeof v === 'string' ? v.trim() : (v === undefined || v === null ? '' : String(v)));
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* One address, with every part named.
|
|
48
|
+
*
|
|
49
|
+
* Stored as parts rather than a blob because an invoice, a letter and a
|
|
50
|
+
* delivery note each want them arranged differently - and because a postcode
|
|
51
|
+
* you cannot read out separately is not much use for anything.
|
|
52
|
+
*/
|
|
53
|
+
function cleanAddress(raw) {
|
|
54
|
+
return {
|
|
55
|
+
label: str(raw?.label) || 'Address',
|
|
56
|
+
line1: str(raw?.line1),
|
|
57
|
+
line2: str(raw?.line2),
|
|
58
|
+
city: str(raw?.city),
|
|
59
|
+
region: str(raw?.region),
|
|
60
|
+
postcode: str(raw?.postcode),
|
|
61
|
+
country: str(raw?.country),
|
|
62
|
+
isBilling: raw?.isBilling === true || raw?.isBilling === 'true'
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function cleanEmail(raw) {
|
|
67
|
+
return { label: str(raw?.label) || 'Email', address: str(raw?.address) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function cleanPhone(raw) {
|
|
71
|
+
return { label: str(raw?.label) || 'Phone', number: str(raw?.number) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const CLEANERS = { addresses: cleanAddress, emails: cleanEmail, phones: cleanPhone };
|
|
75
|
+
|
|
76
|
+
/** Coerce a repeatable list, dropping rows with nothing in them. */
|
|
77
|
+
function cleanList(field, value) {
|
|
78
|
+
const clean = CLEANERS[field];
|
|
79
|
+
return toArray(value)
|
|
80
|
+
.map(clean)
|
|
81
|
+
.filter((row) => Object.entries(row).some(([k, v]) => k !== 'label' && v !== '' && v !== false));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* An address as an invoice prints it: one line per part, blanks omitted.
|
|
86
|
+
*
|
|
87
|
+
* @param {object} address
|
|
88
|
+
* @returns {string}
|
|
89
|
+
*/
|
|
90
|
+
export function formatAddress(address) {
|
|
91
|
+
if (!address) return '';
|
|
92
|
+
return [address.line1, address.line2, address.city, address.region, address.postcode, address.country]
|
|
93
|
+
.map(str)
|
|
94
|
+
.filter(Boolean)
|
|
95
|
+
.join('\n');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The address to bill to: the one marked for billing, else the first.
|
|
100
|
+
*
|
|
101
|
+
* @param {object[]} addresses
|
|
102
|
+
* @returns {object|null}
|
|
103
|
+
*/
|
|
104
|
+
export function billingAddress(addresses) {
|
|
105
|
+
const list = toArray(addresses);
|
|
106
|
+
return list.find((a) => a?.isBilling) ?? list[0] ?? null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A contact projected into the shape the Invoice plugin's receivers use.
|
|
111
|
+
*
|
|
112
|
+
* This is the join between the two plugins: the field names are Invoice's, not
|
|
113
|
+
* Contacts', so the caller can post it straight at `/api/plugins/invoice/receivers`.
|
|
114
|
+
*
|
|
115
|
+
* @param {object} contact as returned by toContact()
|
|
116
|
+
* @returns {object}
|
|
117
|
+
*/
|
|
118
|
+
export function toBillingProfile(contact) {
|
|
119
|
+
const address = billingAddress(contact.addresses);
|
|
120
|
+
return {
|
|
121
|
+
name: contact.name,
|
|
122
|
+
company: contact.company,
|
|
123
|
+
// The billing address wins over the personal one, which is the whole
|
|
124
|
+
// point of having both - invoices go to accounts, not to a mobile.
|
|
125
|
+
email: contact.billingEmail || contact.email,
|
|
126
|
+
phone: contact.phone,
|
|
127
|
+
address: formatAddress(address),
|
|
128
|
+
vatNumber: contact.vatNumber,
|
|
129
|
+
reference: contact.reference,
|
|
130
|
+
// An issuer prints these so the invoice can be paid; a receiver simply
|
|
131
|
+
// has none, and an empty string is what the Invoice plugin stores anyway.
|
|
132
|
+
bankDetails: formatBankDetails(contact)
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Bank details as an invoice prints them: label and value, one per line,
|
|
138
|
+
* blanks omitted. Sort code and account number ride together on one line
|
|
139
|
+
* because that is how anyone reads them off a page.
|
|
140
|
+
*
|
|
141
|
+
* @param {object} contact
|
|
142
|
+
* @returns {string}
|
|
143
|
+
*/
|
|
144
|
+
export function formatBankDetails(contact) {
|
|
145
|
+
const lines = [];
|
|
146
|
+
if (contact.accountName) lines.push(contact.accountName);
|
|
147
|
+
if (contact.bankName) lines.push(contact.bankName);
|
|
148
|
+
|
|
149
|
+
const sort = str(contact.sortCode);
|
|
150
|
+
const acct = str(contact.accountNumber);
|
|
151
|
+
if (sort && acct) lines.push(`Sort ${sort} Acc ${acct}`);
|
|
152
|
+
else if (sort) lines.push(`Sort ${sort}`);
|
|
153
|
+
else if (acct) lines.push(`Acc ${acct}`);
|
|
154
|
+
|
|
155
|
+
if (contact.iban) lines.push(`IBAN ${str(contact.iban)}`);
|
|
156
|
+
if (contact.bic) lines.push(`BIC ${str(contact.bic)}`);
|
|
157
|
+
return lines.join('\n');
|
|
158
|
+
}
|
|
159
|
+
|
|
7
160
|
/** Map a collection entry to the shape the admin view expects. */
|
|
8
161
|
function toContact(entry) {
|
|
9
162
|
let groups = entry.data.groups;
|
|
@@ -14,18 +167,20 @@ function toContact(entry) {
|
|
|
14
167
|
groups = [];
|
|
15
168
|
}
|
|
16
169
|
}
|
|
17
|
-
|
|
170
|
+
const contact = {
|
|
18
171
|
id: entry.id,
|
|
19
|
-
name: entry.data.name ?? '',
|
|
20
|
-
email: entry.data.email ?? '',
|
|
21
|
-
phone: entry.data.phone ?? '',
|
|
22
172
|
groups: Array.isArray(groups) ? groups : [],
|
|
23
|
-
notes: entry.data.notes ?? '',
|
|
24
173
|
favourite: entry.data.favourite === true || entry.data.favourite === 'true',
|
|
174
|
+
paymentTerms: entry.data.paymentTerms === '' || entry.data.paymentTerms === undefined || entry.data.paymentTerms === null
|
|
175
|
+
? null
|
|
176
|
+
: Number(entry.data.paymentTerms),
|
|
25
177
|
userId: entry.data.userId ?? null,
|
|
26
178
|
createdAt: entry.meta.createdAt,
|
|
27
179
|
updatedAt: entry.meta.updatedAt
|
|
28
180
|
};
|
|
181
|
+
for (const field of TEXT_FIELDS) contact[field] = entry.data[field] ?? '';
|
|
182
|
+
for (const field of LIST_FIELDS) contact[field] = toArray(entry.data[field]);
|
|
183
|
+
return contact;
|
|
29
184
|
}
|
|
30
185
|
|
|
31
186
|
function toGroupName(entry) {
|
|
@@ -34,6 +189,11 @@ function toGroupName(entry) {
|
|
|
34
189
|
|
|
35
190
|
export default async function contactsPlugin(fastify, options) {
|
|
36
191
|
const {authenticate} = options.auth;
|
|
192
|
+
|
|
193
|
+
// A collection is created once and never revisited, so the fields added
|
|
194
|
+
// here reach a fresh install and nowhere else without this.
|
|
195
|
+
await syncPluginSchemas(path.join(__dirname, 'collections'),
|
|
196
|
+
{getCollection, updateCollection}, fastify.log);
|
|
37
197
|
const config = {...defaultConfig, ...(options.settings || {})};
|
|
38
198
|
const scope = config.scope ?? 'user';
|
|
39
199
|
|
|
@@ -53,6 +213,30 @@ export default async function contactsPlugin(fastify, options) {
|
|
|
53
213
|
return entries.map(e => ({id: e.id, name: toGroupName(e)}));
|
|
54
214
|
}
|
|
55
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Build the stored shape from whatever a caller sent.
|
|
218
|
+
*
|
|
219
|
+
* Used by create AND import so the two cannot drift - a field added to one
|
|
220
|
+
* and forgotten in the other is the bug this exists to prevent.
|
|
221
|
+
*
|
|
222
|
+
* @param {object} raw
|
|
223
|
+
* @param {string|null} uid
|
|
224
|
+
* @returns {object}
|
|
225
|
+
*/
|
|
226
|
+
function buildContact(raw, uid) {
|
|
227
|
+
const data = {
|
|
228
|
+
groups: Array.isArray(raw.groups) ? raw.groups : [],
|
|
229
|
+
favourite: raw.favourite === true || raw.favourite === 'true',
|
|
230
|
+
paymentTerms: raw.paymentTerms === '' || raw.paymentTerms === undefined || raw.paymentTerms === null
|
|
231
|
+
? ''
|
|
232
|
+
: (Number(raw.paymentTerms) || 0),
|
|
233
|
+
userId: uid ?? null
|
|
234
|
+
};
|
|
235
|
+
for (const field of TEXT_FIELDS) data[field] = str(raw[field]);
|
|
236
|
+
for (const field of LIST_FIELDS) data[field] = cleanList(field, raw[field]);
|
|
237
|
+
return data;
|
|
238
|
+
}
|
|
239
|
+
|
|
56
240
|
// -------------------------------------------------------------------------
|
|
57
241
|
// Contacts CRUD
|
|
58
242
|
// -------------------------------------------------------------------------
|
|
@@ -86,21 +270,14 @@ export default async function contactsPlugin(fastify, options) {
|
|
|
86
270
|
/** POST /api/plugins/contacts/contacts */
|
|
87
271
|
fastify.post('/contacts', {preHandler: [authenticate]}, async (request, reply) => {
|
|
88
272
|
const uid = userId(request);
|
|
89
|
-
const
|
|
273
|
+
const body = request.body ?? {};
|
|
90
274
|
|
|
91
|
-
if (!name || typeof name !== 'string' || !name.trim()) {
|
|
275
|
+
if (!body.name || typeof body.name !== 'string' || !body.name.trim()) {
|
|
92
276
|
return reply.code(400).send({ error: 'name is required' });
|
|
93
277
|
}
|
|
94
278
|
|
|
95
|
-
const entry = await createEntry(CONTACTS_SLUG,
|
|
96
|
-
|
|
97
|
-
email: email?.trim() ?? '',
|
|
98
|
-
phone: phone?.trim() ?? '',
|
|
99
|
-
groups: Array.isArray(groups) ? groups : [],
|
|
100
|
-
notes: notes?.trim() ?? '',
|
|
101
|
-
favourite: favourite === true,
|
|
102
|
-
userId: uid ?? null
|
|
103
|
-
}, {createdBy: uid, source: 'admin'});
|
|
279
|
+
const entry = await createEntry(CONTACTS_SLUG, buildContact(body, uid),
|
|
280
|
+
{createdBy: uid, source: 'admin'});
|
|
104
281
|
|
|
105
282
|
return reply.code(201).send(toContact(entry));
|
|
106
283
|
});
|
|
@@ -116,9 +293,12 @@ export default async function contactsPlugin(fastify, options) {
|
|
|
116
293
|
if (uid && entry.data.userId !== uid) return reply.code(404).send({error: 'Contact not found'});
|
|
117
294
|
|
|
118
295
|
const merged = {...entry.data};
|
|
119
|
-
for (const key of [
|
|
296
|
+
for (const key of [...TEXT_FIELDS, 'groups', 'favourite', 'paymentTerms']) {
|
|
120
297
|
if (key in updates) merged[key] = updates[key];
|
|
121
298
|
}
|
|
299
|
+
for (const key of LIST_FIELDS) {
|
|
300
|
+
if (key in updates) merged[key] = cleanList(key, updates[key]);
|
|
301
|
+
}
|
|
122
302
|
|
|
123
303
|
const updated = await updateEntry(CONTACTS_SLUG, id, merged);
|
|
124
304
|
return reply.send(toContact(updated));
|
|
@@ -159,6 +339,21 @@ export default async function contactsPlugin(fastify, options) {
|
|
|
159
339
|
return reply.send({ ok: true });
|
|
160
340
|
});
|
|
161
341
|
|
|
342
|
+
/**
|
|
343
|
+
* GET /api/plugins/contacts/contacts/:id/billing
|
|
344
|
+
*
|
|
345
|
+
* The contact as the Invoice plugin wants a receiver: same field names, so
|
|
346
|
+
* the body can go straight to POST /api/plugins/invoice/receivers. Nothing
|
|
347
|
+
* in the CMS calls this yet - it is the seam, put where the data is.
|
|
348
|
+
*/
|
|
349
|
+
fastify.get('/contacts/:id/billing', {preHandler: [authenticate]}, async (request, reply) => {
|
|
350
|
+
const uid = userId(request);
|
|
351
|
+
const entry = await getEntry(CONTACTS_SLUG, request.params.id);
|
|
352
|
+
if (!entry) return reply.code(404).send({error: 'Contact not found'});
|
|
353
|
+
if (uid && entry.data.userId !== uid) return reply.code(404).send({error: 'Contact not found'});
|
|
354
|
+
return reply.send(toBillingProfile(toContact(entry)));
|
|
355
|
+
});
|
|
356
|
+
|
|
162
357
|
// -------------------------------------------------------------------------
|
|
163
358
|
// Groups CRUD
|
|
164
359
|
// -------------------------------------------------------------------------
|
|
@@ -274,15 +469,7 @@ export default async function contactsPlugin(fastify, options) {
|
|
|
274
469
|
let imported = 0;
|
|
275
470
|
for (const raw of incoming) {
|
|
276
471
|
if (!raw || typeof raw.name !== 'string') continue;
|
|
277
|
-
await createEntry(CONTACTS_SLUG, {
|
|
278
|
-
name: raw.name?.trim() ?? '',
|
|
279
|
-
email: raw.email?.trim() ?? '',
|
|
280
|
-
phone: raw.phone?.trim() ?? '',
|
|
281
|
-
groups: Array.isArray(raw.groups) ? raw.groups : [],
|
|
282
|
-
notes: raw.notes?.trim() ?? '',
|
|
283
|
-
favourite: raw.favourite === true,
|
|
284
|
-
userId: uid ?? null
|
|
285
|
-
}, {source: 'import'});
|
|
472
|
+
await createEntry(CONTACTS_SLUG, buildContact(raw, uid), {source: 'import'});
|
|
286
473
|
imported++;
|
|
287
474
|
}
|
|
288
475
|
|
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
"date": "2026-03-24",
|
|
8
8
|
"icon": "users",
|
|
9
9
|
"admin": {
|
|
10
|
+
"css": [
|
|
11
|
+
"admin/css/index.css"
|
|
12
|
+
],
|
|
10
13
|
"sidebar": [
|
|
11
14
|
{
|
|
12
15
|
"id": "contacts",
|
|
@@ -25,7 +28,7 @@
|
|
|
25
28
|
],
|
|
26
29
|
"views": {
|
|
27
30
|
"plugin-contacts": {
|
|
28
|
-
"entry": "contacts/admin/views/contacts.js?v=
|
|
31
|
+
"entry": "contacts/admin/views/contacts.js?v=25e5dae4",
|
|
29
32
|
"exportName": "contactsView"
|
|
30
33
|
}
|
|
31
34
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.invoice-hidden{display:none!important}.invoice-editor{align-items:flex-start}.invoice-editor-main{flex:1;min-width:0}.invoice-editor-side{width:300px;flex-shrink:0}@media(max-width:900px){.invoice-editor{flex-wrap:wrap}.invoice-editor-side{width:100%}}.invoice-num,.invoice-line-total{font-variant-numeric:tabular-nums}#line-items-table th{font-weight:600;font-size:.85em}#line-items-table td{vertical-align:middle}#line-items-table .form-input{padding:.35rem .5rem}#line-items-table th.invoice-col-desc{width:44%}#line-items-table th.invoice-col-qty{width:13%;text-align:right}#line-items-table th.invoice-col-price{width:17%;text-align:right}#line-items-table th.invoice-col-total{width:18%;text-align:right}#line-items-table th.invoice-col-remove{width:8%}#line-items-table td.invoice-line-total{text-align:right}#line-items-table input[type=number]{text-align:right;font-variant-numeric:tabular-nums}.li-remove{font-size:1.1em;line-height:1;padding:.15rem .5rem;min-width:1.9rem}.invoice-number-link{font-weight:600}.invoice-row-actions{display:flex;gap:.4rem}#invoices-table td .invoice-num{display:block;text-align:right}#invoices-table th:nth-child(5){text-align:right}#invoices-table th:last-child,#invoices-table td:last-child{text-align:right;width:3rem}.invoice-party-form{display:flex;flex-direction:column;gap:.75rem;padding:.5rem}.invoice-party-actions{display:flex;justify-content:flex-end;gap:.75rem;margin-top:.75rem}.stat-icon--danger{background:color-mix(in srgb,var(--dm-danger) 15%,transparent);color:var(--dm-danger)}.invoice-col-date,.invoice-col-method{width:22%}#payments-table th{font-weight:600;font-size:.85em}#payments-table td{vertical-align:middle}#payments-table td.invoice-line-total,#payments-table th.invoice-col-total{text-align:right}.invoice-balance strong{color:var(--dm-warning)}.invoice-balance.is-settled strong{color:var(--dm-success)}.invoice-toolbar .card-body{padding:.75rem 1rem}.invoice-toolbar-row{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}.invoice-toolbar-row--sub{margin-top:.6rem;padding-top:.6rem;border-top:1px solid var(--dm-border);font-size:.85rem}.invoice-toolbar-spacer{flex:1 1 auto}.invoice-search{position:relative;display:flex;align-items:center;flex:1 1 260px;min-width:200px}.invoice-search>[data-icon],.invoice-search>svg{position:absolute;left:.6rem;color:var(--dm-text-muted);pointer-events:none}.invoice-search .form-input{padding-left:2rem}.invoice-filter-narrow{width:auto;min-width:9rem}.invoice-field{display:flex;align-items:center;gap:.35rem;color:var(--dm-text-muted);white-space:nowrap}.invoice-field .form-input{width:auto}.invoice-check{display:flex;align-items:center;gap:.35rem;white-space:nowrap;cursor:pointer}.invoice-matches{white-space:nowrap}.invoice-hint{margin:-.25rem 0 .75rem}.invoice-row-menu{font-size:1.1rem;line-height:1;padding:.15rem .55rem}.invoice-settings{padding:.25rem}.invoice-settings .tab-panel{padding-top:.75rem}.invoice-settings-form{margin-bottom:.5rem}.invoice-settings-inline{display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem}.invoice-settings-inline .form-label{margin:0;white-space:nowrap}.invoice-settings-inline .form-select{width:auto;min-width:12rem}.invoice-panel-head{display:flex;align-items:center;justify-content:flex-end;gap:.5rem;margin-bottom:.5rem;flex-wrap:wrap}.invoice-number-preview{font-family:var(--dm-font-mono, monospace);color:var(--dm-text-muted);margin-bottom:.25rem}.invoice-slideover-resizer{padding:0;position:absolute;left:0;top:0;bottom:0;width:6px;cursor:col-resize;z-index:2;background:transparent}.invoice-slideover-resizer:hover,.is-resizing>.invoice-slideover-resizer{background:var(--dm-primary);opacity:.7}.is-resizing{user-select:none;transition:none!important}.invoice-media-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:.6rem}.invoice-media-tile{display:flex;flex-direction:column;align-items:center;gap:.3rem;padding:.5rem;border:1px solid var(--dm-border);border-radius:var(--dm-radius-md, 8px);background:var(--dm-surface);cursor:pointer}.invoice-media-tile:hover{border-color:var(--dm-primary)}.invoice-media-tile img{max-width:100%;max-height:72px;object-fit:contain}.invoice-media-tile span{font-size:.7rem;color:var(--dm-text-muted);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.invoice-logo-frame{display:flex;align-items:center;justify-content:center;min-height:110px;padding:.75rem;border:1px dashed var(--dm-border);border-radius:var(--dm-radius-md, 8px);background:var(--dm-background-alt)}.invoice-logo-preview{max-height:90px;max-width:100%;object-fit:contain}.invoice-media-row{display:flex;gap:.5rem;align-items:center}.invoice-media-row .form-input{flex:1 1 auto;min-width:0}.invoice-media-pick{flex:0 0 auto}.invoice-picker-filters{display:flex;gap:.5rem;margin-bottom:.6rem}.invoice-picker-filters .form-input{flex:1 1 auto}.invoice-picker-filters .form-select{width:auto}.invoice-picker-list{display:flex;flex-direction:column;gap:.2rem;max-height:60vh;overflow-y:auto}.invoice-picker-row{display:flex;align-items:baseline;gap:.6rem;width:100%;padding:.4rem .6rem;border:1px solid transparent;border-radius:var(--dm-radius-sm, 6px);background:transparent;text-align:left;cursor:pointer}.invoice-picker-row:hover{background:var(--dm-hover-bg, var(--dm-background-alt))}.invoice-picker-row.is-chosen{border-color:var(--dm-primary);background:color-mix(in srgb,var(--dm-primary) 10%,transparent)}.invoice-picker-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.invoice-picker-mail{color:var(--dm-text-muted);font-size:.8rem;white-space:nowrap}.invoice-picker-count{flex:1 1 auto}.invoice-recipient-actions{display:flex;gap:.5rem;margin-top:.35rem}.invoice-receiver-row{display:flex;gap:.5rem;align-items:center}.invoice-receiver-row .form-select{flex:1 1 auto;min-width:0}#kpi-outstanding{font-size:1.4rem;line-height:1.2;font-variant-numeric:tabular-nums}@media(max-width:1500px){#kpi-outstanding{font-size:1.15rem}}.invoice-empty{margin:.75rem 0 0;padding:.85rem 1rem;border:1px dashed var(--dm-border);border-radius:var(--dm-radius-md, 8px);text-align:center}.dm-plugin-body .card-header--quiet{background:transparent;border-bottom:0;padding:.65rem 1rem 0;min-height:0}.dm-plugin-body .card-header--quiet+.card-body{padding-top:.5rem}.invoice-cap{display:inline-flex;align-items:center;font-size:.7rem;font-weight:700;letter-spacing:.07em;text-transform:uppercase;color:var(--dm-text-muted)}.invoice-kpi{cursor:pointer;transition:border-color .15s,transform .15s}.invoice-kpi:hover{border-color:var(--dm-primary);transform:translateY(-1px)}.invoice-kpi:active{transform:none}.invoice-setup{border-color:var(--dm-primary);margin-bottom:.75rem}.invoice-setup .card-body{display:flex;flex-direction:column;gap:.4rem}.invoice-setup p{margin:0}#btn-new-invoice:disabled{opacity:.5;cursor:not-allowed}.invoice-template-picker{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:.5rem;margin-bottom:.75rem}.invoice-template-card{display:flex;flex-direction:column;align-items:flex-start;gap:.2rem;padding:.6rem .75rem;text-align:left;background:var(--dm-surface);border:1px solid var(--dm-border);border-radius:var(--dm-radius-md, 8px);cursor:pointer}.invoice-template-card:hover{border-color:var(--dm-primary)}.invoice-template-card.is-chosen{border-color:var(--dm-primary);background:color-mix(in srgb,var(--dm-primary) 10%,transparent)}.invoice-template-name{font-size:.85rem}.invoice-template-desc{font-size:.72rem;color:var(--dm-text-muted);line-height:1.35}.invoice-template-tag{font-size:.6rem;margin-top:.15rem}.invoice-template-preview{width:100%;height:60vh;min-height:320px;border:1px solid var(--dm-border);border-radius:var(--dm-radius-md, 8px);background:#fff}.invoice-template-preview.is-loading{opacity:.5}
|
|
@@ -1,30 +1,43 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
<!--
|
|
2
|
+
Every value on this screen is bound. The view builds an observable per field
|
|
3
|
+
and one computed per derived number, then calls M.applyBindings once - there
|
|
4
|
+
is no render function, and no path by which the DOM and the totals can drift.
|
|
5
|
+
The heading is removed by the admin's plugin shell (dedupeHeading), so nothing
|
|
6
|
+
per-record is bound to it.
|
|
7
|
+
-->
|
|
8
|
+
<div class="view-header">
|
|
9
|
+
<h1>Invoice</h1>
|
|
10
|
+
<div class="view-header-actions">
|
|
11
|
+
<button id="btn-print" type="button" class="btn btn-secondary" data-if="isEdit.value" data-on-click="openPrint">
|
|
12
|
+
<span data-icon="printer"></span> Print
|
|
13
|
+
</button>
|
|
14
|
+
<button id="btn-pdf" type="button" class="btn btn-secondary" data-if="canPdf.value" data-on-click="downloadPdf">
|
|
15
|
+
<span data-icon="download"></span> Download PDF
|
|
16
|
+
</button>
|
|
17
|
+
<button id="btn-send" type="button" class="btn btn-secondary" data-if="isEdit.value" data-on-click="sendInvoice">
|
|
18
|
+
<span data-icon="mail"></span> Send
|
|
19
|
+
</button>
|
|
20
|
+
<button id="btn-save" type="button" class="btn btn-primary" data-on-click="save">
|
|
8
21
|
<span data-icon="save"></span> Save
|
|
9
22
|
</button>
|
|
10
23
|
</div>
|
|
11
24
|
</div>
|
|
12
25
|
|
|
13
|
-
<div class="
|
|
26
|
+
<div class="flex gap-4 invoice-editor">
|
|
14
27
|
|
|
15
|
-
<div
|
|
28
|
+
<div class="invoice-editor-main">
|
|
16
29
|
|
|
17
30
|
<div class="card mb-3">
|
|
18
31
|
<div class="card-body">
|
|
19
32
|
<div class="grid grid-cols-2 gap-3">
|
|
20
33
|
<div>
|
|
21
|
-
<label class="form-label">Invoice Number</label>
|
|
22
|
-
<input id="f-number" type="text" class="form-
|
|
23
|
-
<div class="text-muted
|
|
34
|
+
<label class="form-label" for="f-number" data-help="Leave it blank and one is allocated when you save - per prefix, per year, never reused.">Invoice Number</label>
|
|
35
|
+
<input id="f-number" type="text" class="form-input font-mono" data-model="number.value" placeholder="auto-generated">
|
|
36
|
+
<div class="text-muted text-sm">Leave blank to auto-number</div>
|
|
24
37
|
</div>
|
|
25
38
|
<div>
|
|
26
|
-
<label class="form-label">Status</label>
|
|
27
|
-
<select id="f-status" class="form-select">
|
|
39
|
+
<label class="form-label" for="f-status">Status</label>
|
|
40
|
+
<select id="f-status" class="form-select" data-model="status.value">
|
|
28
41
|
<option value="draft">Draft</option>
|
|
29
42
|
<option value="sent">Sent</option>
|
|
30
43
|
<option value="paid">Paid</option>
|
|
@@ -33,29 +46,47 @@
|
|
|
33
46
|
</select>
|
|
34
47
|
</div>
|
|
35
48
|
<div>
|
|
36
|
-
<label class="form-label">Issue Date</label>
|
|
37
|
-
|
|
49
|
+
<label class="form-label" for="f-issue-date">Issue Date</label>
|
|
50
|
+
<!-- E.datePicker attaches to these; it writes ISO into the input and
|
|
51
|
+
fires input/change, so data-model keeps working. -->
|
|
52
|
+
<input id="f-issue-date" type="text" class="form-input" data-model="issueDate.value">
|
|
38
53
|
</div>
|
|
39
54
|
<div>
|
|
40
|
-
<label class="form-label">Due Date</label>
|
|
41
|
-
<input id="f-due-date" type="
|
|
55
|
+
<label class="form-label" for="f-due-date" data-help="Filled in from your payment terms. Past this date a sent invoice reports itself overdue.">Due Date</label>
|
|
56
|
+
<input id="f-due-date" type="text" class="form-input" data-model="dueDate.value">
|
|
42
57
|
</div>
|
|
43
58
|
<div>
|
|
44
|
-
<label class="form-label">Issuer</label>
|
|
45
|
-
<select id="f-issuer" class="form-select"
|
|
59
|
+
<label class="form-label" for="f-issuer" data-help="Which of your businesses is raising this. Manage them under the cog, in Issuers.">Issuer</label>
|
|
60
|
+
<select id="f-issuer" class="form-select"
|
|
61
|
+
data-options="issuers.value"
|
|
62
|
+
data-options-text="label"
|
|
63
|
+
data-options-value="id"
|
|
64
|
+
data-options-caption="'- select -'"
|
|
65
|
+
data-model="issuerId.value"></select>
|
|
46
66
|
</div>
|
|
47
67
|
<div>
|
|
48
|
-
<label class="form-label">Receiver</label>
|
|
49
|
-
<
|
|
68
|
+
<label class="form-label" for="f-receiver" data-help="Who you are billing. From Contacts imports one, with its company name, billing address and VAT number.">Receiver</label>
|
|
69
|
+
<div class="invoice-receiver-row">
|
|
70
|
+
<select id="f-receiver" class="form-select"
|
|
71
|
+
data-options="receivers.value"
|
|
72
|
+
data-options-text="label"
|
|
73
|
+
data-options-value="id"
|
|
74
|
+
data-options-caption="'- select -'"
|
|
75
|
+
data-model="receiverId.value"></select>
|
|
76
|
+
<button type="button" class="btn btn-sm btn-outline" data-if="canImport.value"
|
|
77
|
+
data-on-click="importReceiver" title="Create a receiver from a contact">
|
|
78
|
+
From Contacts
|
|
79
|
+
</button>
|
|
80
|
+
</div>
|
|
50
81
|
</div>
|
|
51
82
|
</div>
|
|
52
83
|
</div>
|
|
53
84
|
</div>
|
|
54
85
|
|
|
55
86
|
<div class="card mb-3">
|
|
56
|
-
<div class="card-header
|
|
57
|
-
<
|
|
58
|
-
<button id="btn-add-line" type="button" class="btn btn-sm btn-outline">
|
|
87
|
+
<div class="card-header card-header--quiet flex justify-between items-center">
|
|
88
|
+
<span class="invoice-cap" data-help="Prices are NET - VAT is applied once, to the total. Right-click a row to duplicate, move or remove it.">Line items</span>
|
|
89
|
+
<button id="btn-add-line" type="button" class="btn btn-sm btn-outline" data-on-click="addLine">
|
|
59
90
|
<span data-icon="plus"></span> Add Line
|
|
60
91
|
</button>
|
|
61
92
|
</div>
|
|
@@ -63,62 +94,122 @@
|
|
|
63
94
|
<table class="table mb-0" id="line-items-table">
|
|
64
95
|
<thead>
|
|
65
96
|
<tr>
|
|
66
|
-
<th
|
|
67
|
-
<th
|
|
68
|
-
<th
|
|
69
|
-
<th
|
|
70
|
-
<th
|
|
97
|
+
<th class="invoice-col-desc">Description</th>
|
|
98
|
+
<th class="invoice-col-qty">Qty</th>
|
|
99
|
+
<th class="invoice-col-price">Unit (net)</th>
|
|
100
|
+
<th class="invoice-col-total">Total</th>
|
|
101
|
+
<th class="invoice-col-remove"></th>
|
|
102
|
+
</tr>
|
|
103
|
+
</thead>
|
|
104
|
+
<!-- key=id is required: it is what keeps a row's own input focused
|
|
105
|
+
and uncommitted while the row above it is deleted. -->
|
|
106
|
+
<tbody id="line-items-body" data-each="lineItems.value key=id">
|
|
107
|
+
<tr>
|
|
108
|
+
<td><input type="text" class="form-input" data-model="description.value" placeholder="Description"></td>
|
|
109
|
+
<td><input type="number" class="form-input" min="0" step="0.01" data-model="quantity.value"></td>
|
|
110
|
+
<td><input type="number" class="form-input" min="0" step="0.01" data-model="unitPrice.value"></td>
|
|
111
|
+
<td class="invoice-line-total" data-bind-text="lineTotalText.value"></td>
|
|
112
|
+
<td>
|
|
113
|
+
<button type="button" class="btn btn-sm btn-ghost li-remove" title="Remove"
|
|
114
|
+
data-on-click="$parent.removeLine($data)">×</button>
|
|
115
|
+
</td>
|
|
116
|
+
</tr>
|
|
117
|
+
</tbody>
|
|
118
|
+
</table>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<div class="card mb-3" data-if="isEdit.value">
|
|
123
|
+
<div class="card-header card-header--quiet flex justify-between items-center">
|
|
124
|
+
<span class="invoice-cap" data-help="Money received against this invoice. Settle the balance and the status moves to paid on its own.">Payments</span>
|
|
125
|
+
<button id="btn-record-payment" type="button" class="btn btn-sm btn-outline" data-on-click="recordPayment">
|
|
126
|
+
<span data-icon="plus"></span> Record Payment
|
|
127
|
+
</button>
|
|
128
|
+
</div>
|
|
129
|
+
<div class="card-body">
|
|
130
|
+
<table class="table mb-0" id="payments-table" data-if="hasPayments.value">
|
|
131
|
+
<thead>
|
|
132
|
+
<tr>
|
|
133
|
+
<th class="invoice-col-date">Date</th>
|
|
134
|
+
<th class="invoice-col-method">Method</th>
|
|
135
|
+
<th>Reference</th>
|
|
136
|
+
<th class="invoice-col-total">Amount</th>
|
|
137
|
+
<th class="invoice-col-remove"></th>
|
|
71
138
|
</tr>
|
|
72
139
|
</thead>
|
|
73
|
-
<tbody id="
|
|
140
|
+
<tbody id="payments-body" data-each="payments.value key=id">
|
|
141
|
+
<tr>
|
|
142
|
+
<td data-bind-text="dateText"></td>
|
|
143
|
+
<td data-bind-text="methodText"></td>
|
|
144
|
+
<td data-bind-text="reference"></td>
|
|
145
|
+
<td class="invoice-line-total" data-bind-text="amountText.value"></td>
|
|
146
|
+
<td>
|
|
147
|
+
<button type="button" class="btn btn-sm btn-ghost li-remove" title="Remove"
|
|
148
|
+
data-on-click="$parent.removePayment($data)">×</button>
|
|
149
|
+
</td>
|
|
150
|
+
</tr>
|
|
151
|
+
</tbody>
|
|
74
152
|
</table>
|
|
153
|
+
<p class="text-muted text-sm mb-0" data-if="noPayments.value">Nothing recorded against this invoice yet.</p>
|
|
75
154
|
</div>
|
|
76
155
|
</div>
|
|
77
156
|
|
|
78
157
|
<div class="card mb-3">
|
|
79
|
-
<div class="card-header"><
|
|
158
|
+
<div class="card-header card-header--quiet"><span class="invoice-cap" data-help="Printed on the invoice under the totals. Payment instructions, a purchase order number, a thank you.">Notes</span></div>
|
|
80
159
|
<div class="card-body">
|
|
81
|
-
<textarea id="f-notes" class="form-
|
|
160
|
+
<textarea id="f-notes" class="form-textarea" rows="3" data-model="notes.value"
|
|
161
|
+
placeholder="Optional notes printed on the invoice"></textarea>
|
|
82
162
|
</div>
|
|
83
163
|
</div>
|
|
84
164
|
</div>
|
|
85
165
|
|
|
86
|
-
<div
|
|
166
|
+
<div class="invoice-editor-side">
|
|
87
167
|
|
|
88
168
|
<div class="card mb-3">
|
|
89
|
-
<div class="card-header"><
|
|
169
|
+
<div class="card-header card-header--quiet"><span class="invoice-cap">Totals</span></div>
|
|
90
170
|
<div class="card-body">
|
|
91
|
-
<div class="
|
|
171
|
+
<div class="flex justify-between mb-2">
|
|
92
172
|
<span class="text-muted">Subtotal</span>
|
|
93
|
-
<span
|
|
173
|
+
<span class="invoice-num" data-bind-text="subtotalText.value"></span>
|
|
94
174
|
</div>
|
|
95
|
-
<div class="
|
|
96
|
-
<span class="text-muted">VAT (<span
|
|
97
|
-
<span
|
|
175
|
+
<div class="flex justify-between mb-2" id="vat-row" data-if="vatEnabled.value">
|
|
176
|
+
<span class="text-muted">VAT (<span data-bind-text="vatRate.value"></span>%)</span>
|
|
177
|
+
<span class="invoice-num" data-bind-text="vatText.value"></span>
|
|
98
178
|
</div>
|
|
99
179
|
<hr>
|
|
100
|
-
<div class="
|
|
180
|
+
<div class="flex justify-between">
|
|
101
181
|
<strong>Total</strong>
|
|
102
|
-
<strong
|
|
182
|
+
<strong class="invoice-num" data-bind-text="totalText.value"></strong>
|
|
183
|
+
</div>
|
|
184
|
+
<div class="flex justify-between mt-2" data-if="hasPayments.value">
|
|
185
|
+
<span class="text-muted">Paid</span>
|
|
186
|
+
<span class="invoice-num" data-bind-text="paidText.value"></span>
|
|
187
|
+
</div>
|
|
188
|
+
<div class="flex justify-between mt-1 invoice-balance" data-if="hasPayments.value" data-bind-class="settled.value && 'is-settled'">
|
|
189
|
+
<strong data-bind-text="balanceLabel.value"></strong>
|
|
190
|
+
<strong class="invoice-num" data-bind-text="balanceText.value"></strong>
|
|
103
191
|
</div>
|
|
104
192
|
</div>
|
|
105
193
|
</div>
|
|
106
194
|
|
|
107
195
|
<div class="card mb-3">
|
|
108
|
-
<div class="card-header"><
|
|
196
|
+
<div class="card-header card-header--quiet"><span class="invoice-cap" data-help="One rate for the whole invoice, applied to the net total. Switch it off for a non-VAT invoice.">VAT</span></div>
|
|
109
197
|
<div class="card-body">
|
|
110
|
-
|
|
111
|
-
|
|
198
|
+
<!-- .form-checkbox is a WRAPPER in Domma; the class that styles the box
|
|
199
|
+
itself is .form-check-input (appearance:none, primary fill, tick). -->
|
|
200
|
+
<label class="form-check mb-2" for="f-vat-enabled">
|
|
201
|
+
<input id="f-vat-enabled" type="checkbox" class="form-check-input" data-model="vatEnabled.value">
|
|
202
|
+
<span class="form-check-label">Apply VAT</span>
|
|
112
203
|
</label>
|
|
113
|
-
<label class="form-label">Rate %</label>
|
|
114
|
-
<input id="f-vat-rate" type="number" min="0" max="100" step="0.01" class="form-
|
|
204
|
+
<label class="form-label" for="f-vat-rate">Rate %</label>
|
|
205
|
+
<input id="f-vat-rate" type="number" min="0" max="100" step="0.01" class="form-input" data-model="vatRate.value">
|
|
115
206
|
</div>
|
|
116
207
|
</div>
|
|
117
208
|
|
|
118
209
|
<div class="card mb-3">
|
|
119
|
-
<div class="card-header"><
|
|
210
|
+
<div class="card-header card-header--quiet"><span class="invoice-cap" data-help="Set per invoice. The default for new ones lives under the cog, in Defaults.">Currency</span></div>
|
|
120
211
|
<div class="card-body">
|
|
121
|
-
<select id="f-currency" class="form-select">
|
|
212
|
+
<select id="f-currency" class="form-select" data-model="currency.value">
|
|
122
213
|
<option value="GBP">GBP - British Pound</option>
|
|
123
214
|
<option value="EUR">EUR - Euro</option>
|
|
124
215
|
<option value="USD">USD - US Dollar</option>
|