@arcforgelabs/flow 2026.10.35 → 2026.10.37
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/README.md +1 -0
- package/npm-shrinkwrap.json +81 -50
- package/package.json +6 -5
- package/server/api/invoicing.js +23 -2
- package/server/auth/clerk.js +35 -0
- package/server/auth/routes.js +45 -6
- package/server/core/host.js +24 -0
- package/server/index.js +9 -3
- package/server/integrations/seazona/index.js +1 -1
- package/server/integrations/seazona/settings.js +5 -2
- package/server/integrations/sentry/index.js +5 -2
- package/server/integrations/xero/index.js +3 -3
- package/server/integrations/xero/settings.js +6 -0
- package/server/ui/app.js +88 -23
- package/server/ui/bulk-bar.js +99 -0
- package/server/ui/custom-fields.js +223 -56
- package/server/ui/flow.css +158 -11
- package/server/ui/invoicing.js +291 -45
- package/server/ui/job-board.js +58 -34
- package/server/ui/jobs.js +27 -13
- package/server/ui/routes.js +11 -1
package/server/ui/invoicing.js
CHANGED
|
@@ -6,26 +6,27 @@
|
|
|
6
6
|
// zero, server/core/money.js). Data comes from /api/invoicing
|
|
7
7
|
// (server/api/invoicing.js). app.js passes in its shared pieces as `ui`.
|
|
8
8
|
import { alertStrip } from '/assets/ui/alerts.js';
|
|
9
|
+
import { bulkBar } from '/assets/ui/bulk-bar.js';
|
|
9
10
|
import { draftNote, sendModeNote } from '/assets/ui/invoice-notes.js';
|
|
10
11
|
import { jobChangeBanner } from '/assets/ui/job-change.js';
|
|
11
12
|
|
|
12
|
-
const STATUS_LABEL = { draft: 'Draft', issued: '
|
|
13
|
+
const STATUS_LABEL = { draft: 'Draft', issued: 'Finalised', void: 'Void' };
|
|
13
14
|
const STATUS_TONE = { draft: 'info', issued: 'success', void: 'mute' };
|
|
14
15
|
// Drafts (to check), Problem and Ready are views (server/core/sending.js):
|
|
15
16
|
// every draft is in one of them. A person marks a checked draft ready; a
|
|
16
17
|
// saved change keeps it there. An issued invoice that failed to send is a
|
|
17
18
|
// problem.
|
|
18
19
|
// Ready first: it opens by default, as the drafts most often wait there to send.
|
|
19
|
-
const TABS = [['ready', 'Ready'], ['to_check', 'Drafts'], ['problem', 'Problem'], ['issued', '
|
|
20
|
+
const TABS = [['ready', 'Ready'], ['to_check', 'Drafts'], ['problem', 'Problem'], ['issued', 'Finalised'], ['void', 'Void']];
|
|
20
21
|
const VIEW_LABEL = { problem: 'Problem', ready: 'Ready', sending: 'Sending', to_check: 'Draft' };
|
|
21
22
|
const VIEW_TONE = { problem: 'danger', ready: 'success', sending: 'info', to_check: 'info' };
|
|
22
23
|
const EMPTY = {
|
|
23
24
|
to_check: 'No drafts to check. Press New invoice to start one.',
|
|
24
25
|
problem: 'No problems.',
|
|
25
|
-
ready: 'Nothing is ready.
|
|
26
|
+
ready: 'Nothing is ready. In Drafts, tick drafts and press Ready.',
|
|
26
27
|
};
|
|
27
|
-
//
|
|
28
|
-
|
|
28
|
+
// Every tab's rows can be ticked; the bulk bar (bulkActions) says what the
|
|
29
|
+
// ticked rows can do there.
|
|
29
30
|
|
|
30
31
|
const money = new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' });
|
|
31
32
|
export const formatCents = (cents) => money.format((cents ?? 0) / 100);
|
|
@@ -46,6 +47,35 @@ export function xeroTag(ui, status) {
|
|
|
46
47
|
: h('span', { class: 'fx-status', 'data-tone': XERO_TONE[status.state] ?? 'mute' }, status.label);
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
// The Linked column: one icon for each connected system (linksTo), with a
|
|
51
|
+
// dot for the state of this invoice's link there. An icon with a link opens
|
|
52
|
+
// the record in that system. Accounting icons show on numbered invoices only.
|
|
53
|
+
const LINK_TONE = {
|
|
54
|
+
not_sent: 'warn', sending: 'warn', not_copied: 'warn', draft: 'success',
|
|
55
|
+
approved: 'success', part_paid: 'success', paid: 'success', voided: 'mute', deleted: 'mute',
|
|
56
|
+
};
|
|
57
|
+
export function linkIcon(ui, system, { tone = null, title, url = null } = {}) {
|
|
58
|
+
const { h } = ui;
|
|
59
|
+
const kids = [h('img', { src: `/assets/ui/logos/${system.id}.svg`, alt: '' }), tone ? h('i', { class: 'fx-link__dot', 'data-tone': tone }) : null];
|
|
60
|
+
const props = { class: `fx-link${tone ? '' : ' fx-link--none'}`, title, 'aria-label': title };
|
|
61
|
+
return url
|
|
62
|
+
? h('a', { ...props, href: url, target: '_blank', rel: 'noopener', onclick: (event) => event.stopPropagation() }, ...kids)
|
|
63
|
+
: h('span', props, ...kids);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function workIcon(ui, system, job) {
|
|
67
|
+
if (job?.sourceSystem !== system.id) return linkIcon(ui, system, { title: `Not linked to ${system.name}` });
|
|
68
|
+
const ref = job.caseRef ?? job.sourceJobRef;
|
|
69
|
+
return linkIcon(ui, system, { tone: 'success', title: `${system.name} ${ref}${job.link?.url ? `. Open in ${system.name}.` : ''}`, url: job.link?.url ?? null });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function accountingIcon(ui, system, status) {
|
|
73
|
+
const tone = status.finding ? 'danger' : LINK_TONE[status.state] ?? 'mute';
|
|
74
|
+
const number = status.xero?.number ? ` ${status.xero.number}` : '';
|
|
75
|
+
const title = `${system.name}${number}: ${status.finding ? status.message : status.label}${status.xero?.url ? ` Open in ${system.name}.` : ''}`;
|
|
76
|
+
return linkIcon(ui, system, { tone, title, url: status.xero?.url ?? null });
|
|
77
|
+
}
|
|
78
|
+
|
|
49
79
|
const showField = (field, value) => (['subtotal', 'tax', 'total'].includes(field) && Number.isFinite(value) ? formatCents(value) : String(value ?? '—'));
|
|
50
80
|
|
|
51
81
|
// Xero's status for some invoices, or an empty map while Xero is off.
|
|
@@ -91,18 +121,28 @@ export async function renderInvoiceList(main, ui) {
|
|
|
91
121
|
const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a number, client or reference', 'aria-label': 'Find an invoice', value: state.q });
|
|
92
122
|
const bar = h('div', { class: 'fx-sendbar', hidden: true });
|
|
93
123
|
const host = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
|
|
124
|
+
const bulk = bulkBar(ui);
|
|
94
125
|
const context = await flow('/api/invoicing/context').catch(() => ({ canIssue: false }));
|
|
95
126
|
let body = null;
|
|
96
127
|
const openTab = (id) => { state.status = id; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); };
|
|
97
128
|
const alerts = alertStrip(ui, 'invoices', { onTab: openTab });
|
|
129
|
+
// The tabs, search and send bar stay at the top while the rows scroll up
|
|
130
|
+
// under them; the bulk bar floats at the foot (ClickUp's layout).
|
|
131
|
+
const head = h('div', { class: 'fx-listhead' }, h('div', { class: 'fx-toolbar' }, tabs, search), bar);
|
|
132
|
+
const edge = h('div', { class: 'fx-listhead__edge', 'aria-hidden': 'true' });
|
|
98
133
|
main.append(
|
|
99
134
|
pageHeader('Invoices', {
|
|
100
135
|
actions: [h('a', { class: 'fx-btn fx-btn--primary', href: '/invoices/new', onclick: go }, 'New invoice')],
|
|
101
136
|
}),
|
|
102
137
|
alerts.element,
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
host
|
|
138
|
+
edge,
|
|
139
|
+
head,
|
|
140
|
+
host,
|
|
141
|
+
bulk.element);
|
|
142
|
+
// A shadow under the head shows once rows scroll under it.
|
|
143
|
+
if (typeof IntersectionObserver === 'function') {
|
|
144
|
+
new IntersectionObserver(([entry]) => head.toggleAttribute('data-stuck', !entry.isIntersecting), { rootMargin: `-${head.offsetTop > 0 && window.matchMedia?.('(max-width: 820px)').matches ? 61 : 1}px 0px 0px 0px` }).observe(edge);
|
|
145
|
+
}
|
|
106
146
|
|
|
107
147
|
let timer;
|
|
108
148
|
search.addEventListener('input', () => {
|
|
@@ -138,33 +178,180 @@ export async function renderInvoiceList(main, ui) {
|
|
|
138
178
|
const ticked = [...state.ticked];
|
|
139
179
|
const parts = [];
|
|
140
180
|
if (state.status === 'to_check') {
|
|
141
|
-
parts.push(h('p', { class: 'fx-dim' },
|
|
142
|
-
if (ticked.length) {
|
|
143
|
-
parts.push(action(`Move ${ticked.length} to Ready`, 'mark-ready', () => ({ invoiceIds: ticked }), (res) => (res.refused.length
|
|
144
|
-
? [`${res.marked} ready. ${plural(res.refused.length, 'draft has', 'drafts have')} a problem and moved to Problem.`, 'danger']
|
|
145
|
-
: [`${plural(res.marked, 'draft', 'drafts')} ready.`, 'success'])));
|
|
146
|
-
}
|
|
181
|
+
parts.push(h('p', { class: 'fx-dim' }, 'Check each draft, tick it, then press Ready.'));
|
|
147
182
|
} else if (state.status === 'ready') {
|
|
148
183
|
parts.push(h('p', { class: 'fx-dim' }, sendModeNote(to, body.sendMode)));
|
|
149
184
|
const buttons = [];
|
|
150
|
-
if (ticked.length) {
|
|
151
|
-
buttons.push(action(`Move ${ticked.length} to Drafts`, 'mark-not-ready', () => ({ invoiceIds: ticked }),
|
|
152
|
-
(res) => [`${plural(res.moved, 'invoice', 'invoices')} back in Drafts.`, 'success'], 'ghost'));
|
|
153
|
-
}
|
|
154
185
|
if (context.canIssue && body.counts.ready > 0) {
|
|
155
186
|
buttons.push(action(to ? `Send all ${body.counts.ready} ready to ${to}` : `Finalise all ${body.counts.ready} ready`, 'send-ready', () => ({ issueDate: localDate() }),
|
|
156
|
-
(res) => [`${res.issued}
|
|
187
|
+
(res) => [`${res.issued} finalised.${to ? ` They are on the way to ${to}.` : ''}${res.remaining ? ` ${res.remaining} more are ready: press again.` : ''}`, 'success']));
|
|
157
188
|
}
|
|
158
189
|
if (buttons.length) parts.push(h('div', { class: 'fx-sendbar__actions' }, buttons));
|
|
159
190
|
} else if (state.status === 'problem') {
|
|
160
191
|
const failed = body.invoices.filter((i) => i.status === 'issued').length;
|
|
161
192
|
if (context.canIssue && to && failed > 0) {
|
|
162
|
-
parts.push(h('p', { class: 'fx-dim' }, `${plural(failed, '
|
|
193
|
+
parts.push(h('p', { class: 'fx-dim' }, `${plural(failed, 'finalised invoice', 'finalised invoices')} did not reach ${to}. Fix the cause, then send again.`));
|
|
163
194
|
parts.push(action('Send again', 'send-again', () => ({}), (res) => [`${res.queued} on the way to ${to} again.`, 'success']));
|
|
164
195
|
}
|
|
165
196
|
}
|
|
166
197
|
bar.replaceChildren(...parts);
|
|
167
198
|
bar.hidden = !parts.length;
|
|
199
|
+
bulk.draw({ count: ticked.length, onClear: () => setTicks([]), ...bulkActions(ticked) });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Ticks changed from the bar: the rows draw again with the new ticks.
|
|
203
|
+
function setTicks(ids) {
|
|
204
|
+
state.ticked = new Set(ids);
|
|
205
|
+
if (body) draw(body);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// A bulk step on the server: on success the ticks clear and the list loads
|
|
209
|
+
// again; on a failure the ticks stay, so a person can try again.
|
|
210
|
+
async function post(path, payload, done) {
|
|
211
|
+
try {
|
|
212
|
+
const res = await flow(`/api/invoicing/invoices/${path}`, { method: 'POST', body: JSON.stringify(payload) });
|
|
213
|
+
toast(...done(res));
|
|
214
|
+
state.ticked.clear();
|
|
215
|
+
seen.clear();
|
|
216
|
+
await load();
|
|
217
|
+
alerts.refresh();
|
|
218
|
+
} catch (error) {
|
|
219
|
+
toast(error.message, 'danger');
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// What the ticked rows can do on this tab. The bar shows the main ones;
|
|
224
|
+
// More shows them all, with Selection steps under them.
|
|
225
|
+
function bulkActions(ticked) {
|
|
226
|
+
const to = body.sendsTo;
|
|
227
|
+
const rows = body.invoices.filter((i) => state.ticked.has(i.id));
|
|
228
|
+
const numbered = ['issued', 'void'].includes(state.status);
|
|
229
|
+
const status = [];
|
|
230
|
+
const apply = [];
|
|
231
|
+
if (state.status === 'ready' && context.canIssue) {
|
|
232
|
+
status.push({
|
|
233
|
+
icon: 'circle-check-big', label: 'Finalise', primary: true,
|
|
234
|
+
title: to ? `Finalise and send to ${to}` : 'Finalise',
|
|
235
|
+
run: async () => {
|
|
236
|
+
const ok = await ui.dialog({
|
|
237
|
+
title: `Finalise ${plural(ticked.length, 'invoice', 'invoices')}?`,
|
|
238
|
+
body: h('p', {}, `Each one gets the next number and is locked.${to ? ` ${sendModeNote(to, body.sendMode, 'each one')}` : ''}`),
|
|
239
|
+
confirm: to ? `Finalise and send ${ticked.length}` : `Finalise ${ticked.length}`,
|
|
240
|
+
});
|
|
241
|
+
if (!ok) return;
|
|
242
|
+
await post('send-ready', { issueDate: localDate(), invoiceIds: ticked },
|
|
243
|
+
(res) => [`${plural(res.issued, 'invoice', 'invoices')} finalised.${to ? ` They are on the way to ${to}.` : ''}${res.remaining ? ` ${res.remaining} more stay in Ready: tick them and press again.` : ''}`, 'success']);
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
if (state.status === 'ready') {
|
|
248
|
+
status.push({
|
|
249
|
+
icon: 'undo-2', label: 'Drafts', title: 'Move to Drafts',
|
|
250
|
+
run: () => post('mark-not-ready', { invoiceIds: ticked }, (res) => [`${plural(res.moved, 'invoice', 'invoices')} back in Drafts.`, 'success']),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (state.status === 'to_check') {
|
|
254
|
+
status.push({
|
|
255
|
+
icon: 'check-check', label: 'Ready', primary: true, title: 'Move to Ready',
|
|
256
|
+
run: () => post('mark-ready', { invoiceIds: ticked }, (res) => (res.refused.length
|
|
257
|
+
? [`${res.marked} ready. ${plural(res.refused.length, 'draft has', 'drafts have')} a problem and moved to Problem.`, 'danger']
|
|
258
|
+
: [`${plural(res.marked, 'draft', 'drafts')} ready.`, 'success'])),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
const failed = rows.filter((i) => i.status === 'issued').map((i) => i.id);
|
|
262
|
+
if (state.status === 'problem' && context.canIssue && to && failed.length) {
|
|
263
|
+
status.push({
|
|
264
|
+
icon: 'refresh-cw', label: 'Retry', primary: true, title: `Send again to ${to}`,
|
|
265
|
+
run: () => post('send-again', { invoiceIds: failed }, (res) => [`${res.queued} on the way to ${to} again.`, 'success']),
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
apply.push({
|
|
269
|
+
icon: 'copy', label: 'Copy', iconOnly: true, title: numbered ? 'Copy numbers' : 'Copy references',
|
|
270
|
+
run: () => copyLines(rows.map((i) => (numbered ? i.number : i.reference) ?? i.contactName ?? '')),
|
|
271
|
+
});
|
|
272
|
+
apply.push({ icon: 'download', label: 'Export', iconOnly: true, title: 'Export CSV', run: () => exportCsv(rows) });
|
|
273
|
+
if (numbered) apply.push({ icon: 'file-down', label: 'PDFs', iconOnly: true, title: 'Download PDFs', run: () => downloadPdfs(rows) });
|
|
274
|
+
const danger = [];
|
|
275
|
+
const voidable = rows.filter((i) => i.status === 'issued');
|
|
276
|
+
if (state.status === 'issued' && context.canIssue && voidable.length) {
|
|
277
|
+
danger.push({
|
|
278
|
+
icon: 'ban', label: 'Void', iconOnly: true, tone: 'danger', title: 'Void',
|
|
279
|
+
run: () => voidAll(voidable),
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
const all = body.invoices.map((i) => i.id);
|
|
283
|
+
const selection = [
|
|
284
|
+
{ icon: 'list-checks', label: 'Tick all shown', run: () => setTicks(all) },
|
|
285
|
+
{ icon: 'square-check', label: 'Invert ticks', run: () => setTicks(all.filter((id) => !state.ticked.has(id))) },
|
|
286
|
+
{ icon: 'square-x', label: 'Clear ticks', run: () => setTicks([]) },
|
|
287
|
+
];
|
|
288
|
+
return {
|
|
289
|
+
groups: [status, apply, danger],
|
|
290
|
+
more: [
|
|
291
|
+
{ head: 'Change status', actions: [...status, ...danger] },
|
|
292
|
+
{ head: 'Apply an action', actions: apply },
|
|
293
|
+
{ head: 'Selection', actions: selection },
|
|
294
|
+
],
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function copyLines(lines) {
|
|
299
|
+
try {
|
|
300
|
+
await navigator.clipboard.writeText(lines.join('\n'));
|
|
301
|
+
toast(`${plural(lines.length, 'line', 'lines')} copied.`, 'success');
|
|
302
|
+
} catch {
|
|
303
|
+
toast('The browser did not allow a copy.', 'danger');
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// The ticked rows as a CSV file, with the columns on the screen.
|
|
308
|
+
function exportCsv(rows) {
|
|
309
|
+
const cell = (v) => (/[",\n]/.test(String(v ?? '')) ? `"${String(v).replace(/"/g, '""')}"` : String(v ?? ''));
|
|
310
|
+
const lines = [['Number', 'Client', 'Reference', 'Date', 'Total', 'Status']]
|
|
311
|
+
.concat(rows.map((i) => [i.number ?? '', i.contactName ?? '', i.reference ?? '', i.issueDate ?? i.createdAt?.slice(0, 10) ?? '',
|
|
312
|
+
((i.totalCents ?? 0) / 100).toFixed(2), i.status === 'void' ? 'Void' : VIEW_LABEL[i.view] ?? STATUS_LABEL[i.status]]));
|
|
313
|
+
save(new Blob([lines.map((l) => l.map(cell).join(',')).join('\r\n')], { type: 'text/csv' }), `invoices-${state.status}-${localDate()}.csv`);
|
|
314
|
+
toast(`${plural(rows.length, 'invoice', 'invoices')} exported.`, 'success');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function downloadPdfs(rows) {
|
|
318
|
+
const token = await window.Clerk?.session?.getToken();
|
|
319
|
+
let saved = 0;
|
|
320
|
+
for (const invoice of rows) {
|
|
321
|
+
try {
|
|
322
|
+
const res = await fetch(`/api/invoicing/invoices/${invoice.id}/pdf`, { headers: { Authorization: `Bearer ${token}` } });
|
|
323
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
324
|
+
save(await res.blob(), `${invoice.number ?? invoice.id}.pdf`);
|
|
325
|
+
saved += 1;
|
|
326
|
+
} catch {
|
|
327
|
+
// The toast below counts the ones that did not save.
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
toast(saved === rows.length ? `${plural(saved, 'PDF', 'PDFs')} saved.` : `${saved} of ${rows.length} PDFs saved.`, saved === rows.length ? 'success' : 'danger');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Void, one invoice after the other, as on one invoice's page.
|
|
334
|
+
async function voidAll(rows) {
|
|
335
|
+
const ok = await ui.dialog({
|
|
336
|
+
title: `Void ${plural(rows.length, 'invoice', 'invoices')}?`,
|
|
337
|
+
body: h('div', {}, h('p', {}, 'Their numbers stay used. A void invoice cannot be finalised again.'),
|
|
338
|
+
body.sendsTo ? h('p', { class: 'fx-problem' }, `Flow does not change ${body.sendsTo}: void them in ${body.sendsTo} too.`) : null),
|
|
339
|
+
confirm: `Void ${rows.length}`, tone: 'danger',
|
|
340
|
+
});
|
|
341
|
+
if (!ok) return;
|
|
342
|
+
let done = 0;
|
|
343
|
+
let error = null;
|
|
344
|
+
for (const invoice of rows) {
|
|
345
|
+
try {
|
|
346
|
+
await flow(`/api/invoicing/invoices/${invoice.id}/void`, { method: 'POST', body: '{}' });
|
|
347
|
+
done += 1;
|
|
348
|
+
} catch (e) { error = e; }
|
|
349
|
+
}
|
|
350
|
+
toast(error ? `${done} of ${rows.length} void. ${error.message}` : `${plural(done, 'invoice', 'invoices')} void.`, error ? 'danger' : 'success');
|
|
351
|
+
state.ticked.clear();
|
|
352
|
+
seen.clear();
|
|
353
|
+
await load();
|
|
354
|
+
alerts.refresh();
|
|
168
355
|
}
|
|
169
356
|
|
|
170
357
|
// A tab switch shows at once: the tab is selected, the last list seen for
|
|
@@ -228,12 +415,15 @@ export async function renderInvoiceList(main, ui) {
|
|
|
228
415
|
saveNav({ tab: state.status, q: state.q, ids: body.invoices.map((i) => i.id) });
|
|
229
416
|
const open = (href) => { saveNav({ ...readNav(), scrollY: window.scrollY }); go(href); };
|
|
230
417
|
const link = (href, label) => h('a', { class: 'fx-strong', href, onclick: (event) => { event.preventDefault(); event.stopPropagation(); open(href); } }, label);
|
|
231
|
-
const ticks = TICKS[state.status];
|
|
232
418
|
const showProblem = state.status === 'problem';
|
|
233
419
|
const created = ['to_check', 'problem', 'ready'].includes(state.status);
|
|
234
420
|
// An invoice gets its number when it is sent or issued, so only the
|
|
235
|
-
//
|
|
421
|
+
// Finalised and Void tabs show a Number column; the others open on Client.
|
|
236
422
|
const numbered = !created;
|
|
423
|
+
// With an integration on, every tab has a Linked column (linkIcon).
|
|
424
|
+
const systems = body.linksTo ?? [];
|
|
425
|
+
const linked = systems.length > 0;
|
|
426
|
+
const accountingSystem = systems.find((s) => s.kind === 'accounting') ?? null;
|
|
237
427
|
const boxes = [];
|
|
238
428
|
const tickBox = (invoice) => {
|
|
239
429
|
const box = h('input', {
|
|
@@ -247,6 +437,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
247
437
|
};
|
|
248
438
|
const all = h('input', {
|
|
249
439
|
type: 'checkbox', class: 'fx-tick', 'aria-label': 'Tick every invoice shown',
|
|
440
|
+
...(state.ticked.size && body.invoices.every((i) => state.ticked.has(i.id)) ? { checked: true } : {}),
|
|
250
441
|
onchange: () => {
|
|
251
442
|
for (const invoice of body.invoices) { if (all.checked) state.ticked.add(invoice.id); else state.ticked.delete(invoice.id); }
|
|
252
443
|
for (const box of boxes) box.checked = all.checked;
|
|
@@ -256,15 +447,16 @@ export async function renderInvoiceList(main, ui) {
|
|
|
256
447
|
const statusCells = new Map();
|
|
257
448
|
host.replaceChildren(h('table', { class: 'fx-table fx-table--rows' },
|
|
258
449
|
h('thead', {}, h('tr', {},
|
|
259
|
-
|
|
450
|
+
h('th', { class: 'fx-tick-cell' }, all),
|
|
260
451
|
numbered ? h('th', {}, 'Number') : null, h('th', {}, 'Client'), h('th', {}, showProblem ? 'Problem' : 'Reference'),
|
|
261
|
-
h('th', {}, created ? 'Created' : '
|
|
452
|
+
h('th', {}, created ? 'Created' : 'Finalised'), h('th', { class: 'fx-num' }, 'Total'), h('th', {}, 'Status'),
|
|
453
|
+
linked ? h('th', {}, 'Linked') : null)),
|
|
262
454
|
h('tbody', {}, body.invoices.map((invoice) => {
|
|
263
455
|
const href = `/invoices/${invoice.id}`;
|
|
264
456
|
const first = invoice.problems?.[0];
|
|
265
457
|
const view = invoice.status === 'void' ? null : invoice.view;
|
|
266
458
|
return h('tr', { tabindex: '0', onclick: () => open(href), onkeydown: (event) => { if (event.key === 'Enter') open(href); } },
|
|
267
|
-
|
|
459
|
+
h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, tickBox(invoice)),
|
|
268
460
|
numbered ? h('td', {}, link(href, invoice.number ?? 'Draft')) : null,
|
|
269
461
|
h('td', {}, numbered ? (invoice.contactName ?? '—') : link(href, invoice.contactName ?? 'No client')),
|
|
270
462
|
showProblem
|
|
@@ -276,17 +468,25 @@ export async function renderInvoiceList(main, ui) {
|
|
|
276
468
|
const cell = h('td', { class: 'fx-status-cell' }, view
|
|
277
469
|
? h('span', { class: 'fx-status', 'data-tone': VIEW_TONE[view] }, VIEW_LABEL[view])
|
|
278
470
|
: h('span', { class: 'fx-status', 'data-tone': STATUS_TONE[invoice.status] }, STATUS_LABEL[invoice.status]));
|
|
471
|
+
if (!linked) statusCells.set(invoice.id, cell);
|
|
472
|
+
return cell;
|
|
473
|
+
})(),
|
|
474
|
+
linked ? (() => {
|
|
475
|
+
const cell = h('td', { class: 'fx-links' }, systems.filter((s) => s.kind === 'work').map((s) => workIcon(ui, s, invoice.job)));
|
|
279
476
|
statusCells.set(invoice.id, cell);
|
|
280
477
|
return cell;
|
|
281
|
-
})());
|
|
478
|
+
})() : null);
|
|
282
479
|
}))));
|
|
283
|
-
//
|
|
284
|
-
|
|
480
|
+
// Finalised and void invoices also show Xero's status, from Flow's copy:
|
|
481
|
+
// in the Linked column every one gets an icon, so one not in Xero
|
|
482
|
+
// stands out.
|
|
483
|
+
if (numbered) {
|
|
285
484
|
const shownBody = body;
|
|
286
485
|
const statuses = await xeroStatuses(ui, context.organisations?.[0]?.id, body.invoices.map((i) => i.id));
|
|
287
486
|
if (shownBody !== body) return;
|
|
288
487
|
for (const [invoiceId, status] of statuses) {
|
|
289
|
-
if (
|
|
488
|
+
if (linked) { if (accountingSystem) statusCells.get(invoiceId)?.append(accountingIcon(ui, accountingSystem, status)); }
|
|
489
|
+
else if (status.state !== 'not_sent' || status.finding) statusCells.get(invoiceId)?.append(' ', xeroTag(ui, status));
|
|
290
490
|
}
|
|
291
491
|
}
|
|
292
492
|
}
|
|
@@ -294,6 +494,18 @@ export async function renderInvoiceList(main, ui) {
|
|
|
294
494
|
if (returning && back?.scrollY) window.scrollTo(0, back.scrollY);
|
|
295
495
|
}
|
|
296
496
|
|
|
497
|
+
// A file the browser saves, from a blob made on this page.
|
|
498
|
+
function save(blob, name) {
|
|
499
|
+
const url = URL.createObjectURL(blob);
|
|
500
|
+
const a = document.createElement('a');
|
|
501
|
+
a.href = url;
|
|
502
|
+
a.download = name;
|
|
503
|
+
document.body.append(a);
|
|
504
|
+
a.click();
|
|
505
|
+
a.remove();
|
|
506
|
+
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
507
|
+
}
|
|
508
|
+
|
|
297
509
|
// The list an invoice was opened from: its tab, search, invoice ids in order,
|
|
298
510
|
// and scroll. The invoice page's bar steps through it and goes back to it.
|
|
299
511
|
const NAV = 'flow_invoice_nav';
|
|
@@ -307,7 +519,7 @@ function saveNav(nav) {
|
|
|
307
519
|
|
|
308
520
|
// The bar above one invoice: Back to the list as it was, and the previous and
|
|
309
521
|
// next invoice of that list. An invoice opened from elsewhere (the job board,
|
|
310
|
-
// search, a link) steps through its own tab: Drafts, Ready, Problem,
|
|
522
|
+
// search, a link) steps through its own tab: Drafts, Ready, Problem, Finalised
|
|
311
523
|
// or Void, read once here.
|
|
312
524
|
function navBar(ui, invoice) {
|
|
313
525
|
const { h, go, flow } = ui;
|
|
@@ -393,16 +605,46 @@ function totalsBox(ui, { subtotalCents, taxCents, totalCents }) {
|
|
|
393
605
|
h('dt', { class: 'fx-totals__grand' }, 'Total'), h('dd', { class: 'fx-totals__grand' }, formatCents(totalCents)));
|
|
394
606
|
}
|
|
395
607
|
|
|
396
|
-
// The
|
|
397
|
-
|
|
608
|
+
// The Linked card: this invoice's link in each connected system (linksTo).
|
|
609
|
+
// A work system row names the job's order and opens it there; the
|
|
610
|
+
// accounting row gives the number and state there (from Flow's copy). A
|
|
611
|
+
// system with no link shows a dashed row. With no system on, the card holds
|
|
612
|
+
// the job's facts only.
|
|
613
|
+
function linkedCard(ui, invoice, accountingStatus = null) {
|
|
398
614
|
const { h } = ui;
|
|
399
|
-
|
|
615
|
+
const job = invoice?.job ?? null;
|
|
616
|
+
const systems = invoice?.linksTo ?? [];
|
|
617
|
+
if (!job && !systems.length) return null;
|
|
618
|
+
const facts = job && (job.practitioner || job.shippedOn) ? h('dl', { class: 'fx-facts' },
|
|
619
|
+
job.practitioner ? [h('dt', {}, 'Practitioner'), h('dd', {}, job.practitioner)] : null,
|
|
620
|
+
job.shippedOn ? [h('dt', {}, 'Shipped'), h('dd', {}, showDate(job.shippedOn))] : null) : null;
|
|
621
|
+
const row = (system, { tone = null, ref = null, state = null, url = null, none = null }) => h('div', { class: 'fx-linked__row' },
|
|
622
|
+
linkIcon(ui, system, { tone, title: none ?? `${system.name}${ref ? ` ${ref}` : ''}` }),
|
|
623
|
+
h('span', { class: 'fx-strong' }, system.name),
|
|
624
|
+
ref ? h('span', { class: 'fx-linked__ref' }, ref) : null,
|
|
625
|
+
state ? h('span', { class: 'fx-status', 'data-tone': tone ?? 'mute' }, state) : null,
|
|
626
|
+
none ? h('span', { class: 'fx-dim' }, none) : null,
|
|
627
|
+
url ? h('a', { class: 'fx-linked__open', href: url, target: '_blank', rel: 'noopener', title: `Open in ${system.name}` }, 'Open ↗') : null);
|
|
628
|
+
const rows = systems.map((system) => {
|
|
629
|
+
if (system.kind === 'work') {
|
|
630
|
+
return job?.sourceSystem === system.id
|
|
631
|
+
? row(system, { tone: 'success', ref: job.caseRef ?? job.sourceJobRef, url: job.link?.url ?? null })
|
|
632
|
+
: row(system, { none: 'No job linked' });
|
|
633
|
+
}
|
|
634
|
+
const status = accountingStatus;
|
|
635
|
+
if (!status || (status.state === 'not_sent' && !status.finding)) {
|
|
636
|
+
return row(system, { none: invoice?.status === 'draft' ? 'Goes there when sent' : `Not in ${system.name} yet` });
|
|
637
|
+
}
|
|
638
|
+
const tone = status.finding ? 'danger' : LINK_TONE[status.state] ?? 'mute';
|
|
639
|
+
const state = status.finding ? 'Out of sync' : status.label.replace(` in ${system.name}`, '');
|
|
640
|
+
return row(system, { tone, ref: status.xero?.number ?? null, state, url: status.xero?.url ?? null });
|
|
641
|
+
});
|
|
642
|
+
const workRows = systems.some((s) => s.kind === 'work');
|
|
400
643
|
return h('section', { class: 'fx-card fx-side-card' },
|
|
401
|
-
h('h3', { class: 'fx-label' }, '
|
|
402
|
-
h('
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
h('dt', {}, 'From'), h('dd', {}, job.sourceSystem === 'seazona' ? 'Seazona' : job.sourceSystem)));
|
|
644
|
+
h('h3', { class: 'fx-label' }, 'Linked'),
|
|
645
|
+
!workRows && job ? h('p', { class: 'fx-dim' }, `Job from ${job.systemName ?? job.sourceSystem}`) : null,
|
|
646
|
+
h('div', { class: 'fx-linked' }, rows),
|
|
647
|
+
facts);
|
|
406
648
|
}
|
|
407
649
|
|
|
408
650
|
// A draft, or a new invoice before its first save.
|
|
@@ -754,7 +996,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
754
996
|
h('div', { class: 'fx-lines__foot' },
|
|
755
997
|
h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', onclick: () => { lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null }); drawLines(); } }, '+ Add line'),
|
|
756
998
|
totalsHost))),
|
|
757
|
-
h('aside', { class: 'fx-invoice__aside' }, statusCard,
|
|
999
|
+
h('aside', { class: 'fx-invoice__aside' }, statusCard, linkedCard(ui, invoice))),
|
|
758
1000
|
].map((part) => {
|
|
759
1001
|
// Any edit on the page shows Save changes; undoing it hides it again.
|
|
760
1002
|
for (const kind of ['input', 'change', 'click']) part?.addEventListener?.(kind, () => setTimeout(watchChanges));
|
|
@@ -813,12 +1055,14 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
813
1055
|
};
|
|
814
1056
|
const organisationId = context.organisations?.[0]?.id;
|
|
815
1057
|
const xeroHost = h('div');
|
|
1058
|
+
const linkedHost = h('div', {}, linkedCard(ui, invoice) ?? '');
|
|
816
1059
|
let xeroStatus = null;
|
|
817
1060
|
const send = invoice.status === 'issued' && context.canIssue ? sendToXero(invoice, ui, context) : null;
|
|
818
1061
|
const loadXero = async () => {
|
|
819
1062
|
if (!context.xero) return;
|
|
820
1063
|
xeroStatus = (await xeroStatuses(ui, organisationId, [invoice.id])).get(invoice.id) ?? null;
|
|
821
1064
|
xeroHost.replaceChildren(xeroCard(invoice, ui, context, xeroStatus, loadXero) ?? '');
|
|
1065
|
+
linkedHost.replaceChildren(linkedCard(ui, invoice, xeroStatus) ?? '');
|
|
822
1066
|
if (xeroStatus && xeroStatus.state !== 'not_sent') showSent(send);
|
|
823
1067
|
};
|
|
824
1068
|
loadXero();
|
|
@@ -826,7 +1070,7 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
826
1070
|
const voidBody = () => {
|
|
827
1071
|
const live = xeroStatus?.xero && !['VOIDED', 'DELETED'].includes(xeroStatus.xero.status);
|
|
828
1072
|
return h('div', {},
|
|
829
|
-
h('p', {}, 'The number stays used. A void invoice cannot be
|
|
1073
|
+
h('p', {}, 'The number stays used. A void invoice cannot be finalised again.'),
|
|
830
1074
|
live ? h('p', { class: 'fx-problem' }, `Xero still holds it as ${xeroStatus.label.replace(/ in Xero$/, '').toLowerCase()}${xeroStatus.xero.number ? ` (${xeroStatus.xero.number})` : ''}. Flow does not change Xero: void it in Xero too.`) : null);
|
|
831
1075
|
};
|
|
832
1076
|
const actions = invoice.status === 'issued' && context.canIssue ? [
|
|
@@ -843,7 +1087,7 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
843
1087
|
h('div', { class: 'fx-row' },
|
|
844
1088
|
h('h1', { class: 'fx-title' }, invoice.number),
|
|
845
1089
|
h('span', { class: 'fx-status', 'data-tone': STATUS_TONE[invoice.status] }, STATUS_LABEL[invoice.status])),
|
|
846
|
-
h('p', { class: 'fx-ph__sub' }, `${invoice.contact?.name ?? ''} ·
|
|
1090
|
+
h('p', { class: 'fx-ph__sub' }, `${invoice.contact?.name ?? ''} · finalised ${showDate(invoice.issueDate)}`)),
|
|
847
1091
|
h('div', { class: 'fx-ph__actions' }, actions, previewButton(ui, invoice.id), send)),
|
|
848
1092
|
h('div', { class: 'fx-invoice__grid' },
|
|
849
1093
|
h('div', { class: 'fx-invoice__main' },
|
|
@@ -851,7 +1095,7 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
851
1095
|
h('dl', { class: 'fx-doc__meta' },
|
|
852
1096
|
h('div', {}, h('dt', { class: 'fx-label' }, 'Bill to'), h('dd', {}, invoice.contact?.name ?? '—', invoice.contact?.email ? h('span', { class: 'fx-dim' }, invoice.contact.email) : null)),
|
|
853
1097
|
h('div', {}, h('dt', { class: 'fx-label' }, 'Reference'), h('dd', {}, invoice.reference ?? '—')),
|
|
854
|
-
h('div', {}, h('dt', { class: 'fx-label' }, '
|
|
1098
|
+
h('div', {}, h('dt', { class: 'fx-label' }, 'Finalised'), h('dd', {}, showDate(invoice.issueDate))),
|
|
855
1099
|
h('div', {}, h('dt', { class: 'fx-label' }, 'Due'), h('dd', {}, showDate(invoice.dueDate)))),
|
|
856
1100
|
h('table', { class: 'fx-lines__table fx-lines__table--read' },
|
|
857
1101
|
h('thead', {}, h('tr', {}, h('th', {}, 'Description'), h('th', { class: 'fx-num' }, 'Qty'), h('th', { class: 'fx-num' }, 'Unit price'), h('th', {}, 'Tax'), h('th', { class: 'fx-num' }, 'Amount'))),
|
|
@@ -863,12 +1107,12 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
863
1107
|
h('td', { class: 'fx-num fx-strong' }, formatCents(line.lineAmountCents)))))),
|
|
864
1108
|
h('div', { class: 'fx-lines__foot' }, h('span'), totalsBox(ui, invoice)))),
|
|
865
1109
|
h('aside', { class: 'fx-invoice__aside' },
|
|
1110
|
+
linkedHost,
|
|
866
1111
|
xeroHost,
|
|
867
|
-
jobCard(ui, invoice.job),
|
|
868
1112
|
invoice.versions?.length ? h('section', { class: 'fx-card fx-side-card' },
|
|
869
1113
|
h('h3', { class: 'fx-label' }, 'History'),
|
|
870
1114
|
h('ol', { class: 'fx-timeline' }, invoice.versions.map((v) => h('li', {},
|
|
871
|
-
h('span', { class: 'fx-strong' }, v.version === 1 ? '
|
|
1115
|
+
h('span', { class: 'fx-strong' }, v.version === 1 ? 'Finalised' : `Revised (version ${v.version})`),
|
|
872
1116
|
h('span', { class: 'fx-dim' }, new Date(v.issuedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })))))) : null)),
|
|
873
1117
|
];
|
|
874
1118
|
}
|
|
@@ -878,7 +1122,9 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
878
1122
|
// never writes to Xero: a person fixes the difference where they choose.
|
|
879
1123
|
function xeroCard(invoice, ui, context, status, reload) {
|
|
880
1124
|
const { h, flow, toast } = ui;
|
|
881
|
-
|
|
1125
|
+
// The Linked card shows Xero's number and state; this card shows only a
|
|
1126
|
+
// difference to resolve.
|
|
1127
|
+
if (!status?.finding) return null;
|
|
882
1128
|
const rows = status.differences.map((d) => h('tr', {},
|
|
883
1129
|
h('td', {}, FIELD_LABEL[d.field] ?? d.field),
|
|
884
1130
|
h('td', { class: 'fx-num' }, showField(d.field, d.flow)),
|