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.
- package/package.json +2 -2
- package/plugins/analytics/admin/templates/analytics.html +52 -1
- package/plugins/analytics/admin/views/analytics.js +157 -32
- package/plugins/analytics/config.js +10 -2
- package/plugins/analytics/plugin.js +214 -25
- package/plugins/analytics/plugin.json +9 -5
- package/plugins/analytics/public/inject-body.html +25 -7
- package/plugins/blog/admin/templates/blog.html +25 -2
- package/plugins/blog/admin/views/blog.js +72 -56
- package/plugins/blog/admin/views/post-editor.js +98 -79
- package/plugins/blog/plugin.js +133 -0
- package/plugins/blog/plugin.json +3 -3
- package/plugins/blog/templates/post.html +2 -1
- package/plugins/invoice/admin/templates/editor.html +129 -0
- package/plugins/invoice/admin/templates/index.html +43 -0
- package/plugins/invoice/admin/templates/issuers.html +5 -0
- package/plugins/invoice/admin/templates/receivers.html +5 -0
- package/plugins/invoice/admin/views/editor.js +267 -0
- package/plugins/invoice/admin/views/index.js +155 -0
- package/plugins/invoice/admin/views/issuers.js +23 -0
- package/plugins/invoice/admin/views/party-view.js +148 -0
- package/plugins/invoice/admin/views/receivers.js +22 -0
- package/plugins/invoice/collections/invoice-issuers/schema.json +16 -0
- package/plugins/invoice/collections/invoice-receivers/schema.json +15 -0
- package/plugins/invoice/collections/invoices/schema.json +27 -0
- package/plugins/invoice/config.js +16 -0
- package/plugins/invoice/plugin.js +283 -0
- package/plugins/invoice/plugin.json +85 -0
- package/plugins/invoice/templates/invoice-print.html +213 -0
- package/server/services/markdown.js +24 -4
- package/server/services/renderer.js +9 -3
package/plugins/blog/plugin.js
CHANGED
|
@@ -51,9 +51,142 @@ function toComment(entry) {
|
|
|
51
51
|
};
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Embed shortcodes — register `[blog-list]`, `[blog-grid]`, `[blog-featured]`,
|
|
56
|
+
// `[blog-categories]` so users can drop blog content into any page.
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
function escapeHtml(str) {
|
|
60
|
+
return String(str ?? '')
|
|
61
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
62
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatDate(iso) {
|
|
66
|
+
if (!iso) return '';
|
|
67
|
+
return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isVisible(post) {
|
|
71
|
+
return post.status === 'published'
|
|
72
|
+
|| (post.status === 'scheduled' && post.publishedAt && new Date(post.publishedAt) <= new Date());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function loadPosts() {
|
|
76
|
+
const result = await listEntries(POSTS_SLUG, { limit: 100000, sort: 'createdAt', order: 'desc' });
|
|
77
|
+
const arr = Array.isArray(result) ? result : (result?.entries || []);
|
|
78
|
+
return arr.map((e) => ({ id: e.id, ...(e.data ?? e) }));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function loadCategories() {
|
|
82
|
+
const result = await listEntries(CATEGORIES_SLUG, { limit: 10000 });
|
|
83
|
+
const arr = Array.isArray(result) ? result : (result?.entries || []);
|
|
84
|
+
return arr.map((e) => ({ id: e.id, ...(e.data ?? e) }));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function selectPosts(posts, { limit, categorySlug, categoryMap }) {
|
|
88
|
+
let filtered = posts.filter(isVisible);
|
|
89
|
+
if (categorySlug && categoryMap) {
|
|
90
|
+
const cat = Object.values(categoryMap).find((c) => c.slug === categorySlug);
|
|
91
|
+
if (!cat) return [];
|
|
92
|
+
filtered = filtered.filter((p) => Array.isArray(p.categories) && p.categories.includes(cat.id));
|
|
93
|
+
}
|
|
94
|
+
filtered.sort((a, b) => new Date(b.publishedAt || 0) - new Date(a.publishedAt || 0));
|
|
95
|
+
if (limit) filtered = filtered.slice(0, limit);
|
|
96
|
+
return filtered;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function renderEmbedCard(post, base, { showImage = true } = {}) {
|
|
100
|
+
const image = showImage && post.featuredImage
|
|
101
|
+
? `<a href="${base}/${escapeHtml(post.slug)}"><img src="${escapeHtml(post.featuredImage)}" alt="${escapeHtml(post.title)}" class="blog-embed-image"></a>`
|
|
102
|
+
: '';
|
|
103
|
+
return `<article class="card mb-3 blog-embed-card">
|
|
104
|
+
${image}
|
|
105
|
+
<div class="card-body">
|
|
106
|
+
<h3 class="h5"><a href="${base}/${escapeHtml(post.slug)}">${escapeHtml(post.title)}</a></h3>
|
|
107
|
+
<p class="text-muted small mb-2">${formatDate(post.publishedAt)}</p>
|
|
108
|
+
${post.excerpt ? `<p class="mb-0">${escapeHtml(post.excerpt)}</p>` : ''}
|
|
109
|
+
</div>
|
|
110
|
+
</article>`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function registerEmbedShortcodes(registerShortcode, basePath) {
|
|
114
|
+
registerShortcode('blog-list', async (attrStr, _body, ctx) => {
|
|
115
|
+
const attrs = ctx.parseShortcodeAttrs(attrStr);
|
|
116
|
+
const limit = parseInt(attrs.count ?? attrs.limit ?? '5', 10) || 5;
|
|
117
|
+
const [posts, categories] = await Promise.all([loadPosts(), loadCategories()]);
|
|
118
|
+
const categoryMap = Object.fromEntries(categories.map((c) => [c.id, c]));
|
|
119
|
+
const selected = selectPosts(posts, { limit, categorySlug: attrs.category, categoryMap });
|
|
120
|
+
if (!selected.length) return '<p class="text-muted">No posts to show.</p>';
|
|
121
|
+
const items = selected.map((p) => `<li class="mb-2">
|
|
122
|
+
<a href="${basePath}/${escapeHtml(p.slug)}" class="d-block">
|
|
123
|
+
<strong>${escapeHtml(p.title)}</strong>
|
|
124
|
+
</a>
|
|
125
|
+
<span class="text-muted small">${formatDate(p.publishedAt)}</span>
|
|
126
|
+
</li>`).join('');
|
|
127
|
+
return `<ul class="list-unstyled blog-embed-list">${items}</ul>`;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
registerShortcode('blog-grid', async (attrStr, _body, ctx) => {
|
|
131
|
+
const attrs = ctx.parseShortcodeAttrs(attrStr);
|
|
132
|
+
const limit = parseInt(attrs.count ?? attrs.limit ?? '6', 10) || 6;
|
|
133
|
+
const cols = Math.max(1, Math.min(4, parseInt(attrs.cols ?? '3', 10) || 3));
|
|
134
|
+
const [posts, categories] = await Promise.all([loadPosts(), loadCategories()]);
|
|
135
|
+
const categoryMap = Object.fromEntries(categories.map((c) => [c.id, c]));
|
|
136
|
+
const selected = selectPosts(posts, { limit, categorySlug: attrs.category, categoryMap });
|
|
137
|
+
if (!selected.length) return '<p class="text-muted">No posts to show.</p>';
|
|
138
|
+
const cards = selected.map((p) => renderEmbedCard(p, basePath, { showImage: true })).join('');
|
|
139
|
+
return `<div class="grid grid-cols-${cols} gap-3 blog-embed-grid">${cards}</div>`;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
registerShortcode('blog-featured', async (attrStr, _body, ctx) => {
|
|
143
|
+
const attrs = ctx.parseShortcodeAttrs(attrStr);
|
|
144
|
+
const posts = await loadPosts();
|
|
145
|
+
let post;
|
|
146
|
+
if (attrs.slug) {
|
|
147
|
+
post = posts.find((p) => p.slug === attrs.slug && isVisible(p));
|
|
148
|
+
} else {
|
|
149
|
+
post = posts.filter(isVisible)
|
|
150
|
+
.sort((a, b) => new Date(b.publishedAt || 0) - new Date(a.publishedAt || 0))[0];
|
|
151
|
+
}
|
|
152
|
+
if (!post) return '<p class="text-muted">No featured post available.</p>';
|
|
153
|
+
const image = post.featuredImage
|
|
154
|
+
? `<a href="${basePath}/${escapeHtml(post.slug)}"><img src="${escapeHtml(post.featuredImage)}" alt="${escapeHtml(post.title)}" class="blog-featured-image"></a>`
|
|
155
|
+
: '';
|
|
156
|
+
return `<article class="card blog-featured-card mb-4">
|
|
157
|
+
${image}
|
|
158
|
+
<div class="card-body">
|
|
159
|
+
<h2><a href="${basePath}/${escapeHtml(post.slug)}">${escapeHtml(post.title)}</a></h2>
|
|
160
|
+
<p class="text-muted small mb-3">${formatDate(post.publishedAt)}</p>
|
|
161
|
+
${post.excerpt ? `<p>${escapeHtml(post.excerpt)}</p>` : ''}
|
|
162
|
+
<a href="${basePath}/${escapeHtml(post.slug)}" class="btn btn-primary">Read more</a>
|
|
163
|
+
</div>
|
|
164
|
+
</article>`;
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
registerShortcode('blog-categories', async (attrStr, _body, ctx) => {
|
|
168
|
+
const attrs = ctx.parseShortcodeAttrs(attrStr);
|
|
169
|
+
const showCounts = attrs['show-counts'] === 'true' || attrs.counts === 'true';
|
|
170
|
+
const [posts, categories] = await Promise.all([loadPosts(), loadCategories()]);
|
|
171
|
+
if (!categories.length) return '<p class="text-muted">No categories yet.</p>';
|
|
172
|
+
const visible = posts.filter(isVisible);
|
|
173
|
+
const items = categories.map((c) => {
|
|
174
|
+
const count = visible.filter((p) => Array.isArray(p.categories) && p.categories.includes(c.id)).length;
|
|
175
|
+
const countSpan = showCounts ? ` <span class="badge badge-secondary">${count}</span>` : '';
|
|
176
|
+
return `<li class="mb-2"><a href="${basePath}/category/${escapeHtml(c.slug)}">${escapeHtml(c.name)}</a>${countSpan}</li>`;
|
|
177
|
+
}).join('');
|
|
178
|
+
return `<ul class="list-unstyled blog-embed-categories">${items}</ul>`;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
54
182
|
export default async function blogPlugin(fastify, options) {
|
|
55
183
|
const { authenticate, requireAdmin } = options.auth;
|
|
56
184
|
const settings = { ...defaultConfig, ...(options.settings || {}) };
|
|
185
|
+
const basePath = (settings.basePath ?? '/blog').replace(/\/$/, '');
|
|
186
|
+
|
|
187
|
+
if (options.hooks?.registerShortcode) {
|
|
188
|
+
registerEmbedShortcodes(options.hooks.registerShortcode, basePath);
|
|
189
|
+
}
|
|
57
190
|
|
|
58
191
|
// -------------------------------------------------------------------------
|
|
59
192
|
// Posts
|
package/plugins/blog/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "blog",
|
|
3
3
|
"displayName": "Blog",
|
|
4
|
-
"version": "1.
|
|
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=
|
|
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=
|
|
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}} • <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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
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
|
+
};
|