domma-cms 0.14.10 → 0.16.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 (37) hide show
  1. package/admin/js/lib/effect-defs.js +1 -0
  2. package/admin/js/lib/effects-builder.js +3 -0
  3. package/admin/js/lib/markdown-toolbar.js +17 -46
  4. package/admin/js/templates/effects.html +83 -0
  5. package/admin/js/views/page-editor.js +12 -12
  6. package/package.json +2 -2
  7. package/plugins/analytics/admin/templates/analytics.html +52 -1
  8. package/plugins/analytics/admin/views/analytics.js +157 -32
  9. package/plugins/analytics/config.js +10 -2
  10. package/plugins/analytics/plugin.js +214 -25
  11. package/plugins/analytics/plugin.json +9 -5
  12. package/plugins/analytics/public/inject-body.html +25 -7
  13. package/plugins/blog/admin/templates/blog.html +25 -2
  14. package/plugins/blog/admin/views/blog.js +72 -56
  15. package/plugins/blog/admin/views/post-editor.js +98 -79
  16. package/plugins/blog/plugin.js +133 -0
  17. package/plugins/blog/plugin.json +3 -3
  18. package/plugins/blog/templates/post.html +2 -1
  19. package/plugins/invoice/admin/templates/editor.html +129 -0
  20. package/plugins/invoice/admin/templates/index.html +43 -0
  21. package/plugins/invoice/admin/templates/issuers.html +5 -0
  22. package/plugins/invoice/admin/templates/receivers.html +5 -0
  23. package/plugins/invoice/admin/views/editor.js +267 -0
  24. package/plugins/invoice/admin/views/index.js +155 -0
  25. package/plugins/invoice/admin/views/issuers.js +23 -0
  26. package/plugins/invoice/admin/views/party-view.js +148 -0
  27. package/plugins/invoice/admin/views/receivers.js +22 -0
  28. package/plugins/invoice/collections/invoice-issuers/schema.json +16 -0
  29. package/plugins/invoice/collections/invoice-receivers/schema.json +15 -0
  30. package/plugins/invoice/collections/invoices/schema.json +27 -0
  31. package/plugins/invoice/config.js +16 -0
  32. package/plugins/invoice/plugin.js +283 -0
  33. package/plugins/invoice/plugin.json +85 -0
  34. package/plugins/invoice/templates/invoice-print.html +213 -0
  35. package/public/js/effects.js +1 -1
  36. package/server/services/markdown.js +114 -25
  37. package/server/services/renderer.js +9 -3
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "blog",
3
3
  "displayName": "Blog",
4
- "version": "1.0.0",
4
+ "version": "1.1.0",
5
5
  "description": "Public-facing blog with posts, categories, and comments.",
6
6
  "author": "Domma CMS",
7
7
  "date": "2026-04-07",
@@ -72,11 +72,11 @@
72
72
  ],
