domma-cms 0.15.0 → 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 (31) hide show
  1. package/package.json +2 -2
  2. package/plugins/analytics/admin/templates/analytics.html +52 -1
  3. package/plugins/analytics/admin/views/analytics.js +157 -32
  4. package/plugins/analytics/config.js +10 -2
  5. package/plugins/analytics/plugin.js +214 -25
  6. package/plugins/analytics/plugin.json +9 -5
  7. package/plugins/analytics/public/inject-body.html +25 -7
  8. package/plugins/blog/admin/templates/blog.html +25 -2
  9. package/plugins/blog/admin/views/blog.js +72 -56
  10. package/plugins/blog/admin/views/post-editor.js +98 -79
  11. package/plugins/blog/plugin.js +133 -0
  12. package/plugins/blog/plugin.json +3 -3
  13. package/plugins/blog/templates/post.html +2 -1
  14. package/plugins/invoice/admin/templates/editor.html +129 -0
  15. package/plugins/invoice/admin/templates/index.html +43 -0
  16. package/plugins/invoice/admin/templates/issuers.html +5 -0
  17. package/plugins/invoice/admin/templates/receivers.html +5 -0
  18. package/plugins/invoice/admin/views/editor.js +267 -0
  19. package/plugins/invoice/admin/views/index.js +155 -0
  20. package/plugins/invoice/admin/views/issuers.js +23 -0
  21. package/plugins/invoice/admin/views/party-view.js +148 -0
  22. package/plugins/invoice/admin/views/receivers.js +22 -0
  23. package/plugins/invoice/collections/invoice-issuers/schema.json +16 -0
  24. package/plugins/invoice/collections/invoice-receivers/schema.json +15 -0
  25. package/plugins/invoice/collections/invoices/schema.json +27 -0
  26. package/plugins/invoice/config.js +16 -0
  27. package/plugins/invoice/plugin.js +283 -0
  28. package/plugins/invoice/plugin.json +85 -0
  29. package/plugins/invoice/templates/invoice-print.html +213 -0
  30. package/server/services/markdown.js +24 -4
  31. package/server/services/renderer.js +9 -3
