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.
Files changed (211) hide show
  1. package/CLAUDE.md +130 -2
  2. package/README.md +4 -4
  3. package/admin/css/admin.css +1 -1
  4. package/admin/dist/domma/domma-tools.css +3 -3
  5. package/admin/dist/domma/domma-tools.min.js +3 -3
  6. package/admin/js/api.js +1 -1
  7. package/admin/js/app.js +3 -3
  8. package/admin/js/lib/page-picker.js +1 -0
  9. package/admin/js/lib/plugin-accent.js +1 -0
  10. package/admin/js/lib/plugin-chrome.js +1 -0
  11. package/admin/js/lib/shortcode-context-menu.js +2 -2
  12. package/admin/js/lib/sidebar-grouping.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  14. package/admin/js/lib/sidebar-renderer.js +4 -4
  15. package/admin/js/lib/slideover-resizable.js +1 -0
  16. package/admin/js/templates/context-menu-editor.html +212 -0
  17. package/admin/js/templates/context-menus.html +16 -0
  18. package/admin/js/templates/plugin-code.html +1 -1
  19. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  20. package/admin/js/templates/settings.html +16 -97
  21. package/admin/js/templates/theme.html +173 -0
  22. package/admin/js/views/context-menu-editor.js +55 -0
  23. package/admin/js/views/context-menus.js +5 -0
  24. package/admin/js/views/form-editor.js +7 -7
  25. package/admin/js/views/index.js +1 -1
  26. package/admin/js/views/plugin-marketplace.js +1 -1
  27. package/admin/js/views/plugins.js +25 -23
  28. package/admin/js/views/search.js +1 -0
  29. package/admin/js/views/settings.js +3 -3
  30. package/admin/js/views/theme.js +1 -0
  31. package/bin/cli.js +2 -0
  32. package/bin/update.js +13 -2
  33. package/config/menus/admin-sidebar.json +129 -23
  34. package/config/plugins.json +5 -5
  35. package/config/search.json +13 -0
  36. package/config/theme.json +18 -0
  37. package/package.json +12 -4
  38. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  39. package/plugins/_lib/admin/mail/contacts.js +301 -0
  40. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  41. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  42. package/plugins/_lib/admin/mail/identity.js +480 -0
  43. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  44. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  45. package/plugins/_lib/admin/mail/mail.css +1 -0
  46. package/plugins/_lib/admin/mail/mail.html +71 -0
  47. package/plugins/_lib/admin/mail/panes.js +253 -0
  48. package/plugins/_lib/admin/mail/reader-view.js +4453 -0
  49. package/plugins/_lib/admin/mail/resizable.js +26 -0
  50. package/plugins/_lib/admin/mail/rules.js +343 -0
  51. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  52. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  53. package/plugins/_lib/admin/mail/templates.js +238 -0
  54. package/plugins/_lib/admin/mail/threads.js +200 -0
  55. package/plugins/_lib/admin/mail/vacation.js +269 -0
  56. package/plugins/_lib/admin/ui/help.css +1 -0
  57. package/plugins/_lib/admin/ui/help.js +174 -0
  58. package/plugins/_lib/admin/ui/resizable.js +151 -0
  59. package/plugins/_lib/dataStore.js +117 -0
  60. package/plugins/_lib/mail/accounts.js +919 -0
  61. package/plugins/_lib/mail/bodyTokens.js +101 -0
  62. package/plugins/_lib/mail/compose.js +256 -0
  63. package/plugins/_lib/mail/defaults.js +57 -0
  64. package/plugins/_lib/mail/diagnostics.js +154 -0
  65. package/plugins/_lib/mail/envelope.js +161 -0
  66. package/plugins/_lib/mail/folders.js +192 -0
  67. package/plugins/_lib/mail/handoff.js +274 -0
  68. package/plugins/_lib/mail/imapPool.js +293 -0
  69. package/plugins/_lib/mail/mbox.js +74 -0
  70. package/plugins/_lib/mail/pollSchedule.js +79 -0
  71. package/plugins/_lib/mail/poller.js +135 -0
  72. package/plugins/_lib/mail/priority.js +138 -0
  73. package/plugins/_lib/mail/readRoutes.js +680 -0
  74. package/plugins/_lib/mail/render.js +291 -0
  75. package/plugins/_lib/mail/ruleRunner.js +152 -0
  76. package/plugins/_lib/mail/scheduler.js +254 -0
  77. package/plugins/_lib/mail/secretbox.js +229 -0
  78. package/plugins/_lib/mail/send.js +396 -0
  79. package/plugins/_lib/mail/store.js +1002 -0
  80. package/plugins/_lib/mail/sync.js +292 -0
  81. package/plugins/_lib/mail/syncPlan.js +126 -0
  82. package/plugins/_lib/mail/syncSelection.js +82 -0
  83. package/plugins/_lib/mail/unsubscribe.js +183 -0
  84. package/plugins/_lib/mail/vacationRunner.js +114 -0
  85. package/plugins/_lib/mail/write.js +473 -0
  86. package/plugins/_lib/schemaSync.js +83 -0
  87. package/plugins/_template/admin/css/index.css +0 -0
  88. package/plugins/_template/admin/templates/index.html +4 -4
  89. package/plugins/_template/admin/views/index.js +7 -0
  90. package/plugins/analytics/admin/css/index.css +1 -0
  91. package/plugins/analytics/admin/templates/analytics.html +22 -13
  92. package/plugins/analytics/plugin.json +3 -0
  93. package/plugins/blog/admin/css/index.css +1 -0
  94. package/plugins/blog/admin/templates/blog.html +30 -18
  95. package/plugins/blog/admin/templates/categories.html +2 -2
  96. package/plugins/blog/admin/templates/comments.html +2 -2
  97. package/plugins/blog/admin/templates/post-editor.html +34 -34
  98. package/plugins/blog/admin/templates/settings.html +6 -3
  99. package/plugins/blog/admin/views/blog.js +8 -5
  100. package/plugins/blog/admin/views/categories.js +5 -10
  101. package/plugins/blog/admin/views/comments.js +5 -5
  102. package/plugins/blog/admin/views/post-editor.js +39 -20
  103. package/plugins/blog/admin/views/settings.js +52 -50
  104. package/plugins/blog/collections/categories/schema.json +7 -6
  105. package/plugins/blog/collections/comments/schema.json +11 -10
  106. package/plugins/blog/collections/posts/schema.json +14 -13
  107. package/plugins/blog/plugin.js +36 -13
  108. package/plugins/blog/plugin.json +13 -5
  109. package/plugins/blog/plugin.public.js +312 -0
  110. package/plugins/contacts/admin/css/index.css +1 -0
  111. package/plugins/contacts/admin/templates/contacts.html +128 -0
  112. package/plugins/contacts/admin/views/contacts.js +237 -4
  113. package/plugins/contacts/collections/user-contacts/schema.json +108 -0
  114. package/plugins/contacts/plugin.js +214 -27
  115. package/plugins/contacts/plugin.json +4 -1
  116. package/plugins/invoice/admin/css/index.css +1 -0
  117. package/plugins/invoice/admin/templates/editor.html +140 -49
  118. package/plugins/invoice/admin/templates/index.html +153 -23
  119. package/plugins/invoice/admin/templates/issuers.html +2 -5
  120. package/plugins/invoice/admin/templates/receivers.html +2 -5
  121. package/plugins/invoice/admin/views/contacts-source.js +266 -0
  122. package/plugins/invoice/admin/views/editor.js +366 -199
  123. package/plugins/invoice/admin/views/export.js +199 -0
  124. package/plugins/invoice/admin/views/help-content.js +61 -0
  125. package/plugins/invoice/admin/views/index.js +582 -94
  126. package/plugins/invoice/admin/views/issuers.js +24 -17
  127. package/plugins/invoice/admin/views/media.js +172 -0
  128. package/plugins/invoice/admin/views/party-view.js +305 -67
  129. package/plugins/invoice/admin/views/payments.js +127 -0
  130. package/plugins/invoice/admin/views/print.js +130 -0
  131. package/plugins/invoice/admin/views/receivers.js +49 -16
  132. package/plugins/invoice/admin/views/send.js +212 -0
  133. package/plugins/invoice/admin/views/settings.js +594 -0
  134. package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
  135. package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
  136. package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
  137. package/plugins/invoice/collections/invoices/schema.json +19 -13
  138. package/plugins/invoice/config.js +27 -6
  139. package/plugins/invoice/pdf.js +164 -0
  140. package/plugins/invoice/plugin.js +1217 -44
  141. package/plugins/invoice/plugin.json +10 -9
  142. package/plugins/invoice/templates/_base.css +1 -0
  143. package/plugins/invoice/templates/classic-nologo.html +100 -0
  144. package/plugins/invoice/templates/classic.html +91 -0
  145. package/plugins/invoice/templates/invoice-print.html +24 -0
  146. package/plugins/invoice/templates/minimal.html +99 -0
  147. package/plugins/invoice/templates/modern-nologo.html +114 -0
  148. package/plugins/invoice/templates/modern.html +113 -0
  149. package/plugins/invoice/templates/templates.json +11 -0
  150. package/plugins/mail-reader/admin/views/mail.js +19 -0
  151. package/plugins/mail-reader/config.js +7 -0
  152. package/plugins/mail-reader/plugin.js +48 -0
  153. package/plugins/mail-reader/plugin.json +33 -0
  154. package/plugins/notes/admin/views/notes.js +1 -1
  155. package/plugins/notes/plugin.json +2 -2
  156. package/plugins/surveys/lib/audience.js +37 -0
  157. package/plugins/surveys/lib/campaigns.js +43 -0
  158. package/plugins/surveys/lib/ledger.js +110 -0
  159. package/plugins/surveys/lib/sending.js +106 -0
  160. package/plugins/surveys/lib/stats.js +62 -0
  161. package/plugins/surveys/lib/submit.js +95 -0
  162. package/plugins/surveys/lib/tokens.js +28 -0
  163. package/plugins/surveys/plugin.public.js +149 -0
  164. package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
  165. package/public/css/forms.css +1 -1
  166. package/public/css/search.css +1 -0
  167. package/public/css/site.css +1 -1
  168. package/public/js/collection-context.js +2 -2
  169. package/public/js/context-menus.js +1 -0
  170. package/public/js/form-logic-engine.js +1 -1
  171. package/public/js/forms.js +2 -2
  172. package/public/js/search.js +1 -0
  173. package/public/js/site.js +1 -1
  174. package/scripts/build.js +37 -3
  175. package/scripts/copy-domma.js +48 -0
  176. package/scripts/seed.js +1996 -0
  177. package/server/routes/api/collections.js +34 -0
  178. package/server/routes/api/context-menus.js +104 -0
  179. package/server/routes/api/forms.js +42 -3
  180. package/server/routes/api/notifications.js +69 -19
  181. package/server/routes/api/plugins.js +50 -6
  182. package/server/routes/api/search.js +43 -0
  183. package/server/routes/api/theme.js +69 -0
  184. package/server/routes/public.js +42 -7
  185. package/server/server.js +74 -0
  186. package/server/services/adapters/FileAdapter.js +6 -1
  187. package/server/services/content.js +26 -0
  188. package/server/services/contextMenus.js +477 -0
  189. package/server/services/markdown.js +70 -9
  190. package/server/services/permissionRegistry.js +24 -0
  191. package/server/services/pluginFiles.js +52 -11
  192. package/server/services/plugins.js +229 -6
  193. package/server/services/renderer.js +144 -22
  194. package/server/services/roles.js +1 -1
  195. package/server/services/search-migration.js +82 -0
  196. package/server/services/search.js +413 -0
  197. package/server/services/sidebar-migration.js +1 -0
  198. package/server/services/themeSettings.js +541 -0
  199. package/server/services/users.js +8 -0
  200. package/server/templates/page.html +4 -2
  201. package/plugins/contacts/data/contacts.json +0 -20
  202. package/plugins/notes/data/notes.json +0 -1
  203. package/plugins/site-search/admin/views/site-search.js +0 -116
  204. package/plugins/site-search/config.js +0 -15
  205. package/plugins/site-search/plugin.js +0 -188
  206. package/plugins/site-search/plugin.json +0 -40
  207. package/plugins/site-search/public/inject-body.html +0 -17
  208. package/plugins/site-search/public/inject-head.html +0 -1
  209. package/plugins/site-search/public/search.css +0 -1
  210. package/plugins/site-search/public/search.js +0 -1
  211. 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