73
73
  "views": {
74
74
  "plugin-blog-posts": {
75
- "entry": "blog/admin/views/blog.js?v=73a4a936",
75
+ "entry": "blog/admin/views/blog.js?v=2722ac2e",
76
76
  "exportName": "blogView"
77
77
  },
78
78
  "plugin-blog-post-editor": {
79
- "entry": "blog/admin/views/post-editor.js?v=3288949c",
79
+ "entry": "blog/admin/views/post-editor.js?v=d3f2c327",
80
80
  "exportName": "postEditorView"
81
81
  },
82
82
  "plugin-blog-categories": {
@@ -1,10 +1,11 @@
1
1
  <main class="container" style="padding: 2rem 0;">
2
- <article>
2
+ <article class="blog-post">
3
3
  <header class="mb-4">
4
4
  <h1>{{title}}</h1>
5
5
  <p class="text-muted">by {{authorName}} &bull; <time>{{publishedAt}}</time></p>
6
6
  <div class="mb-2">{{categories}}{{tags}}</div>
7
7
  </header>
8
+ {{featuredImage}}
8
9
  <div class="post-content mb-5">
9
10
  {{content}}
10
11
  </div>
@@ -0,0 +1,129 @@
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">
8
+ <span data-icon="save"></span> Save
9
+ </button>
10
+ </div>
11
+ </div>
12
+
13
+ <div class="d-flex gap-4" style="align-items:flex-start;">
14
+
15
+ <div style="flex:1; min-width:0;">
16
+
17
+ <div class="card mb-3">
18
+ <div class="card-body">
19
+ <div class="grid grid-cols-2 gap-3">
20
+ <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>
24
+ </div>
25
+ <div>
26
+ <label class="form-label">Status</label>
27
+ <select id="f-status" class="form-select">
28
+ <option value="draft">Draft</option>
29
+ <option value="sent">Sent</option>
30
+ <option value="paid">Paid</option>
31
+ <option value="overdue">Overdue</option>
32
+ <option value="cancelled">Cancelled</option>
33
+ </select>
34
+ </div>
35
+ <div>
36
+ <label class="form-label">Issue Date</label>
37
+ <input id="f-issue-date" type="date" class="form-control">
38
+ </div>
39
+ <div>
40
+ <label class="form-label">Due Date</label>
41
+ <input id="f-due-date" type="date" class="form-control">
42
+ </div>
43
+ <div>
44
+ <label class="form-label">Issuer</label>
45
+ <select id="f-issuer" class="form-select"></select>
46
+ </div>
47
+ <div>
48
+ <label class="form-label">Receiver</label>
49
+ <select id="f-receiver" class="form-select"></select>
50
+ </div>
51
+ </div>
52
+ </div>
53
+ </div>
54
+
55
+ <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">
59
+ <span data-icon="plus"></span> Add Line
60
+ </button>
61
+ </div>
62
+ <div class="card-body">
63
+ <table class="table mb-0" id="line-items-table">
64
+ <thead>
65
+ <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>
71
+ </tr>
72
+ </thead>
73
+ <tbody id="line-items-body"></tbody>
74
+ </table>
75
+ </div>
76
+ </div>
77
+
78
+ <div class="card mb-3">
79
+ <div class="card-header"><strong>Notes</strong></div>
80
+ <div class="card-body">
81
+ <textarea id="f-notes" class="form-control" rows="3" placeholder="Optional notes printed on the invoice"></textarea>
82
+ </div>
83
+ </div>
84
+ </div>
85
+
86
+ <div style="width:300px; flex-shrink:0;">
87
+
88
+ <div class="card mb-3">
89
+ <div class="card-header"><strong>Totals</strong></div>
90
+ <div class="card-body">
91
+ <div class="d-flex justify-content-between mb-2">
92
+ <span class="text-muted">Subtotal</span>
93
+ <span id="t-subtotal" style="font-variant-numeric:tabular-nums;">—</span>
94
+ </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>
98
+ </div>
99
+ <hr>
100
+ <div class="d-flex justify-content-between">
101
+ <strong>Total</strong>
102
+ <strong id="t-total" style="font-variant-numeric:tabular-nums;">—</strong>
103
+ </div>
104
+ </div>
105
+ </div>
106
+
107
+ <div class="card mb-3">
108
+ <div class="card-header"><strong>VAT</strong></div>
109
+ <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
112
+ </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">
115
+ </div>
116
+ </div>
117
+
118
+ <div class="card mb-3">
119
+ <div class="card-header"><strong>Currency</strong></div>
120
+ <div class="card-body">
121
+ <select id="f-currency" class="form-select">
122
+ <option value="GBP">GBP — British Pound</option>
123
+ <option value="EUR">EUR — Euro</option>
124
+ <option value="USD">USD — US Dollar</option>
125
+ </select>
126
+ </div>
127
+ </div>
128
+ </div>
129
+ </div>
@@ -0,0 +1,43 @@
1
+ <div class="view-header d-flex align-items-center justify-content-between mb-4">
2
+ <h1 class="h3 mb-0"><span data-icon="file-text"></span> Invoices</h1>
3
+ <button id="btn-new-invoice" class="btn btn-primary">
4
+ <span data-icon="plus"></span> New Invoice
5
+ </button>
6
+ </div>
7
+
8
+ <div class="grid grid-cols-4 gap-3 mb-4">
9
+ <div class="card"><div class="card-body">
10
+ <div class="text-muted small">Total invoices</div>
11
+ <div class="h3 mb-0" id="kpi-total">—</div>
12
+ </div></div>
13
+ <div class="card"><div class="card-body">
14
+ <div class="text-muted small">Outstanding</div>
15
+ <div class="h3 mb-0" id="kpi-outstanding">—</div>
16
+ </div></div>
17
+ <div class="card"><div class="card-body">
18
+ <div class="text-muted small">Paid</div>
19
+ <div class="h3 mb-0" id="kpi-paid">—</div>
20
+ </div></div>
21
+ <div class="card"><div class="card-body">
22
+ <div class="text-muted small">Drafts</div>
23
+ <div class="h3 mb-0" id="kpi-drafts">—</div>
24
+ </div></div>
25
+ </div>
26
+
27
+ <div class="card mb-3">
28
+ <div class="card-body">
29
+ <div class="d-flex gap-2 flex-wrap">
30
+ <select id="filter-status" class="form-select" style="width:auto;">
31
+ <option value="">All statuses</option>
32
+ <option value="draft">Draft</option>
33
+ <option value="sent">Sent</option>
34
+ <option value="paid">Paid</option>
35
+ <option value="overdue">Overdue</option>
36
+ <option value="cancelled">Cancelled</option>
37
+ </select>
38
+ <input id="filter-search" type="search" class="form-control" style="width:240px;" placeholder="Search number or receiver…">
39
+ </div>
40
+ </div>
41
+ </div>
42
+
43
+ <div id="invoices-table"></div>
@@ -0,0 +1,5 @@
1
+ <div class="view-header d-flex align-items-center justify-content-between mb-4">
2
+ <h1 class="h3 mb-0"><span data-icon="briefcase"></span> Issuers</h1>
3
+ <button id="btn-new" class="btn btn-primary"><span data-icon="plus"></span> New Issuer</button>
4
+ </div>
5
+ <div id="party-table"></div>
@@ -0,0 +1,5 @@
1
+ <div class="view-header d-flex align-items-center justify-content-between mb-4">
2
+ <h1 class="h3 mb-0"><span data-icon="users"></span> Receivers</h1>
3
+ <button id="btn-new" class="btn btn-primary"><span data-icon="plus"></span> New Receiver</button>
4
+ </div>
5
+ <div id="party-table"></div>
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Invoice plugin — editor view. Create or edit a single invoice.
3
+ */
4
+ function escapeHtml(s) {
5
+ return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
6
+ }
7
+
8
+ function fmt(amount, currency) {
9
+ try {
10
+ return new Intl.NumberFormat('en-GB', { style: 'currency', currency: currency || 'GBP' }).format(Number(amount) || 0);
11
+ } catch {
12
+ return `${currency || 'GBP'} ${Number(amount).toFixed(2)}`;
13
+ }
14
+ }
15
+
16
+ function round2(n) {
17
+ return Math.round((Number(n) + Number.EPSILON) * 100) / 100;
18
+ }
19
+
20
+ const BASE = '/api/plugins/invoice';
21
+
22
+ export const editorView = {
23
+ templateUrl: '/plugins/invoice/admin/templates/editor.html',
24
+
25
+ async onMount($container) {
26
+ const id = R.current?.params?.id ?? null;
27
+ const isEdit = Boolean(id);
28
+ let invoice = null;
29
+ let issuers = [];
30
+ let receivers = [];
31
+ let lineItems = [];
32
+
33
+ try {
34
+ const [issRes, recRes] = await Promise.all([
35
+ H.get(`${BASE}/issuers`),
36
+ H.get(`${BASE}/receivers`)
37
+ ]);
38
+ issuers = issRes.data ?? issRes ?? [];
39
+ receivers = recRes.data ?? recRes ?? [];
40
+ } catch {
41
+ E.toast('Could not load issuers / receivers.', { type: 'error' });
42
+ }
43
+
44
+ if (isEdit) {
45
+ try {
46
+ const res = await H.get(`${BASE}/invoices/${id}`);
47
+ invoice = res.data ?? res;
48
+ lineItems = Array.isArray(invoice.lineItems) ? [...invoice.lineItems] : [];
49
+ } catch {
50
+ E.toast('Invoice not found.', { type: 'error' });
51
+ R.navigate('#/plugins/invoice');
52
+ return;
53
+ }
54
+ }
55
+
56
+ const $title = $container.find('#editor-title');
57
+ const $number = $container.find('#f-number');
58
+ const $status = $container.find('#f-status');
59
+ const $issueDate = $container.find('#f-issue-date');
60
+ const $dueDate = $container.find('#f-due-date');
61
+ const $issuer = $container.find('#f-issuer');
62
+ const $receiver = $container.find('#f-receiver');
63
+ const $vatEnabled = $container.find('#f-vat-enabled');
64
+ const $vatRate = $container.find('#f-vat-rate');
65
+ const $currency = $container.find('#f-currency');
66
+ const $notes = $container.find('#f-notes');
67
+ const $print = $container.find('#btn-print');
68
+ const $body = $container.find('#line-items-body');
69
+ const $vatRow = $container.find('#vat-row');
70
+ const $tSubtotal = $container.find('#t-subtotal');
71
+ const $tVat = $container.find('#t-vat');
72
+ const $tVatRate = $container.find('#t-vat-rate');
73
+ const $tTotal = $container.find('#t-total');
74
+
75
+ function populateSelect($select, list, valueKey, labelFn, selected) {
76
+ const el = $select.get(0);
77
+ while (el.firstChild) el.removeChild(el.firstChild);
78
+ const placeholder = document.createElement('option');
79
+ placeholder.value = '';
80
+ placeholder.textContent = '— select —';
81
+ el.appendChild(placeholder);
82
+ for (const item of list) {
83
+ const opt = document.createElement('option');
84
+ opt.value = item[valueKey];
85
+ opt.textContent = labelFn(item);
86
+ if (item[valueKey] === selected) opt.selected = true;
87
+ el.appendChild(opt);
88
+ }
89
+ }
90
+
91
+ populateSelect($issuer, issuers, 'id', (i) => i.name || '(unnamed)', invoice?.issuerId);
92
+ populateSelect($receiver, receivers, 'id', (r) => r.company || r.name || '(unnamed)', invoice?.receiverId);
93
+
94
+ const today = new Date().toISOString().slice(0, 10);
95
+ $title.text(isEdit ? `Edit Invoice ${invoice?.number ?? ''}` : 'New Invoice');
96
+ $number.val(invoice?.number ?? '');
97
+ $status.val(invoice?.status ?? 'draft');
98
+ $issueDate.val(invoice?.issueDate ?? today);
99
+ $dueDate.val(invoice?.dueDate ?? '');
100
+ $vatEnabled.prop('checked', invoice?.vatEnabled !== false);
101
+ $vatRate.val(invoice?.vatRate ?? 20);
102
+ $currency.val(invoice?.currency ?? 'GBP');
103
+ $notes.val(invoice?.notes ?? '');
104
+
105
+ if (isEdit) {
106
+ $print.attr('href', `${BASE}/invoices/${id}/print`).css('display', '');
107
+ }
108
+
109
+ if (!lineItems.length) {
110
+ lineItems.push({ description: '', quantity: 1, unitPrice: 0 });
111
+ }
112
+ renderLineItems();
113
+ recomputeTotals();
114
+
115
+ function makeCell(child) {
116
+ const td = document.createElement('td');
117
+ td.appendChild(child);
118
+ return td;
119
+ }
120
+
121
+ function renderLineItems() {
122
+ const body = $body.get(0);
123
+ while (body.firstChild) body.removeChild(body.firstChild);
124
+
125
+ lineItems.forEach((item, idx) => {
126
+ const tr = document.createElement('tr');
127
+
128
+ const desc = document.createElement('input');
129
+ desc.type = 'text';
130
+ desc.className = 'form-control li-desc';
131
+ desc.dataset.idx = idx;
132
+ desc.value = item.description ?? '';
133
+ desc.placeholder = 'Description';
134
+
135
+ const qty = document.createElement('input');
136
+ qty.type = 'number';
137
+ qty.className = 'form-control li-qty';
138
+ qty.dataset.idx = idx;
139
+ qty.min = '0';
140
+ qty.step = '0.01';
141
+ qty.value = Number(item.quantity) || 0;
142
+
143
+ const price = document.createElement('input');
144
+ price.type = 'number';
145
+ price.className = 'form-control li-price';
146
+ price.dataset.idx = idx;
147
+ price.min = '0';
148
+ price.step = '0.01';
149
+ price.value = Number(item.unitPrice) || 0;
150
+
151
+ const totalCell = document.createElement('td');
152
+ totalCell.style.cssText = 'text-align:right;font-variant-numeric:tabular-nums;';
153
+ totalCell.id = `line-total-${idx}`;
154
+ totalCell.textContent = '—';
155
+
156
+ const removeBtn = document.createElement('button');
157
+ removeBtn.type = 'button';
158
+ removeBtn.className = 'btn btn-sm btn-ghost li-remove';
159
+ removeBtn.dataset.idx = idx;
160
+ removeBtn.title = 'Remove';
161
+ removeBtn.textContent = '×';
162
+
163
+ tr.appendChild(makeCell(desc));
164
+ tr.appendChild(makeCell(qty));
165
+ tr.appendChild(makeCell(price));
166
+ tr.appendChild(totalCell);
167
+ tr.appendChild(makeCell(removeBtn));
168
+ body.appendChild(tr);
169
+ });
170
+
171
+ updateLineTotals();
172
+ }
173
+
174
+ function updateLineTotals() {
175
+ const currency = $currency.val();
176
+ lineItems.forEach((item, idx) => {
177
+ const lineTotal = round2((Number(item.quantity) || 0) * (Number(item.unitPrice) || 0));
178
+ const cell = $container.find(`#line-total-${idx}`).get(0);
179
+ if (cell) cell.textContent = fmt(lineTotal, currency);
180
+ });
181
+ }
182
+
183
+ function recomputeTotals() {
184
+ const currency = $currency.val();
185
+ const vatEnabled = $vatEnabled.prop('checked');
186
+ const vatRate = Number($vatRate.val()) || 0;
187
+
188
+ let subtotal = 0;
189
+ for (const item of lineItems) {
190
+ subtotal += (Number(item.quantity) || 0) * (Number(item.unitPrice) || 0);
191
+ }
192
+ subtotal = round2(subtotal);
193
+ const vat = vatEnabled ? round2(subtotal * (vatRate / 100)) : 0;
194
+ const total = round2(subtotal + vat);
195
+
196
+ $tSubtotal.text(fmt(subtotal, currency));
197
+ $tVat.text(fmt(vat, currency));
198
+ $tVatRate.text(vatRate);
199
+ $tTotal.text(fmt(total, currency));
200
+ $vatRow.css('display', vatEnabled ? '' : 'none');
201
+ updateLineTotals();
202
+ }
203
+
204
+ $body.on('input', '.li-desc', function () {
205
+ lineItems[Number(this.dataset.idx)].description = this.value;
206
+ });
207
+ $body.on('input', '.li-qty', function () {
208
+ lineItems[Number(this.dataset.idx)].quantity = Number(this.value) || 0;
209
+ recomputeTotals();
210
+ });
211
+ $body.on('input', '.li-price', function () {
212
+ lineItems[Number(this.dataset.idx)].unitPrice = Number(this.value) || 0;
213
+ recomputeTotals();
214
+ });
215
+ $body.on('click', '.li-remove', function () {
216
+ const idx = Number(this.dataset.idx);
217
+ lineItems.splice(idx, 1);
218
+ if (!lineItems.length) lineItems.push({ description: '', quantity: 1, unitPrice: 0 });
219
+ renderLineItems();
220
+ recomputeTotals();
221
+ });
222
+
223
+ $container.find('#btn-add-line').on('click', () => {
224
+ lineItems.push({ description: '', quantity: 1, unitPrice: 0 });
225
+ renderLineItems();
226
+ recomputeTotals();
227
+ });
228
+
229
+ $vatEnabled.on('change', recomputeTotals);
230
+ $vatRate.on('input', recomputeTotals);
231
+ $currency.on('change', recomputeTotals);
232
+
233
+ $container.find('#btn-save').on('click', async () => {
234
+ const issuerId = $issuer.val();
235
+ const receiverId = $receiver.val();
236
+ if (!issuerId) { E.toast('Pick an issuer.', { type: 'error' }); return; }
237
+ if (!receiverId) { E.toast('Pick a receiver.', { type: 'error' }); return; }
238
+
239
+ const body = {
240
+ number: $number.val()?.trim() || undefined,
241
+ status: $status.val(),
242
+ issueDate: $issueDate.val(),
243
+ dueDate: $dueDate.val() || '',
244
+ issuerId,
245
+ receiverId,
246
+ vatEnabled: $vatEnabled.prop('checked'),
247
+ vatRate: Number($vatRate.val()) || 0,
248
+ currency: $currency.val(),
249
+ notes: $notes.val() ?? '',
250
+ lineItems
251
+ };
252
+
253
+ try {
254
+ const res = isEdit
255
+ ? await H.put(`${BASE}/invoices/${id}`, body)
256
+ : await H.post(`${BASE}/invoices`, body);
257
+ if (res.error) throw new Error(res.error);
258
+ E.toast(isEdit ? 'Invoice saved.' : 'Invoice created.', { type: 'success' });
259
+ R.navigate('#/plugins/invoice');
260
+ } catch (err) {
261
+ E.toast(err?.message ?? 'Save failed.', { type: 'error' });
262
+ }
263
+ });
264
+
265
+ Domma.icons.scan();
266
+ }
267
+ };
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Invoice plugin — list view.
3
+ */
4
+ function escapeHtml(s) {
5
+ return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
6
+ }
7
+
8
+ const BASE = '/api/plugins/invoice';
9
+
10
+ const STATUS_BADGE = {
11
+ draft: 'badge-secondary',
12
+ sent: 'badge-info',
13
+ paid: 'badge-success',
14
+ overdue: 'badge-danger',
15
+ cancelled: 'badge-secondary'
16
+ };
17
+
18
+ export const indexView = {
19
+ templateUrl: '/plugins/invoice/admin/templates/index.html',
20
+
21
+ async onMount($container) {
22
+ let invoices = [];
23
+ let receivers = [];
24
+ let table = null;
25
+
26
+ function receiverName(id) {
27
+ const r = receivers.find((x) => x.id === id);
28
+ if (!r) return '—';
29
+ return r.company || r.name || id;
30
+ }
31
+
32
+ function fmt(amount, currency) {
33
+ try {
34
+ return new Intl.NumberFormat('en-GB', { style: 'currency', currency: currency || 'GBP' }).format(Number(amount) || 0);
35
+ } catch {
36
+ return `${currency || 'GBP'} ${Number(amount).toFixed(2)}`;
37
+ }
38
+ }
39
+
40
+ function buildTable(data) {
41
+ const el = $container.find('#invoices-table').get(0);
42
+ if (!el) return;
43
+ if (table) {
44
+ table.setData(data);
45
+ Domma.icons.scan(el);
46
+ return;
47
+ }
48
+ table = T.create(el, {
49
+ data,
50
+ columns: [
51
+ {
52
+ key: 'number', title: 'Number', sortable: true,
53
+ render: (v, row) => `<a href="#/plugins/invoice/edit/${escapeHtml(row.id)}" style="font-weight:600;">${escapeHtml(v || '—')}</a>`
54
+ },
55
+ { key: 'receiverId', title: 'Billed To', sortable: false,
56
+ render: (v) => escapeHtml(receiverName(v))
57
+ },
58
+ { key: 'issueDate', title: 'Issue Date', sortable: true,
59
+ render: (v) => v ? escapeHtml(D(v).format('DD MMM YYYY')) : '—'
60
+ },
61
+ { key: 'totals', title: 'Total', sortable: false,
62
+ render: (totals, row) => `<span style="font-variant-numeric:tabular-nums;">${escapeHtml(fmt(totals?.total ?? 0, row.currency))}</span>`
63
+ },
64
+ { key: 'status', title: 'Status', sortable: true,
65
+ render: (v) => `<span class="badge ${STATUS_BADGE[v] || 'badge-secondary'}">${escapeHtml(v || 'draft')}</span>`
66
+ },
67
+ { key: 'id', title: 'Actions', sortable: false,
68
+ render: (id) => `<div style="display:flex;gap:.4rem;">
69
+ <button class="btn btn-sm btn-outline edit-btn" data-id="${escapeHtml(id)}"><span data-icon="edit" data-icon-size="14"></span></button>
70
+ <a class="btn btn-sm btn-outline" href="${BASE}/invoices/${escapeHtml(id)}/print" target="_blank" title="Print / PDF"><span data-icon="printer" data-icon-size="14"></span></a>
71
+ <button class="btn btn-sm btn-danger delete-btn" data-id="${escapeHtml(id)}"><span data-icon="trash" data-icon-size="14"></span></button>
72
+ </div>`
73
+ }
74
+ ],
75
+ emptyMessage: 'No invoices yet. Click “New Invoice” to create your first.'
76
+ });
77
+
78
+ el.addEventListener('click', async (e) => {
79
+ const editBtn = e.target.closest('.edit-btn');
80
+ const delBtn = e.target.closest('.delete-btn');
81
+ if (editBtn) R.navigate('#/plugins/invoice/edit/' + editBtn.dataset.id);
82
+ if (delBtn) {
83
+ const id = delBtn.dataset.id;
84
+ const inv = invoices.find((x) => x.id === id);
85
+ const ok = await E.confirm(`Delete invoice ${inv?.number ?? ''}?`);
86
+ if (!ok) return;
87
+ try {
88
+ await H.delete(`${BASE}/invoices/${id}`);
89
+ E.toast('Invoice deleted.', { type: 'success' });
90
+ await reload();
91
+ } catch {
92
+ E.toast('Delete failed.', { type: 'error' });
93
+ }
94
+ }
95
+ });
96
+
97
+ Domma.icons.scan(el);
98
+ }
99
+
100
+ function refreshKpis() {
101
+ const counts = { total: invoices.length, outstanding: 0, paid: 0, drafts: 0 };
102
+ for (const i of invoices) {
103
+ if (i.status === 'paid') counts.paid++;
104
+ else if (i.status === 'draft') counts.drafts++;
105
+ else if (i.status === 'sent' || i.status === 'overdue') counts.outstanding++;
106
+ }
107
+ $container.find('#kpi-total').text(counts.total);
108
+ $container.find('#kpi-outstanding').text(counts.outstanding);
109
+ $container.find('#kpi-paid').text(counts.paid);
110
+ $container.find('#kpi-drafts').text(counts.drafts);
111
+ }
112
+
113
+ function applyFilters() {
114
+ const status = $container.find('#filter-status').val() || '';
115
+ const q = ($container.find('#filter-search').val() || '').toString().toLowerCase().trim();
116
+ let filtered = invoices;
117
+ if (status) filtered = filtered.filter((i) => i.status === status);
118
+ if (q) {
119
+ filtered = filtered.filter((i) =>
120
+ (i.number ?? '').toLowerCase().includes(q) ||
121
+ receiverName(i.receiverId).toLowerCase().includes(q)
122
+ );
123
+ }
124
+ buildTable(filtered);
125
+ }
126
+
127
+ async function reload() {
128
+ try {
129
+ const [invRes, recRes] = await Promise.all([
130
+ H.get(`${BASE}/invoices`),
131
+ H.get(`${BASE}/receivers`).catch(() => ({ data: [] }))
132
+ ]);
133
+ invoices = invRes.data ?? invRes ?? [];
134
+ receivers = recRes.data ?? recRes ?? [];
135
+ } catch {
136
+ invoices = [];
137
+ receivers = [];
138
+ E.toast('Could not load invoices.', { type: 'error' });
139
+ }
140
+ refreshKpis();
141
+ applyFilters();
142
+ }
143
+
144
+ $container.find('#btn-new-invoice').on('click', () => R.navigate('#/plugins/invoice/new'));
145
+ $container.find('#filter-status').on('change', applyFilters);
146
+ let timer = null;
147
+ $container.find('#filter-search').on('input', () => {
148
+ clearTimeout(timer);
149
+ timer = setTimeout(applyFilters, 200);
150
+ });
151
+
152
+ await reload();
153
+ Domma.icons.scan();
154
+ }
155
+ };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Invoice plugin — issuers CRUD view.
3
+ */
4
+ import {mountPartyView} from './party-view.js?v=1';
5
+
6
+ export const issuersView = {
7
+ templateUrl: '/plugins/invoice/admin/templates/issuers.html',
8
+ onMount: ($container) => mountPartyView($container, {
9
+ kind: 'issuer',
10
+ endpoint: '/api/plugins/invoice/issuers',
11
+ label: 'Issuer',
12
+ fields: [
13
+ { key: 'name', label: 'Trading Name', required: true, type: 'text' },
14
+ { key: 'email', label: 'Email', type: 'text' },
15
+ { key: 'phone', label: 'Phone', type: 'text' },
16
+ { key: 'website', label: 'Website', type: 'text' },
17
+ { key: 'vatNumber', label: 'VAT Number', type: 'text' },
18
+ { key: 'logoUrl', label: 'Logo URL', type: 'text' },
19
+ { key: 'address', label: 'Address', type: 'textarea' },
20
+ { key: 'bankDetails', label: 'Bank Details', type: 'textarea' }
21
+ ]
22
+ })
23
+ };