@@ -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
+ };
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Shared CRUD view for issuers and receivers.
3
+ * Renders a sortable table and an edit slideover driven by a fields[] config.
4
+ */
5
+ function escapeHtml(s) {
6
+ return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
7
+ }
8
+
9
+ export async function mountPartyView($container, config) {
10
+ let parties = [];
11
+ let table = null;
12
+
13
+ function buildTable(data) {
14
+ const el = $container.find('#party-table').get(0);
15
+ if (!el) return;
16
+ if (table) {
17
+ table.setData(data);
18
+ Domma.icons.scan(el);
19
+ return;
20
+ }
21
+
22
+ const cols = [
23
+ { key: 'name', title: 'Name', sortable: true,
24
+ render: (v, row) => `<a href="#" data-id="${escapeHtml(row.id)}" class="party-edit" style="font-weight:600;">${escapeHtml(v || '(unnamed)')}</a>`
25
+ }
26
+ ];
27
+ if (config.fields.some((f) => f.key === 'company')) {
28
+ cols.push({ key: 'company', title: 'Company', sortable: true, render: (v) => escapeHtml(v || '—') });
29
+ }
30
+ cols.push(
31
+ { key: 'email', title: 'Email', sortable: true, render: (v) => v ? `<a href="mailto:${escapeHtml(v)}">${escapeHtml(v)}</a>` : '—' },
32
+ { key: 'vatNumber', title: 'VAT', sortable: false, render: (v) => v ? `<code>${escapeHtml(v)}</code>` : '—' },
33
+ { key: 'id', title: 'Actions', sortable: false,
34
+ render: (id) => `<div style="display:flex;gap:.4rem;">
35
+ <button class="btn btn-sm btn-outline party-edit" data-id="${escapeHtml(id)}"><span data-icon="edit" data-icon-size="14"></span></button>
36
+ <button class="btn btn-sm btn-danger party-delete" data-id="${escapeHtml(id)}"><span data-icon="trash" data-icon-size="14"></span></button>
37
+ </div>` }
38
+ );
39
+
40
+ table = T.create(el, { data, columns: cols, emptyMessage: `No ${config.label.toLowerCase()}s yet.` });
41
+
42
+ el.addEventListener('click', async (e) => {
43
+ const editEl = e.target.closest('.party-edit');
44
+ const delEl = e.target.closest('.party-delete');
45
+ if (editEl) {
46
+ e.preventDefault();
47
+ const party = parties.find((p) => p.id === editEl.dataset.id);
48
+ openEditor(party);
49
+ }
50
+ if (delEl) {
51
+ const party = parties.find((p) => p.id === delEl.dataset.id);
52
+ const ok = await E.confirm(`Delete ${config.label.toLowerCase()} "${party?.name ?? ''}"?`);
53
+ if (!ok) return;
54
+ try {
55
+ await H.delete(`${config.endpoint}/${party.id}`);
56
+ E.toast('Deleted.', { type: 'success' });
57
+ await reload();
58
+ } catch {
59
+ E.toast('Delete failed.', { type: 'error' });
60
+ }
61
+ }
62
+ });
63
+
64
+ Domma.icons.scan(el);
65
+ }
66
+
67
+ async function reload() {
68
+ try {
69
+ const res = await H.get(config.endpoint);
70
+ parties = res.data ?? res ?? [];
71
+ } catch {
72
+ parties = [];
73
+ E.toast(`Could not load ${config.label.toLowerCase()}s.`, { type: 'error' });
74
+ }
75
+ buildTable(parties);
76
+ }
77
+
78
+ function openEditor(existing) {
79
+ const isEdit = Boolean(existing);
80
+ const form = document.createElement('div');
81
+ form.style.cssText = 'display:flex;flex-direction:column;gap:.75rem;padding:.5rem;';
82
+
83
+ const inputs = {};
84
+ for (const field of config.fields) {
85
+ const wrap = document.createElement('div');
86
+ const label = document.createElement('label');
87
+ label.className = 'form-label';
88
+ label.textContent = field.label + (field.required ? ' *' : '');
89
+ wrap.appendChild(label);
90
+
91
+ let input;
92
+ if (field.type === 'textarea') {
93
+ input = document.createElement('textarea');
94
+ input.rows = 3;
95
+ } else {
96
+ input = document.createElement('input');
97
+ input.type = 'text';
98
+ }
99
+ input.className = 'form-control';
100
+ input.value = existing?.[field.key] ?? '';
101
+ wrap.appendChild(input);
102
+ form.appendChild(wrap);
103
+ inputs[field.key] = input;
104
+ }
105
+
106
+ const saveBtn = document.createElement('button');
107
+ saveBtn.type = 'button';
108
+ saveBtn.className = 'btn btn-primary mt-2';
109
+ saveBtn.textContent = isEdit ? 'Save changes' : 'Create';
110
+ form.appendChild(saveBtn);
111
+
112
+ const slideover = E.slideover({
113
+ title: isEdit ? `Edit ${config.label}` : `New ${config.label}`,
114
+ size: 'md',
115
+ position: 'right',
116
+ content: form
117
+ });
118
+
119
+ saveBtn.addEventListener('click', async () => {
120
+ const body = {};
121
+ for (const field of config.fields) {
122
+ body[field.key] = inputs[field.key].value;
123
+ }
124
+ if (!body.name?.trim()) {
125
+ E.toast('Name is required.', { type: 'error' });
126
+ return;
127
+ }
128
+ try {
129
+ const res = isEdit
130
+ ? await H.put(`${config.endpoint}/${existing.id}`, body)
131
+ : await H.post(config.endpoint, body);
132
+ if (res.error) throw new Error(res.error);
133
+ slideover.close();
134
+ E.toast(isEdit ? 'Saved.' : 'Created.', { type: 'success' });
135
+ await reload();
136
+ } catch (err) {
137
+ E.toast(err?.message ?? 'Save failed.', { type: 'error' });
138
+ }
139
+ });
140
+
141
+ slideover.open();
142
+ }
143
+
144
+ $container.find('#btn-new').on('click', () => openEditor(null));
145
+
146
+ await reload();
147
+ Domma.icons.scan();
148
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Invoice plugin — receivers CRUD view.
3
+ */
4
+ import {mountPartyView} from './party-view.js?v=1';
5
+
6
+ export const receiversView = {
7
+ templateUrl: '/plugins/invoice/admin/templates/receivers.html',
8
+ onMount: ($container) => mountPartyView($container, {
9
+ kind: 'receiver',
10
+ endpoint: '/api/plugins/invoice/receivers',
11
+ label: 'Receiver',
12
+ fields: [
13
+ { key: 'name', label: 'Contact Name', required: true, type: 'text' },
14
+ { key: 'company', label: 'Company', type: 'text' },
15
+ { key: 'email', label: 'Email', type: 'text' },
16
+ { key: 'phone', label: 'Phone', type: 'text' },
17
+ { key: 'vatNumber', label: 'VAT Number', type: 'text' },
18
+ { key: 'reference', label: 'Reference', type: 'text' },
19
+ { key: 'address', label: 'Address', type: 'textarea' }
20
+ ]
21
+ })
22
+ };
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "invoice-issuers",
3
+ "label": "Invoice Issuers",
4
+ "slug": "invoice-issuers",
5
+ "storage": { "adapter": "file" },
6
+ "fields": [
7
+ { "key": "name", "label": "Trading Name", "type": "text", "required": true },
8
+ { "key": "email", "label": "Email", "type": "text" },
9
+ { "key": "phone", "label": "Phone", "type": "text" },
10
+ { "key": "website", "label": "Website", "type": "text" },
11
+ { "key": "address", "label": "Address", "type": "textarea" },
12
+ { "key": "vatNumber", "label": "VAT Number", "type": "text" },
13
+ { "key": "bankDetails", "label": "Bank Details", "type": "textarea" },
14
+ { "key": "logoUrl", "label": "Logo URL", "type": "text" }
15
+ ]
16
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "invoice-receivers",
3
+ "label": "Invoice Receivers",
4
+ "slug": "invoice-receivers",
5
+ "storage": { "adapter": "file" },
6
+ "fields": [
7
+ { "key": "name", "label": "Name", "type": "text", "required": true },
8
+ { "key": "company", "label": "Company", "type": "text" },
9
+ { "key": "email", "label": "Email", "type": "text" },
10
+ { "key": "phone", "label": "Phone", "type": "text" },
11
+ { "key": "address", "label": "Address", "type": "textarea" },
12
+ { "key": "vatNumber", "label": "VAT Number", "type": "text" },
13
+ { "key": "reference", "label": "Reference", "type": "text" }
14
+ ]
15
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "invoices",
3
+ "label": "Invoices",
4
+ "slug": "invoices",
5
+ "storage": { "adapter": "file" },
6
+ "fields": [
7
+ { "key": "number", "label": "Number", "type": "text", "required": true },
8
+ { "key": "issuerId", "label": "Issuer", "type": "text", "required": true },
9
+ { "key": "receiverId", "label": "Receiver", "type": "text", "required": true },
10
+ { "key": "issueDate", "label": "Issue Date", "type": "date", "required": true },
11
+ { "key": "dueDate", "label": "Due Date", "type": "date" },
12
+ { "key": "status", "label": "Status", "type": "select", "required": true,
13
+ "options": [
14
+ { "value": "draft", "label": "Draft" },
15
+ { "value": "sent", "label": "Sent" },
16
+ { "value": "paid", "label": "Paid" },
17
+ { "value": "overdue", "label": "Overdue" },
18
+ { "value": "cancelled", "label": "Cancelled" }
19
+ ]
20
+ },
21
+ { "key": "currency", "label": "Currency", "type": "text" },
22
+ { "key": "vatEnabled", "label": "VAT Enabled", "type": "boolean" },
23
+ { "key": "vatRate", "label": "VAT Rate %", "type": "number" },
24
+ { "key": "lineItems", "label": "Line Items", "type": "json" },
25
+ { "key": "notes", "label": "Notes", "type": "textarea" }
26
+ ]
27
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Invoice Plugin — Default Configuration
3
+ *
4
+ * numberPrefix used in generated invoice numbers (e.g. INV-2026-0001)
5
+ * numberPadding minimum digits for the sequential portion
6
+ * defaultVatRate applied when VAT is enabled and no rate is set on the invoice
7
+ * defaultCurrency ISO 4217 code; controls formatting + symbol
8
+ * footerNote optional small print appended to every printed invoice
9
+ */
10
+ export default {
11
+ numberPrefix: 'INV',
12
+ numberPadding: 4,
13
+ defaultVatRate: 20,
14
+ defaultCurrency: 'GBP',
15
+ footerNote: 'Thank you for your business.'
16
+ };