- return {
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 { name, email, phone, groups, notes, favourite } = request.body ?? {};
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
- name: name.trim(),
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 ['name', 'email', 'phone', 'groups', 'notes', 'favourite']) {
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=b7ed0c5c",
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
- <div class="view-header d-flex align-items-center justify-content-between mb-4">
2
- <h1 class="h3 mb-0" id="editor-title">New Invoice</h1>
3
- <div class="d-flex gap-2">
4
- <a id="btn-print" class="btn btn-secondary" href="#" target="_blank" style="display:none;">
5
- <span data-icon="printer"></span> Print / PDF
6
- </a>
7
- <button id="btn-save" class="btn btn-primary">
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="d-flex gap-4" style="align-items:flex-start;">
26
+ <div class="flex gap-4 invoice-editor">
14
27
 
15
- <div style="flex:1; min-width:0;">
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-control font-monospace" placeholder="auto-generated">
23
- <div class="text-muted small">Leave blank to auto-number</div>
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
- <input id="f-issue-date" type="date" class="form-control">
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="date" class="form-control">
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"></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
- <select id="f-receiver" class="form-select"></select>
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 d-flex justify-content-between align-items-center">
57
- <strong>Line Items</strong>
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 style="width:55%;">Description</th>
67
- <th style="width:10%;">Qty</th>
68
- <th style="width:15%;">Unit (net)</th>
69
- <th style="width:15%; text-align:right;">Total</th>
70
- <th style="width:5%;"></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)">&times;</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="line-items-body"></tbody>
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)">&times;</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"><strong>Notes</strong></div>
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-control" rows="3" placeholder="Optional notes printed on the invoice"></textarea>
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 style="width:300px; flex-shrink:0;">
166
+ <div class="invoice-editor-side">
87
167
 
