@arcforgelabs/flow 2026.10.32-beta.1 → 2026.10.32
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/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/invoicing.js +16 -5
- package/server/core/automations.js +17 -3
- package/server/core/invoices.js +2 -2
- package/server/core/invoicing-rules.js +14 -1
- package/server/core/ledger.js +3 -2
- package/server/core/pipeline.js +1 -1
- package/server/core/references.js +99 -0
- package/server/core/sending.js +11 -5
- package/server/db/migrations/0016_invoice_reference.sql +1 -0
- package/server/db/migrations/meta/0016_snapshot.json +3861 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +2 -0
- package/server/integrations/port.js +1 -0
- package/server/integrations/seazona/feed.js +8 -1
- package/server/integrations/seazona/index.js +9 -0
- package/server/integrations/seazona/lanes.js +1 -1
- package/server/integrations/seazona/patients.js +30 -0
- package/server/integrations/xero/client.js +12 -0
- package/server/integrations/xero/import.js +9 -3
- package/server/integrations/xero/index.js +3 -1
- package/server/integrations/xero/migrations/0007_xero_webhooks.sql +30 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/mirror.js +16 -8
- package/server/integrations/xero/routes.js +20 -0
- package/server/integrations/xero/settings.js +3 -0
- package/server/integrations/xero/webhooks.js +311 -0
- package/server/ui/app.js +15 -7
- package/server/ui/flow.css +47 -7
- package/server/ui/invoice-notes.js +26 -0
- package/server/ui/invoicing-settings.js +3 -1
- package/server/ui/invoicing.js +136 -57
- package/server/ui/routes.js +1 -1
package/server/ui/invoicing.js
CHANGED
|
@@ -6,12 +6,13 @@
|
|
|
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 { draftNote, sendModeNote } from '/assets/ui/invoice-notes.js';
|
|
9
10
|
|
|
10
11
|
const STATUS_LABEL = { draft: 'Draft', issued: 'Issued', void: 'Void' };
|
|
11
12
|
const STATUS_TONE = { draft: 'info', issued: 'success', void: 'mute' };
|
|
12
13
|
// Drafts (to check), Problem and Ready are views (server/core/sending.js):
|
|
13
|
-
// every draft is in one of them. A person marks a checked draft ready;
|
|
14
|
-
//
|
|
14
|
+
// every draft is in one of them. A person marks a checked draft ready; a
|
|
15
|
+
// saved change keeps it there. An issued invoice that failed to send is a
|
|
15
16
|
// problem.
|
|
16
17
|
// Ready first: it opens by default, as the drafts most often wait there to send.
|
|
17
18
|
const TABS = [['ready', 'Ready'], ['to_check', 'Drafts'], ['problem', 'Problem'], ['issued', 'Issued'], ['void', 'Void']];
|
|
@@ -75,29 +76,18 @@ function localDate(date = new Date()) {
|
|
|
75
76
|
|
|
76
77
|
const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) : '—');
|
|
77
78
|
|
|
78
|
-
// What happens to a ready invoice in the accounting system, one fact per
|
|
79
|
-
// sentence. It follows the integration's settings: its connection, whether it
|
|
80
|
-
// takes writes, and the send mode on its page.
|
|
81
|
-
export function sendModeNote(to, sendMode) {
|
|
82
|
-
if (!to) return 'No accounting system is on. Flow keeps each invoice here only.';
|
|
83
|
-
if (sendMode && !sendMode.connected) return `${to} is not connected. Connect it in Integrations before you send.`;
|
|
84
|
-
if (sendMode && !sendMode.writesAllowed) return `${to} does not take invoices from Flow yet. Turn this on in Integrations, ${to}.`;
|
|
85
|
-
const text = sendMode?.mode === 'approve_and_email'
|
|
86
|
-
? [`Flow sends each invoice to ${to} as approved.`, `${to} emails it to the customer at once.`]
|
|
87
|
-
: [`Flow sends each invoice to ${to} as a draft.`, `You approve and send it in ${to}.`];
|
|
88
|
-
const others = sendMode?.otherCustomers ?? 0;
|
|
89
|
-
if (others) text.push(`${others} ${others === 1 ? 'customer has' : 'customers have'} a different setting.`);
|
|
90
|
-
return text.join(' ');
|
|
91
|
-
}
|
|
92
|
-
|
|
93
79
|
// ── The list ───────────────────────────────────────────────────────────────
|
|
94
80
|
|
|
95
81
|
export async function renderInvoiceList(main, ui) {
|
|
96
82
|
const { h, flow, pageHeader, notice, go, toast } = ui;
|
|
97
83
|
const saved = sessionStorage.getItem('flow_invoice_tab');
|
|
98
|
-
|
|
84
|
+
// Back from an invoice comes here with the tab, search and scroll it left.
|
|
85
|
+
const back = readNav();
|
|
86
|
+
const returning = sessionStorage.getItem(NAV_BACK) === '1';
|
|
87
|
+
sessionStorage.removeItem(NAV_BACK);
|
|
88
|
+
const state = { status: TABS.some(([id]) => id === saved) ? saved : 'ready', q: returning ? back?.q ?? '' : '', ticked: new Set() };
|
|
99
89
|
const tabs = h('div', { class: 'fx-tabs', role: 'tablist' });
|
|
100
|
-
const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a number, client or reference', 'aria-label': 'Find an invoice' });
|
|
90
|
+
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 });
|
|
101
91
|
const bar = h('div', { class: 'fx-sendbar', hidden: true });
|
|
102
92
|
const host = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
|
|
103
93
|
const context = await flow('/api/invoicing/context').catch(() => ({ canIssue: false }));
|
|
@@ -156,7 +146,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
156
146
|
parts.push(h('p', { class: 'fx-dim' }, sendModeNote(to, body.sendMode)));
|
|
157
147
|
const buttons = [];
|
|
158
148
|
if (ticked.length) {
|
|
159
|
-
buttons.push(action(`
|
|
149
|
+
buttons.push(action(`Move ${ticked.length} to Drafts`, 'mark-not-ready', () => ({ invoiceIds: ticked }),
|
|
160
150
|
(res) => [`${plural(res.moved, 'invoice', 'invoices')} back in Drafts.`, 'success'], 'ghost'));
|
|
161
151
|
}
|
|
162
152
|
if (context.canIssue && body.counts.ready > 0) {
|
|
@@ -199,6 +189,9 @@ export async function renderInvoiceList(main, ui) {
|
|
|
199
189
|
: EMPTY[state.status] ?? `No ${STATUS_LABEL[state.status].toLowerCase()} invoices yet.`));
|
|
200
190
|
return;
|
|
201
191
|
}
|
|
192
|
+
// The invoice page steps through this list, in this order (navBar).
|
|
193
|
+
saveNav({ tab: state.status, q: state.q, ids: body.invoices.map((i) => i.id) });
|
|
194
|
+
const open = (href) => { saveNav({ ...readNav(), scrollY: window.scrollY }); go(href); };
|
|
202
195
|
const ticks = TICKS[state.status];
|
|
203
196
|
const showProblem = state.status === 'problem';
|
|
204
197
|
const created = ['to_check', 'problem', 'ready'].includes(state.status);
|
|
@@ -231,13 +224,13 @@ export async function renderInvoiceList(main, ui) {
|
|
|
231
224
|
const href = `/invoices/${invoice.id}`;
|
|
232
225
|
const first = invoice.problems?.[0];
|
|
233
226
|
const view = invoice.status === 'void' ? null : invoice.view;
|
|
234
|
-
return h('tr', { tabindex: '0', onclick: () =>
|
|
227
|
+
return h('tr', { tabindex: '0', onclick: () => open(href), onkeydown: (event) => { if (event.key === 'Enter') open(href); } },
|
|
235
228
|
ticks ? h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, tickBox(invoice)) : null,
|
|
236
|
-
h('td', {}, h('a', { class: 'fx-strong', href, onclick:
|
|
229
|
+
h('td', {}, h('a', { class: 'fx-strong', href, onclick: (event) => { event.preventDefault(); event.stopPropagation(); open(href); } }, invoice.number ?? 'Draft')),
|
|
237
230
|
h('td', {}, invoice.contactName ?? '—'),
|
|
238
231
|
showProblem
|
|
239
232
|
? h('td', { class: 'fx-problem' }, first?.message ?? '—', invoice.problems.length > 1 ? h('span', { class: 'fx-dim' }, ` +${invoice.problems.length - 1} more`) : null)
|
|
240
|
-
: h('td', { class: 'fx-dim' }, invoice.
|
|
233
|
+
: h('td', { class: 'fx-dim' }, invoice.reference ?? '—'),
|
|
241
234
|
h('td', { class: 'fx-dim' }, showDate(invoice.issueDate ?? invoice.createdAt?.slice(0, 10))),
|
|
242
235
|
h('td', { class: 'fx-num fx-strong' }, formatCents(invoice.totalCents)),
|
|
243
236
|
(() => {
|
|
@@ -259,6 +252,51 @@ export async function renderInvoiceList(main, ui) {
|
|
|
259
252
|
}
|
|
260
253
|
}
|
|
261
254
|
await load();
|
|
255
|
+
if (returning && back?.scrollY) window.scrollTo(0, back.scrollY);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// The list an invoice was opened from: its tab, search, invoice ids in order,
|
|
259
|
+
// and scroll. The invoice page's bar steps through it and goes back to it.
|
|
260
|
+
const NAV = 'flow_invoice_nav';
|
|
261
|
+
const NAV_BACK = 'flow_invoice_back';
|
|
262
|
+
function readNav() {
|
|
263
|
+
try { return JSON.parse(sessionStorage.getItem(NAV) ?? 'null'); } catch { return null; }
|
|
264
|
+
}
|
|
265
|
+
function saveNav(nav) {
|
|
266
|
+
try { sessionStorage.setItem(NAV, JSON.stringify(nav)); } catch { /* the bar then shows Back only */ }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// The bar above one invoice: Back to the list as it was, and the previous and
|
|
270
|
+
// next invoice of that list.
|
|
271
|
+
function navBar(ui, id) {
|
|
272
|
+
const { h, go } = ui;
|
|
273
|
+
const nav = readNav();
|
|
274
|
+
const ids = nav?.ids ?? [];
|
|
275
|
+
const at = ids.indexOf(id);
|
|
276
|
+
const back = () => { sessionStorage.setItem(NAV_BACK, '1'); go('/invoices'); };
|
|
277
|
+
const step = (to, label, path) => {
|
|
278
|
+
const button = h('button', {
|
|
279
|
+
class: 'fx-pager__step', type: 'button', disabled: !to, 'aria-label': label, title: label,
|
|
280
|
+
onclick: () => go(`/invoices/${to}`),
|
|
281
|
+
});
|
|
282
|
+
button.innerHTML = `<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="${path}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
283
|
+
return button;
|
|
284
|
+
};
|
|
285
|
+
return h('nav', { class: 'fx-pager', 'aria-label': 'Invoices' },
|
|
286
|
+
h('button', { class: 'fx-pager__back', type: 'button', onclick: back }, '← Back'),
|
|
287
|
+
at >= 0 ? h('span', { class: 'fx-pager__count' }, `${at + 1} of ${ids.length}`) : null,
|
|
288
|
+
at >= 0 ? h('span', { class: 'fx-pager__steps' }, step(ids[at - 1], 'Previous invoice', 'M14.5 6l-6 6 6 6'), step(ids[at + 1], 'Next invoice', 'M9.5 6l6 6-6 6')) : null);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// After a send, the next invoice of the list opens; at its end, the list.
|
|
292
|
+
function openNext(ui, id) {
|
|
293
|
+
const nav = readNav();
|
|
294
|
+
const ids = nav?.ids ?? [];
|
|
295
|
+
const at = ids.indexOf(id);
|
|
296
|
+
const next = at >= 0 ? (ids[at + 1] ?? ids[at - 1]) : null;
|
|
297
|
+
if (at >= 0) saveNav({ ...nav, ids: ids.filter((other) => other !== id) });
|
|
298
|
+
if (next) ui.go(`/invoices/${next}`);
|
|
299
|
+
else { sessionStorage.setItem(NAV_BACK, '1'); ui.go('/invoices'); }
|
|
262
300
|
}
|
|
263
301
|
|
|
264
302
|
// ── One invoice ────────────────────────────────────────────────────────────
|
|
@@ -285,17 +323,11 @@ export async function renderInvoice(main, ui, id) {
|
|
|
285
323
|
const draw = (current) => {
|
|
286
324
|
host.replaceChildren(...(current?.status && current.status !== 'draft'
|
|
287
325
|
? issuedView(current, ui, context, draw)
|
|
288
|
-
: draftView(current, ui, context, draw)));
|
|
326
|
+
: draftView(current, ui, context, draw)).filter(Boolean));
|
|
289
327
|
};
|
|
290
328
|
draw(invoice);
|
|
291
329
|
}
|
|
292
330
|
|
|
293
|
-
function crumbs(ui, label) {
|
|
294
|
-
const { h, go } = ui;
|
|
295
|
-
return h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
|
|
296
|
-
h('a', { href: '/invoices', onclick: go }, 'Invoices'), h('span', { 'aria-hidden': 'true' }, '/'), h('span', {}, label));
|
|
297
|
-
}
|
|
298
|
-
|
|
299
331
|
function totalsBox(ui, { subtotalCents, taxCents, totalCents }) {
|
|
300
332
|
const { h } = ui;
|
|
301
333
|
return h('dl', { class: 'fx-totals' },
|
|
@@ -304,13 +336,13 @@ function totalsBox(ui, { subtotalCents, taxCents, totalCents }) {
|
|
|
304
336
|
h('dt', { class: 'fx-totals__grand' }, 'Total'), h('dd', { class: 'fx-totals__grand' }, formatCents(totalCents)));
|
|
305
337
|
}
|
|
306
338
|
|
|
339
|
+
// The job an invoice bills. The case id stays on the record, not on screen.
|
|
307
340
|
function jobCard(ui, job) {
|
|
308
341
|
const { h } = ui;
|
|
309
342
|
if (!job) return null;
|
|
310
343
|
return h('section', { class: 'fx-card fx-side-card' },
|
|
311
344
|
h('h3', { class: 'fx-label' }, 'Job'),
|
|
312
345
|
h('dl', { class: 'fx-facts' },
|
|
313
|
-
h('dt', {}, 'Case'), h('dd', {}, job.caseRef ?? job.sourceJobRef),
|
|
314
346
|
job.practitioner ? [h('dt', {}, 'Practitioner'), h('dd', {}, job.practitioner)] : null,
|
|
315
347
|
job.shippedOn ? [h('dt', {}, 'Shipped'), h('dd', {}, showDate(job.shippedOn))] : null,
|
|
316
348
|
h('dt', {}, 'From'), h('dd', {}, job.sourceSystem === 'seazona' ? 'Seazona' : job.sourceSystem)));
|
|
@@ -471,24 +503,56 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
471
503
|
}
|
|
472
504
|
};
|
|
473
505
|
|
|
506
|
+
// Save shows only when there is something to save: a new invoice, or a
|
|
507
|
+
// change since the page opened. Saving keeps the invoice where it is.
|
|
474
508
|
const saveButton = h('button', {
|
|
475
|
-
class: 'fx-btn', type: 'button',
|
|
509
|
+
class: 'fx-btn', type: 'button', hidden: !isNew,
|
|
476
510
|
onclick: async () => {
|
|
477
511
|
const saved = await save();
|
|
478
512
|
if (!saved) return;
|
|
479
|
-
toast('Draft saved.', 'success');
|
|
513
|
+
toast(isNew ? 'Draft saved.' : 'Changes saved.', 'success');
|
|
480
514
|
if (isNew) go(`/invoices/${saved.id}`, { replace: true });
|
|
481
515
|
else redraw(saved);
|
|
482
516
|
},
|
|
483
|
-
}, 'Save draft');
|
|
484
|
-
const
|
|
517
|
+
}, isNew ? 'Save draft' : 'Save changes');
|
|
518
|
+
const opened = JSON.stringify(body());
|
|
519
|
+
const changed = () => !isNew && JSON.stringify(body()) !== opened;
|
|
520
|
+
const watchChanges = () => { if (!isNew) saveButton.hidden = !changed(); };
|
|
521
|
+
// With an accounting system on, a ready invoice is sent there: Flow issues
|
|
522
|
+
// it and queues it, the same as Send all ready. With none, Flow issues it
|
|
523
|
+
// and keeps it. A draft that is not ready has no send: mark it ready first.
|
|
524
|
+
const to = invoice?.sendsTo ?? null;
|
|
525
|
+
const ready = invoice?.view === 'ready';
|
|
526
|
+
const nextNumber = `${context.organisations[0]?.invoicePrefix ?? ''}${String(context.organisations[0]?.nextInvoiceNumber ?? '').padStart(4, '0')}`;
|
|
527
|
+
const emails = invoice?.sendMode?.mode === 'approve_and_email';
|
|
528
|
+
const sendButton = context.canIssue && to && ready ? h('button', {
|
|
529
|
+
class: 'fx-btn fx-btn--primary', type: 'button',
|
|
530
|
+
onclick: async () => {
|
|
531
|
+
if (changed() && !(await save())) return;
|
|
532
|
+
const ok = await ui.dialog({
|
|
533
|
+
title: `Send this invoice to ${to}?`,
|
|
534
|
+
body: h('p', {}, `It gets the next number (${nextNumber}). ${sendModeNote(to, invoice.sendMode, 'it')}`),
|
|
535
|
+
confirm: emails ? 'Send and email' : 'Send',
|
|
536
|
+
});
|
|
537
|
+
if (!ok) return;
|
|
538
|
+
try {
|
|
539
|
+
await flow('/api/invoicing/invoices/send-ready', { method: 'POST', body: JSON.stringify({ issueDate: localDate(), invoiceIds: [invoice.id] }) });
|
|
540
|
+
const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
|
|
541
|
+
toast(`${fresh.number} is on the way to ${to}.`, 'success');
|
|
542
|
+
openNext(ui, invoice.id);
|
|
543
|
+
} catch (error) {
|
|
544
|
+
toast(error.message, 'danger');
|
|
545
|
+
}
|
|
546
|
+
},
|
|
547
|
+
}, emails ? `Send to ${to} and email` : `Send to ${to}`) : null;
|
|
548
|
+
const issueButton = context.canIssue && !to ? h('button', {
|
|
485
549
|
class: 'fx-btn fx-btn--primary', type: 'button',
|
|
486
550
|
onclick: async () => {
|
|
487
551
|
const saved = await save();
|
|
488
552
|
if (!saved) return;
|
|
489
553
|
const ok = await ui.dialog({
|
|
490
554
|
title: 'Issue this invoice?',
|
|
491
|
-
body: h('p', {}, `It gets the next number (${
|
|
555
|
+
body: h('p', {}, `It gets the next number (${nextNumber}). Flow keeps it exactly as issued. To change it later, revise it.`),
|
|
492
556
|
confirm: 'Issue',
|
|
493
557
|
});
|
|
494
558
|
if (!ok) { if (isNew) go(`/invoices/${saved.id}`, { replace: true }); else redraw(saved); return; }
|
|
@@ -504,40 +568,51 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
504
568
|
}, 'Issue invoice') : null;
|
|
505
569
|
|
|
506
570
|
// Mark ready: the draft is saved, then Flow checks it; one with a problem
|
|
507
|
-
// stays a draft.
|
|
508
|
-
const
|
|
509
|
-
const readyButton = isNew ? null : h('button', {
|
|
571
|
+
// stays a draft. Move to Drafts, on the Status card, undoes it.
|
|
572
|
+
const markReadyButton = isNew || ready ? null : h('button', {
|
|
510
573
|
class: 'fx-btn', type: 'button',
|
|
511
574
|
onclick: async () => {
|
|
512
|
-
|
|
513
|
-
if (!current) return;
|
|
575
|
+
if (!(await save())) return;
|
|
514
576
|
try {
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
} else {
|
|
519
|
-
const res = await flow('/api/invoicing/invoices/mark-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
|
|
520
|
-
if (res.marked) toast('Ready to send.', 'success');
|
|
521
|
-
else toast(res.refused[0]?.problems.map((p) => p.message).join(' ') ?? 'It has a problem.', 'danger');
|
|
522
|
-
}
|
|
577
|
+
const res = await flow('/api/invoicing/invoices/mark-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
|
|
578
|
+
if (res.marked) toast('Ready to send.', 'success');
|
|
579
|
+
else toast(res.refused[0]?.problems.map((p) => p.message).join(' ') ?? 'It has a problem.', 'danger');
|
|
523
580
|
const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
|
|
524
581
|
redraw(fresh);
|
|
525
582
|
} catch (error) {
|
|
526
583
|
toast(error.message, 'danger');
|
|
527
584
|
}
|
|
528
585
|
},
|
|
529
|
-
},
|
|
586
|
+
}, 'Mark ready');
|
|
587
|
+
// A ready invoice can go back to Drafts. It is a status change, so it sits
|
|
588
|
+
// with the status, not with the page's main buttons.
|
|
589
|
+
const statusCard = ready ? h('section', { class: 'fx-card fx-side-card' },
|
|
590
|
+
h('h3', { class: 'fx-label' }, 'Status'),
|
|
591
|
+
h('p', {}, 'Ready to send.'),
|
|
592
|
+
h('button', {
|
|
593
|
+
class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button',
|
|
594
|
+
onclick: async () => {
|
|
595
|
+
try {
|
|
596
|
+
await flow('/api/invoicing/invoices/mark-not-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
|
|
597
|
+
toast('Moved to Drafts.', 'success');
|
|
598
|
+
const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
|
|
599
|
+
redraw(fresh);
|
|
600
|
+
} catch (error) {
|
|
601
|
+
toast(error.message, 'danger');
|
|
602
|
+
}
|
|
603
|
+
},
|
|
604
|
+
}, 'Move to Drafts')) : null;
|
|
530
605
|
const problems = invoice?.problems?.length
|
|
531
606
|
? notice(invoice.problems.map((p) => p.message).join(' '), 'danger', 'Problem.')
|
|
532
607
|
: null;
|
|
533
608
|
|
|
534
609
|
return [
|
|
535
|
-
|
|
610
|
+
navBar(ui, invoice?.id ?? null),
|
|
536
611
|
h('header', { class: 'fx-ph' },
|
|
537
612
|
h('div', { class: 'fx-ph__text' },
|
|
538
613
|
h('h1', { class: 'fx-title' }, isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Draft invoice'),
|
|
539
|
-
h('p', { class: 'fx-ph__sub' },
|
|
540
|
-
h('div', { class: 'fx-ph__actions' }, saveButton,
|
|
614
|
+
h('p', { class: 'fx-ph__sub' }, draftNote(to, invoice, ready))),
|
|
615
|
+
h('div', { class: 'fx-ph__actions' }, saveButton, markReadyButton, sendButton ?? issueButton)),
|
|
541
616
|
problems,
|
|
542
617
|
h('div', { class: 'fx-invoice__grid' },
|
|
543
618
|
h('div', { class: 'fx-invoice__main' },
|
|
@@ -556,8 +631,12 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
556
631
|
h('div', { class: 'fx-lines__foot' },
|
|
557
632
|
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'),
|
|
558
633
|
totalsHost))),
|
|
559
|
-
h('aside', { class: 'fx-invoice__aside' }, jobCard(ui, invoice?.job))),
|
|
560
|
-
]
|
|
634
|
+
h('aside', { class: 'fx-invoice__aside' }, statusCard, jobCard(ui, invoice?.job))),
|
|
635
|
+
].map((part) => {
|
|
636
|
+
// Any edit on the page shows Save changes; undoing it hides it again.
|
|
637
|
+
for (const kind of ['input', 'change', 'click']) part?.addEventListener?.(kind, () => setTimeout(watchChanges));
|
|
638
|
+
return part;
|
|
639
|
+
});
|
|
561
640
|
}
|
|
562
641
|
|
|
563
642
|
// An issued or void invoice: the document as sent, and what can happen next.
|
|
@@ -601,7 +680,7 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
601
680
|
sendToXero(invoice, ui, context),
|
|
602
681
|
] : [];
|
|
603
682
|
return [
|
|
604
|
-
|
|
683
|
+
navBar(ui, invoice.id),
|
|
605
684
|
h('header', { class: 'fx-ph' },
|
|
606
685
|
h('div', { class: 'fx-ph__text' },
|
|
607
686
|
h('div', { class: 'fx-row' },
|
|
@@ -674,7 +753,7 @@ function xeroCard(invoice, ui, context, status, reload) {
|
|
|
674
753
|
}
|
|
675
754
|
|
|
676
755
|
// Send to Xero (POST /api/xero/invoices/push). The customer's send mode
|
|
677
|
-
// (Settings,
|
|
756
|
+
// (Settings, Invoice rules) decides what Xero gets: a draft that a person approves
|
|
678
757
|
// there, or an approved invoice that Xero emails to the customer.
|
|
679
758
|
function sendToXero(invoice, ui, context) {
|
|
680
759
|
const { h, flow, toast } = ui;
|
package/server/ui/routes.js
CHANGED
|
@@ -20,7 +20,7 @@ const themeDir = join(here, '..', 'theme');
|
|
|
20
20
|
const logoDir = join(here, 'logos');
|
|
21
21
|
const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
|
|
22
22
|
const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
|
|
23
|
-
const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'jobs.js', 'job-board.js', 'monitoring.js', 'search.js', 'alerts.js', 'flow-mark.svg']);
|
|
23
|
+
const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'jobs.js', 'job-board.js', 'monitoring.js', 'search.js', 'alerts.js', 'flow-mark.svg']);
|
|
24
24
|
const MAX_AGE = '1h';
|
|
25
25
|
|
|
26
26
|
// Send one named file from dir. Passing dir as root limits the dotfile check
|