88
168
  <div class="card mb-3">
89
- <div class="card-header"><strong>Totals</strong></div>
169
+ <div class="card-header card-header--quiet"><span class="invoice-cap">Totals</span></div>
90
170
  <div class="card-body">
91
- <div class="d-flex justify-content-between mb-2">
171
+ <div class="flex justify-between mb-2">
92
172
  <span class="text-muted">Subtotal</span>
93
- <span id="t-subtotal" style="font-variant-numeric:tabular-nums;">-</span>
173
+ <span class="invoice-num" data-bind-text="subtotalText.value"></span>
94
174
  </div>
95
- <div class="d-flex justify-content-between mb-2" id="vat-row">
96
- <span class="text-muted">VAT (<span id="t-vat-rate">20</span>%)</span>
97
- <span id="t-vat" style="font-variant-numeric:tabular-nums;">-</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="d-flex justify-content-between">
180
+ <div class="flex justify-between">
101
181
  <strong>Total</strong>
102
- <strong id="t-total" style="font-variant-numeric:tabular-nums;">-</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"><strong>VAT</strong></div>
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
- <label class="d-flex align-items-center gap-2 mb-2" style="cursor:pointer;">
111
- <input id="f-vat-enabled" type="checkbox" checked> Apply VAT
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-control" value="20">
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"><strong>Currency</strong></div>
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>