@arcforgelabs/flow 2026.10.37 → 2026.10.39
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 +2 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +4 -2
- package/server/api/custom-fields.js +27 -9
- package/server/api/invoicing.js +198 -51
- package/server/app.js +5 -2
- package/server/core/alerts.js +1 -1
- package/server/core/automations.js +1 -1
- package/server/core/custom-fields.js +89 -3
- package/server/core/job-board.js +2 -1
- package/server/core/job-match.js +241 -0
- package/server/core/ledger.js +109 -8
- package/server/core/price-list.js +27 -2
- package/server/core/read-before-act.js +3 -2
- package/server/core/sending.js +36 -18
- package/server/db/doctor.js +18 -19
- package/server/db/index.js +74 -3
- package/server/db/migrate-all.js +3 -4
- package/server/db/migrations/0020_custom_field_colours.sql +21 -0
- package/server/db/migrations/meta/0020_snapshot.json +4377 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +14 -0
- package/server/integrations/call-guard.js +148 -0
- package/server/integrations/port.js +3 -2
- package/server/integrations/schema.js +9 -5
- package/server/integrations/seazona/feed.js +4 -1
- package/server/integrations/seazona/settings.js +8 -4
- package/server/integrations/xero/drafts.js +4 -4
- package/server/integrations/xero/fit.js +108 -0
- package/server/integrations/xero/import.js +6 -1
- package/server/integrations/xero/index.js +6 -2
- package/server/integrations/xero/ledger.js +3 -0
- package/server/integrations/xero/links.js +293 -0
- package/server/integrations/xero/migrations/0008_xero_invoice_links.sql +26 -0
- package/server/integrations/xero/migrations/0009_xero_sent_to_contact.sql +3 -0
- package/server/integrations/xero/migrations/meta/_journal.json +14 -0
- package/server/integrations/xero/mirror.js +4 -3
- package/server/integrations/xero/reconcile.js +42 -12
- package/server/integrations/xero/routes.js +66 -7
- package/server/integrations/xero/settings.js +6 -5
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +89 -3
- package/server/ui/bulk-bar.js +4 -3
- package/server/ui/custom-fields.js +252 -94
- package/server/ui/flow.css +326 -29
- package/server/ui/invoice-notes.js +2 -2
- package/server/ui/invoicing.js +92 -137
- package/server/ui/job-board.js +9 -7
- package/server/ui/job-change.js +23 -12
- package/server/ui/job-view.js +189 -31
- package/server/ui/jobs.js +738 -70
- package/server/ui/price-list.js +43 -86
- package/server/ui/price-match.js +93 -0
- package/server/ui/price-reconcile.js +357 -0
- package/server/ui/reconcile.js +275 -0
- package/server/ui/routes.js +1 -1
- package/server/ui/xero-import.js +329 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// Reconcile: Flow's jobs with no Flow invoice, and Flow invoices with no job.
|
|
2
|
+
// For one of them, the possible other side beside it, each fact marked same
|
|
3
|
+
// (✓) or different (≠), and a search for any other. A person links one, or
|
|
4
|
+
// for a job makes its invoice. Flow's own records only: matching a Flow
|
|
5
|
+
// invoice to an accounting system's is that integration's work. API:
|
|
6
|
+
// /api/invoicing/reconcile, /jobs/:id/reconcile, /invoices/:id/reconcile,
|
|
7
|
+
// /jobs/:id/link, /jobs/:id/no-invoice (docs/JOB_INVOICE_LINKS.md).
|
|
8
|
+
import { formatCents } from '/assets/ui/invoicing.js';
|
|
9
|
+
|
|
10
|
+
const STATUS = { draft: 'Draft', issued: 'Approved', void: 'Void' };
|
|
11
|
+
const fullDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) : '—');
|
|
12
|
+
const shortDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short' }) : '—');
|
|
13
|
+
const money = (cents) => (cents == null ? '—' : formatCents(cents));
|
|
14
|
+
const lineText = (l) => `${l.code ?? l.description ?? '—'} ${money(l.amountCents)}`;
|
|
15
|
+
|
|
16
|
+
// The words for how far one date is from another.
|
|
17
|
+
export function dayWords(days) {
|
|
18
|
+
if (days == null) return null;
|
|
19
|
+
if (days === 0) return 'same day';
|
|
20
|
+
const n = Math.abs(days);
|
|
21
|
+
return `${n} day${n === 1 ? '' : 's'} ${days > 0 ? 'later' : 'earlier'}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const statusWords = (status) => STATUS[status] ?? status ?? '—';
|
|
25
|
+
|
|
26
|
+
const dateMark = (c) => (c.date ? (Math.abs(c.date.days) <= 3 ? 'same' : 'differs') : null);
|
|
27
|
+
const othersMark = (n) => (n ? 'differs' : 'same');
|
|
28
|
+
|
|
29
|
+
// The facts of a job and of each candidate Flow invoice: { label, value, mark }.
|
|
30
|
+
export function jobFacts(job) {
|
|
31
|
+
return [
|
|
32
|
+
{ label: 'Client', value: job.clientName ?? '—' },
|
|
33
|
+
{ label: 'Reference', value: job.reference ?? '—' },
|
|
34
|
+
{ label: 'Date', value: fullDate(job.jobDate) },
|
|
35
|
+
{ label: 'Lines', value: job.lines.map(lineText) },
|
|
36
|
+
{ label: 'Total', value: money(job.totalCents) },
|
|
37
|
+
{ label: 'Other jobs', value: '—' },
|
|
38
|
+
{ label: 'Status', value: job.sourceStatus ?? '—' },
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function invoiceCandidateFacts(invoice) {
|
|
43
|
+
const c = invoice.checks;
|
|
44
|
+
return [
|
|
45
|
+
{ label: 'Client', value: c.client === 'same' ? 'Same' : invoice.clientName ?? '—', mark: c.client },
|
|
46
|
+
{ label: 'Reference', value: invoice.reference ?? '—', mark: c.reference },
|
|
47
|
+
{ label: 'Date', value: [shortDate(invoice.date), dayWords(c.date?.days)].filter(Boolean).join(' · '), mark: dateMark(c) },
|
|
48
|
+
{ label: 'Lines', value: invoice.lines.map(lineText), mark: c.lines },
|
|
49
|
+
{ label: 'Total', value: money(invoice.totalCents), mark: c.total },
|
|
50
|
+
{ label: 'Other jobs', value: c.others ? `Linked to ${c.others} other job${c.others === 1 ? '' : 's'}` : 'None', mark: othersMark(c.others) },
|
|
51
|
+
{ label: 'Status', value: statusWords(invoice.status), mark: null },
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The facts of a Flow invoice and of each candidate job.
|
|
56
|
+
function invoiceFacts(invoice) {
|
|
57
|
+
return [
|
|
58
|
+
{ label: 'Client', value: invoice.clientName ?? '—' },
|
|
59
|
+
{ label: 'Reference', value: invoice.reference ?? '—' },
|
|
60
|
+
{ label: 'Date', value: fullDate(invoice.date) },
|
|
61
|
+
{ label: 'Lines', value: invoice.lines.map(lineText) },
|
|
62
|
+
{ label: 'Total', value: money(invoice.totalCents) },
|
|
63
|
+
{ label: 'Status', value: statusWords(invoice.status) },
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function jobCandidateFacts(job) {
|
|
68
|
+
const c = job.checks;
|
|
69
|
+
return [
|
|
70
|
+
{ label: 'Client', value: c.client === 'same' ? 'Same' : job.clientName ?? '—', mark: c.client },
|
|
71
|
+
{ label: 'Reference', value: job.practitioner ?? '—', mark: null },
|
|
72
|
+
{ label: 'Date', value: [shortDate(job.jobDate), dayWords(c.date?.days)].filter(Boolean).join(' · '), mark: dateMark(c) },
|
|
73
|
+
{ label: 'Lines', value: job.lines.map(lineText), mark: c.lines },
|
|
74
|
+
{ label: 'Total', value: money(job.totalCents), mark: c.total },
|
|
75
|
+
{ label: 'Status', value: job.sourceStatus ?? (job.ready ? 'Ready to invoice' : 'In work'), mark: null },
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function renderReconcile(main, ui, id = null) {
|
|
80
|
+
const { h, flow, pageHeader } = ui;
|
|
81
|
+
const canAct = ['owner', 'admin'].includes(ui.me?.person?.role);
|
|
82
|
+
const tab = new URLSearchParams(location.search).get('tab') === 'invoices' ? 'invoices' : 'jobs';
|
|
83
|
+
const host = h('div', { class: `fx-rec${id ? ' fx-rec--open' : ''}` }, h('p', { class: 'fx-dim' }, 'Loading…'));
|
|
84
|
+
main.append(pageHeader('Reconcile', { subtitle: 'Flow jobs with no invoice, and Flow invoices with no job.' }), host);
|
|
85
|
+
|
|
86
|
+
let lists;
|
|
87
|
+
try {
|
|
88
|
+
lists = await flow('/api/invoicing/reconcile');
|
|
89
|
+
} catch (error) {
|
|
90
|
+
host.replaceChildren(h('p', { class: 'fx-empty' }, error.message));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const queue = tab === 'invoices' ? lists.invoices : lists.jobs;
|
|
94
|
+
const pathOf = (itemId, inTab = tab) => `/reconcile${itemId ? `/${itemId}` : ''}${inTab === 'invoices' ? '?tab=invoices' : ''}`;
|
|
95
|
+
const open = (itemId, event, inTab = tab) => {
|
|
96
|
+
if (event) event.preventDefault();
|
|
97
|
+
ui.go(pathOf(itemId, inTab));
|
|
98
|
+
};
|
|
99
|
+
const index = id ? queue.findIndex((j) => j.id === id) : -1;
|
|
100
|
+
const next = () => {
|
|
101
|
+
const rest = queue.filter((j) => j.id !== id);
|
|
102
|
+
open(rest[Math.max(0, index)]?.id ?? rest[0]?.id ?? null);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const tabs = h('div', { class: 'fx-rec__tabs', role: 'tablist' },
|
|
106
|
+
[['jobs', `Job, no invoice · ${lists.jobs.length}`], ['invoices', `Invoice, no job · ${lists.invoices.length}`]].map(([name, label]) => h('a', {
|
|
107
|
+
class: 'fx-rec__tab', role: 'tab', href: pathOf(null, name), 'aria-selected': String(name === tab), onclick: (event) => open(null, event, name),
|
|
108
|
+
}, label)));
|
|
109
|
+
const items = queue.map((item) => h('a', {
|
|
110
|
+
class: 'fx-rec__item', href: pathOf(item.id), 'aria-current': item.id === id ? 'true' : 'false', onclick: (event) => open(item.id, event),
|
|
111
|
+
},
|
|
112
|
+
h('span', { class: 'fx-rec__item-top' }, h('span', { class: 'fx-strong' }, tab === 'jobs' ? item.clientName ?? 'No client' : item.number ?? 'Draft'), h('span', { class: 'fx-num' }, money(item.totalCents))),
|
|
113
|
+
h('span', { class: 'fx-dim' }, (tab === 'jobs'
|
|
114
|
+
? [item.practitioner, shortDate(item.jobDate)]
|
|
115
|
+
: [item.clientName, item.issueDate ? shortDate(item.issueDate) : null, statusWords(item.status)]
|
|
116
|
+
).concat(item.possible ? `${item.possible} possible` : 'none found').filter(Boolean).join(' · '))));
|
|
117
|
+
const list = h('nav', { class: 'fx-rec__queue', 'aria-label': tab === 'jobs' ? 'Jobs with no invoice' : 'Invoices with no job' },
|
|
118
|
+
tabs, items.length ? items : h('p', { class: 'fx-empty' }, tab === 'jobs' ? 'Every ready job has an invoice.' : 'Every invoice has a job.'));
|
|
119
|
+
|
|
120
|
+
const detail = h('section', { class: 'fx-rec__detail', 'aria-label': 'Compare' });
|
|
121
|
+
host.replaceChildren(list, detail);
|
|
122
|
+
if (!id) {
|
|
123
|
+
detail.append(h('p', { class: 'fx-empty' }, queue.length ? 'Choose one.' : 'Nothing to reconcile here.'));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const kind = tab === 'jobs' ? 'jobs' : 'invoices';
|
|
128
|
+
const search = h('input', {
|
|
129
|
+
class: 'fx-input fx-rec__search', type: 'search', autocomplete: 'off',
|
|
130
|
+
placeholder: tab === 'jobs' ? 'Search Flow invoices: number, client, Reference, amount' : 'Search jobs: client, practitioner, case',
|
|
131
|
+
'aria-label': tab === 'jobs' ? 'Search Flow invoices' : 'Search jobs',
|
|
132
|
+
});
|
|
133
|
+
const head = h('div', { class: 'fx-rec__head' });
|
|
134
|
+
const results = h('div', { class: 'fx-rec__results' }, h('p', { class: 'fx-dim' }, 'Loading…'));
|
|
135
|
+
const foot = h('div', { class: 'fx-rec__foot' });
|
|
136
|
+
detail.replaceChildren(head, search, results, foot);
|
|
137
|
+
|
|
138
|
+
let timer = null;
|
|
139
|
+
search.addEventListener('input', () => {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
timer = setTimeout(() => load(search.value), 300);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
async function act(run, done) {
|
|
145
|
+
for (const b of detail.querySelectorAll('button')) b.disabled = true;
|
|
146
|
+
try {
|
|
147
|
+
await run();
|
|
148
|
+
ui.toast(done, 'success');
|
|
149
|
+
next();
|
|
150
|
+
} catch (error) {
|
|
151
|
+
const reasons = error.details?.reasons;
|
|
152
|
+
ui.toast(reasons?.length ? `Not done: ${reasons.join('; ')}.` : error.message, 'danger');
|
|
153
|
+
for (const b of detail.querySelectorAll('button')) b.disabled = false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const markOf = (mark) => (mark === 'same' ? h('span', { class: 'fx-rec__mark', 'data-mark': 'same', 'aria-label': 'Same' }, '✓')
|
|
157
|
+
: mark === 'differs' ? h('span', { class: 'fx-rec__mark', 'data-mark': 'differs', 'aria-label': 'Different' }, '≠') : null);
|
|
158
|
+
const valueOf = (fact) => (Array.isArray(fact.value)
|
|
159
|
+
? h('span', { class: 'fx-rec__lines' }, fact.value.length ? fact.value.map((v) => h('span', {}, v)) : '—')
|
|
160
|
+
: h('span', {}, fact.value));
|
|
161
|
+
const inFlow = (href, label) => h('a', { class: 'fx-task__link', href, onclick: (event) => ui.go(event) }, label);
|
|
162
|
+
|
|
163
|
+
// Wide: one table, the record and each candidate a column. Narrow: cards.
|
|
164
|
+
// A candidate's `action` and `link` make a new node for each of the two.
|
|
165
|
+
function compareView({ ours, oursTitle, oursSub, candidates }) {
|
|
166
|
+
const table = h('div', { class: 'fx-rec__table', role: 'table', style: `--cols: ${candidates.length}` },
|
|
167
|
+
h('div', { class: 'fx-rec__row', role: 'row' },
|
|
168
|
+
h('span', { role: 'columnheader' }),
|
|
169
|
+
h('span', { role: 'columnheader', class: 'fx-rec__job' }, h('span', { class: 'fx-strong' }, oursTitle), h('span', { class: 'fx-dim' }, oursSub)),
|
|
170
|
+
candidates.map((c) => h('span', { role: 'columnheader' },
|
|
171
|
+
h('span', { class: 'fx-strong' }, c.title), c.best ? h('span', { class: 'fx-rec__best' }, 'Best match') : h('span', { class: 'fx-dim' }, c.sub)))),
|
|
172
|
+
ours.map((fact, row) => h('div', { class: 'fx-rec__row', role: 'row' },
|
|
173
|
+
h('span', { role: 'rowheader', class: 'fx-dim' }, fact.label),
|
|
174
|
+
h('span', { role: 'cell', class: 'fx-rec__job' }, valueOf(fact)),
|
|
175
|
+
candidates.map((c) => h('span', { role: 'cell', 'data-mark': c.facts[row].mark ?? '' }, markOf(c.facts[row].mark), valueOf(c.facts[row]))))),
|
|
176
|
+
h('div', { class: 'fx-rec__row', role: 'row' },
|
|
177
|
+
h('span', { role: 'cell' }), h('span', { role: 'cell', class: 'fx-rec__job' }),
|
|
178
|
+
candidates.map((c) => h('span', { role: 'cell', class: 'fx-rec__acts' }, c.action?.(), c.link?.()))));
|
|
179
|
+
const cards = h('div', { class: 'fx-rec__cards' },
|
|
180
|
+
h('div', { class: 'fx-rec__card fx-rec__job' },
|
|
181
|
+
h('span', { class: 'fx-rec__label' }, `${oursTitle} · ${oursSub}`),
|
|
182
|
+
ours.filter((f) => f.label !== 'Client' && f.value !== '—').map((f) => h('span', {}, Array.isArray(f.value) ? f.value.join(', ') : f.value))),
|
|
183
|
+
candidates.map((c) => h('div', { class: `fx-rec__card${c.best ? ' fx-rec__card--best' : ''}` },
|
|
184
|
+
h('span', { class: 'fx-rec__card-top' }, h('span', { class: 'fx-strong' }, c.title), c.best ? h('span', { class: 'fx-rec__best' }, 'Best match') : null),
|
|
185
|
+
h('span', { class: 'fx-rec__checks' }, c.facts.filter((f) => f.mark).map((f) => h('span', { 'data-mark': f.mark }, markOf(f.mark), ` ${f.label}`))),
|
|
186
|
+
h('span', { class: 'fx-dim' }, c.summary),
|
|
187
|
+
c.action?.(), c.link?.())));
|
|
188
|
+
return [table, cards];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function load(q = '') {
|
|
192
|
+
let body;
|
|
193
|
+
try {
|
|
194
|
+
body = await flow(`/api/invoicing/${kind}/${id}/reconcile${q.trim() ? `?q=${encodeURIComponent(q.trim())}` : ''}`);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
results.replaceChildren(h('p', { class: 'fx-empty' }, error.message));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const position = index >= 0 ? `${index + 1} of ${queue.length}` : '';
|
|
200
|
+
if (tab === 'jobs') renderJob(body, position, q);
|
|
201
|
+
else renderInvoice(body, position, q);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function renderHead(position, title, link) {
|
|
205
|
+
head.replaceChildren(...[
|
|
206
|
+
h('div', {},
|
|
207
|
+
h('a', { class: 'fx-rec__back', href: pathOf(null), onclick: (event) => open(null, event) }, '← All'),
|
|
208
|
+
h('span', { class: 'fx-dim' }, position),
|
|
209
|
+
h('h2', { class: 'fx-rec__title' }, title)),
|
|
210
|
+
link,
|
|
211
|
+
].filter(Boolean));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function renderJob({ job, invoices }, position, q) {
|
|
215
|
+
const system = job.sourceName ?? job.sourceSystem;
|
|
216
|
+
renderHead(position, job.clientName ?? 'No client', null);
|
|
217
|
+
const candidates = invoices.map((invoice) => {
|
|
218
|
+
const facts = invoiceCandidateFacts(invoice);
|
|
219
|
+
return {
|
|
220
|
+
title: invoice.number ?? 'Draft', sub: statusWords(invoice.status), best: invoice.best, facts,
|
|
221
|
+
summary: `${facts[2].value} · ${money(invoice.totalCents)} · ${statusWords(invoice.status)}`,
|
|
222
|
+
action: canAct && job.open ? () => h('button', {
|
|
223
|
+
class: `fx-btn${invoice.best ? ' fx-btn--primary' : ''}`, type: 'button',
|
|
224
|
+
onclick: () => act(() => flow(`/api/invoicing/jobs/${job.id}/link`, { method: 'POST', body: JSON.stringify({ invoiceId: invoice.id }) }),
|
|
225
|
+
`Linked to ${invoice.number ?? 'the draft'}.`),
|
|
226
|
+
}, 'Link this invoice') : null,
|
|
227
|
+
link: () => inFlow(`/invoices/${invoice.id}`, 'Open invoice'),
|
|
228
|
+
};
|
|
229
|
+
});
|
|
230
|
+
results.replaceChildren(...[
|
|
231
|
+
job.open ? null : h('p', { class: 'fx-notice' }, 'This job has an invoice already.'),
|
|
232
|
+
candidates.length ? compareView({ ours: jobFacts(job), oursTitle: 'This job', oursSub: system, candidates })
|
|
233
|
+
: h('p', { class: 'fx-empty' }, q.trim() ? 'No Flow invoice matches the search.' : 'Flow found no invoice that may be for this job. Search for one, or make it.'),
|
|
234
|
+
].flat().filter(Boolean));
|
|
235
|
+
foot.replaceChildren(
|
|
236
|
+
h('span', { class: 'fx-dim' }, candidates.length ? 'None of these?' : ''),
|
|
237
|
+
h('div', { class: 'fx-rec__foot-acts' },
|
|
238
|
+
queue.length > 1 ? h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', onclick: next }, 'Skip') : null,
|
|
239
|
+
canAct && job.open ? h('button', {
|
|
240
|
+
class: 'fx-btn', type: 'button',
|
|
241
|
+
onclick: () => act(async () => {
|
|
242
|
+
if (job.unclear) await flow(`/api/invoicing/jobs/${job.id}/no-invoice`, { method: 'POST', body: '{}' });
|
|
243
|
+
await flow(`/api/invoicing/jobs/${job.id}/draft`, { method: 'POST', body: '{}' });
|
|
244
|
+
}, 'Draft invoice made.'),
|
|
245
|
+
}, 'Make invoice') : null));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function renderInvoice({ invoice, jobs }, position, q) {
|
|
249
|
+
renderHead(position, invoice.number ?? 'Draft invoice', inFlow(`/invoices/${invoice.id}`, 'Open invoice'));
|
|
250
|
+
const candidates = jobs.map((job) => {
|
|
251
|
+
const facts = jobCandidateFacts(job);
|
|
252
|
+
return {
|
|
253
|
+
title: job.caseRef ?? shortDate(job.jobDate), sub: job.practitioner ?? '', best: job.best, facts,
|
|
254
|
+
summary: `${facts[2].value} · ${money(job.totalCents)}`,
|
|
255
|
+
action: canAct && invoice.open ? () => h('button', {
|
|
256
|
+
class: `fx-btn${job.best ? ' fx-btn--primary' : ''}`, type: 'button',
|
|
257
|
+
onclick: () => act(() => flow(`/api/invoicing/jobs/${job.id}/link`, { method: 'POST', body: JSON.stringify({ invoiceId: invoice.id }) }),
|
|
258
|
+
'Job linked.'),
|
|
259
|
+
}, 'Link this job') : null,
|
|
260
|
+
link: null,
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
results.replaceChildren(...[
|
|
264
|
+
invoice.open ? null : h('p', { class: 'fx-notice' }, 'This invoice has a job already.'),
|
|
265
|
+
candidates.length ? compareView({ ours: invoiceFacts(invoice), oursTitle: 'This invoice', oursSub: 'Flow', candidates })
|
|
266
|
+
: h('p', { class: 'fx-empty' }, q.trim() ? 'No job matches the search.' : 'Flow found no job that may be for this invoice.'),
|
|
267
|
+
].flat().filter(Boolean));
|
|
268
|
+
foot.replaceChildren(
|
|
269
|
+
h('span', { class: 'fx-dim' }, ''),
|
|
270
|
+
h('div', { class: 'fx-rec__foot-acts' },
|
|
271
|
+
queue.length > 1 ? h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', onclick: next }, 'Skip') : null));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
await load();
|
|
275
|
+
}
|
package/server/ui/routes.js
CHANGED
|
@@ -21,7 +21,7 @@ const themeDir = join(here, '..', 'theme');
|
|
|
21
21
|
const logoDir = join(here, 'logos');
|
|
22
22
|
const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
|
|
23
23
|
const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
|
|
24
|
-
const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'custom-fields.js', 'jobs.js', 'job-board.js', 'job-view.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'flow-mark.svg']);
|
|
24
|
+
const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'job-view.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'flow-mark.svg']);
|
|
25
25
|
const iconDir = join(dirname(require.resolve('lucide-static/package.json')), 'icons');
|
|
26
26
|
const ICON_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/;
|
|
27
27
|
const MAX_AGE = '1h';
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// Xero › Import › Review: each Xero invoice in Flow's copy that a Flow invoice
|
|
2
|
+
// or a Flow job fits (server/integrations/xero/links.js). Two views:
|
|
3
|
+
// card one Xero invoice beside its Flow fit, one fact per row, ✓ or ≠
|
|
4
|
+
// between them; Skip, Other match, Link.
|
|
5
|
+
// list every one to check, ticked rows linked together from the bulk bar.
|
|
6
|
+
// ?show=all is the history: every Xero invoice in the copy, with a search;
|
|
7
|
+
// ticked rows with nothing in Flow get a Flow invoice from the bulk bar.
|
|
8
|
+
// The settings and the counts are rows on the Xero integration page. It reads
|
|
9
|
+
// Flow's database only: no Xero call.
|
|
10
|
+
import { bulkBar } from '/assets/ui/bulk-bar.js';
|
|
11
|
+
import { lucide } from '/assets/ui/custom-fields.js';
|
|
12
|
+
import { formatCents } from '/assets/ui/invoicing.js';
|
|
13
|
+
|
|
14
|
+
const XERO_STATUS = { DRAFT: 'Draft', SUBMITTED: 'Awaiting approval', AUTHORISED: 'Approved', PAID: 'Paid' };
|
|
15
|
+
const XERO_TONE = { DRAFT: 'neutral', SUBMITTED: 'warn', AUTHORISED: 'info', PAID: 'success' };
|
|
16
|
+
const shortDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) : '—');
|
|
17
|
+
const money = (cents) => (cents == null ? '—' : formatCents(cents));
|
|
18
|
+
const daysApart = (a, b) => (a && b ? Math.abs(Math.round((Date.parse(a) - Date.parse(b)) / 86_400_000)) : 0);
|
|
19
|
+
const sameSet = (a = [], b = []) => a.length === b.length && a.every((c) => b.includes(c));
|
|
20
|
+
const words = (text) => String(text ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
|
|
21
|
+
|
|
22
|
+
export function xeroStatusWords(row) {
|
|
23
|
+
if (row.status === 'AUTHORISED' && row.amountDueCents > 0 && row.amountDueCents < row.totalCents) return 'Part paid';
|
|
24
|
+
return XERO_STATUS[row.status] ?? row.status;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The Flow side of one fit: a Flow invoice, or a job with no invoice.
|
|
28
|
+
export function flowFit(row, index = 0) {
|
|
29
|
+
if (row.state === 'invoice_fits') {
|
|
30
|
+
const i = row.invoices[index];
|
|
31
|
+
return { kind: 'invoice', id: i.id, name: i.number ?? 'Draft', date: i.date, totalCents: i.totalCents, reference: i.reference ?? null, itemCodes: i.itemCodes ?? [],
|
|
32
|
+
count: row.invoices.length, hard: Boolean(i.hard), path: 'link', body: { invoiceId: i.id } };
|
|
33
|
+
}
|
|
34
|
+
const j = row.jobs[index];
|
|
35
|
+
return { kind: 'job', id: j.id, name: j.caseRef ?? 'Job', date: j.jobDate, totalCents: j.totalCents, reference: j.practitioner ?? null, itemCodes: j.itemCodes ?? [],
|
|
36
|
+
count: row.jobs.length, hard: Boolean(j.tied), path: 'make', body: { jobId: j.id } };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ✓ same, ≠ different, nothing when a side is empty. `tip` says how.
|
|
40
|
+
export function agreement(left, right, same) {
|
|
41
|
+
if (left == null || right == null || left === '' || right === '') return null;
|
|
42
|
+
return same ? 'same' : 'differs';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function renderXeroImport(main, ui) {
|
|
46
|
+
const { h, flow } = ui;
|
|
47
|
+
const canAct = ['owner', 'admin'].includes(ui.me?.person?.role);
|
|
48
|
+
const params = new URLSearchParams(location.search);
|
|
49
|
+
const history = params.get('show') === 'all';
|
|
50
|
+
const icon = (name, label) => lucide(h, name, label);
|
|
51
|
+
const host = h('div', { class: 'fx-xi' }, h('p', { class: 'fx-dim' }, 'Loading…'));
|
|
52
|
+
const subtitle = h('span', {});
|
|
53
|
+
const actions = [];
|
|
54
|
+
let view = params.get('view') === 'list' ? 'list' : 'card';
|
|
55
|
+
if (!history) {
|
|
56
|
+
const toggle = (name, label, glyph) => h('button', {
|
|
57
|
+
class: 'fx-xi__view', type: 'button', role: 'tab', 'aria-selected': String(view === name), 'aria-label': label, title: label, 'data-view': name,
|
|
58
|
+
onclick: () => setView(name),
|
|
59
|
+
}, icon(glyph));
|
|
60
|
+
actions.push(h('div', { class: 'fx-xi__views', role: 'tablist', 'aria-label': 'View' }, toggle('card', 'One at a time', 'square'), toggle('list', 'List, tick many', 'list-checks')));
|
|
61
|
+
}
|
|
62
|
+
function setView(name) {
|
|
63
|
+
view = name;
|
|
64
|
+
window.history.replaceState(null, '', view === 'list' ? '/xero-import?view=list' : '/xero-import');
|
|
65
|
+
for (const b of main.querySelectorAll('.fx-xi__view')) b.setAttribute('aria-selected', String(b.dataset.view === view));
|
|
66
|
+
renderReview();
|
|
67
|
+
}
|
|
68
|
+
main.append(
|
|
69
|
+
h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
|
|
70
|
+
h('a', { href: '/integrations', onclick: (event) => ui.go(event) }, 'Integrations'), h('span', { 'aria-hidden': 'true' }, '›'),
|
|
71
|
+
h('a', { href: '/integrations/xero', onclick: (event) => ui.go(event) }, 'Xero'), h('span', { 'aria-hidden': 'true' }, '›'),
|
|
72
|
+
h('span', {}, history ? 'History' : 'Review')),
|
|
73
|
+
ui.pageHeader(history ? 'Xero history' : 'Review', { subtitle, actions }),
|
|
74
|
+
host);
|
|
75
|
+
|
|
76
|
+
let organisationId;
|
|
77
|
+
try {
|
|
78
|
+
organisationId = (await flow('/api/invoicing/context')).organisations?.[0]?.id;
|
|
79
|
+
} catch (error) {
|
|
80
|
+
host.replaceChildren(h('p', { class: 'fx-empty' }, error.message));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!organisationId) {
|
|
84
|
+
host.replaceChildren(h('p', { class: 'fx-empty' }, 'No organisation.'));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const read = (show, { q = '', offset = 0, limit = 100 } = {}) =>
|
|
88
|
+
flow(`/api/xero/import?organisationId=${organisationId}&show=${show}&limit=${limit}&offset=${offset}${q.trim() ? `&q=${encodeURIComponent(q.trim())}` : ''}`);
|
|
89
|
+
const act = (xeroInvoiceId, path, body) =>
|
|
90
|
+
flow(`/api/xero/import/${xeroInvoiceId}/${path}`, { method: 'POST', body: JSON.stringify({ organisationId, ...body }) });
|
|
91
|
+
// Runs one step for each row, one at a time; answers how many went through.
|
|
92
|
+
const each = async (rows, step) => {
|
|
93
|
+
let done = 0;
|
|
94
|
+
for (const row of rows) {
|
|
95
|
+
try { await step(row); done += 1; } catch { /* the row stays; the toast counts it */ }
|
|
96
|
+
}
|
|
97
|
+
ui.toast(done === rows.length ? `${done} done.` : `${done} of ${rows.length} done. The rest stay here.`, done === rows.length ? 'success' : 'warn');
|
|
98
|
+
};
|
|
99
|
+
const go = (href, label) => h('a', { href, onclick: (event) => ui.go(event) }, label);
|
|
100
|
+
const xeroMark = () => h('img', { class: 'fx-xi__mark', src: '/assets/ui/logos/xero.svg', alt: 'Xero' });
|
|
101
|
+
const flowMark = () => h('img', { class: 'fx-xi__mark', src: '/assets/ui/flow-mark.svg', alt: 'Flow' });
|
|
102
|
+
const pill = (row) => h('span', { class: 'fx-status', 'data-tone': XERO_TONE[row.status] ?? 'neutral' }, xeroStatusWords(row));
|
|
103
|
+
const fitIcon = (fit) => (fit.hard ? icon('link-2', 'Tied by Seazona or the matcher') : fit.kind === 'job' ? icon('briefcase', 'A job fits') : icon('receipt', 'A Flow invoice fits'));
|
|
104
|
+
const bulk = bulkBar(ui);
|
|
105
|
+
main.append(bulk.element);
|
|
106
|
+
|
|
107
|
+
if (history) return renderHistory();
|
|
108
|
+
return renderReview();
|
|
109
|
+
|
|
110
|
+
async function renderReview() {
|
|
111
|
+
bulk.draw({ count: 0 });
|
|
112
|
+
let rows;
|
|
113
|
+
try {
|
|
114
|
+
rows = (await read('check')).rows;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
host.replaceChildren(h('p', { class: 'fx-empty' }, error.message));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (!rows.length) {
|
|
120
|
+
subtitle.replaceChildren('');
|
|
121
|
+
host.replaceChildren(h('div', { class: 'fx-xi__done' }, icon('check-check'), h('p', {}, 'Nothing to check.'), go('/xero-import?show=all', 'Xero history')));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
return view === 'list' ? reviewList(rows) : reviewCard(rows);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// One Xero invoice at a time. Skip moves on and changes nothing.
|
|
128
|
+
function reviewCard(rows) {
|
|
129
|
+
let at = 0;
|
|
130
|
+
let pick = 0;
|
|
131
|
+
const show = () => {
|
|
132
|
+
if (at >= rows.length) return renderReview();
|
|
133
|
+
const row = rows[at];
|
|
134
|
+
const fit = flowFit(row, pick);
|
|
135
|
+
const bar = h('span', { class: 'fx-xi__progress', role: 'progressbar', 'aria-valuemin': '1', 'aria-valuemax': String(rows.length), 'aria-valuenow': String(at + 1) },
|
|
136
|
+
h('span', { style: { width: `${((at + 1) / rows.length) * 100}%` } }));
|
|
137
|
+
subtitle.replaceChildren(h('span', { class: 'fx-xi__count' }, bar, `${at + 1} / ${rows.length}`));
|
|
138
|
+
const days = daysApart(row.issuedOn, fit.date);
|
|
139
|
+
const facts = [
|
|
140
|
+
['hash', 'Number', h('span', { class: 'fx-num' }, row.number ?? '—'), h('span', { class: 'fx-num' }, fit.name), null],
|
|
141
|
+
['building-2', 'Client', row.clientName ?? '—', row.clientName ?? '—', 'same'],
|
|
142
|
+
['calendar', 'Date', shortDate(row.issuedOn), shortDate(fit.date), days ? 'differs' : 'same', days ? `${days} ${days === 1 ? 'day' : 'days'} apart` : null],
|
|
143
|
+
['dollar-sign', 'Total', h('span', { class: 'fx-num' }, money(row.totalCents)), h('span', { class: 'fx-num' }, money(fit.totalCents)), agreement(row.totalCents, fit.totalCents, row.totalCents === fit.totalCents)],
|
|
144
|
+
['list', 'Items', (row.itemCodes ?? []).join(', ') || '—', fit.itemCodes.join(', ') || '—', agreement(row.itemCodes?.length || null, fit.itemCodes.length || null, sameSet(row.itemCodes, fit.itemCodes))],
|
|
145
|
+
['tag', fit.kind === 'job' ? 'Reference / practitioner' : 'Reference', row.reference ?? '—', fit.reference ?? '—',
|
|
146
|
+
agreement(row.reference, fit.reference, fit.kind === 'job' ? words(row.reference).includes(words(fit.reference)) : words(row.reference) === words(fit.reference))],
|
|
147
|
+
['circle-dot', 'Status', pill(row), h('span', { class: 'fx-status', 'data-tone': 'warn' }, fit.kind === 'job' ? 'No invoice' : 'Not in Xero'), null],
|
|
148
|
+
];
|
|
149
|
+
const mark = (state, tip) => (state === 'same' ? h('span', { class: 'fx-xi__agree', 'data-state': 'same' }, icon('equal', tip ?? 'Same'))
|
|
150
|
+
: state === 'differs' ? h('span', { class: 'fx-xi__agree', 'data-state': 'differs' }, icon('equal-not', tip ?? 'Different')) : h('span', {}));
|
|
151
|
+
const table = h('table', { class: 'fx-xi-cmp' },
|
|
152
|
+
h('thead', {}, h('tr', {}, h('th', {}), h('th', {}, xeroMark()), h('th', {}), h('th', {}, flowMark(), fitIcon(fit)))),
|
|
153
|
+
h('tbody', {}, facts.map(([glyph, label, left, right, state, tip]) => h('tr', {},
|
|
154
|
+
h('th', { scope: 'row' }, icon(glyph, label)),
|
|
155
|
+
h('td', {}, left), h('td', { class: 'fx-xi-cmp__mid' }, mark(state, tip)), h('td', {}, right)))));
|
|
156
|
+
const primary = canAct ? h('button', { class: 'fx-btn fx-btn--primary', type: 'button', onclick: async () => {
|
|
157
|
+
primary.disabled = true;
|
|
158
|
+
try {
|
|
159
|
+
await act(row.xeroInvoiceId, fit.path, fit.body);
|
|
160
|
+
ui.toast(fit.kind === 'job' ? 'Made and linked.' : 'Linked.', 'success');
|
|
161
|
+
rows.splice(at, 1);
|
|
162
|
+
pick = 0;
|
|
163
|
+
show();
|
|
164
|
+
} catch (error) {
|
|
165
|
+
ui.toast(error.message, 'danger');
|
|
166
|
+
primary.disabled = false;
|
|
167
|
+
}
|
|
168
|
+
} }, icon(fit.kind === 'job' ? 'file-plus-2' : 'link-2'), fit.kind === 'job' ? 'Make + link' : 'Link') : null;
|
|
169
|
+
host.replaceChildren(
|
|
170
|
+
bulkStrip(rows),
|
|
171
|
+
table,
|
|
172
|
+
h('div', { class: 'fx-xi__acts' },
|
|
173
|
+
fit.count > 1 ? h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', title: 'Other match', 'aria-label': 'Other match', onclick: () => { pick = (pick + 1) % fit.count; show(); } },
|
|
174
|
+
icon('arrow-left-right'), `${pick + 1}/${fit.count}`) : null,
|
|
175
|
+
h('button', { class: 'fx-btn', type: 'button', onclick: () => { at += 1; pick = 0; show(); } }, icon('skip-forward'), 'Skip'),
|
|
176
|
+
primary));
|
|
177
|
+
};
|
|
178
|
+
show();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// The bulk shortcut on the card view: every sure one at once, or the list.
|
|
182
|
+
function bulkStrip(rows) {
|
|
183
|
+
const sure = rows.filter((r) => r.sure);
|
|
184
|
+
return h('div', { class: 'fx-xi__bulk' },
|
|
185
|
+
icon('list-checks'),
|
|
186
|
+
h('span', { class: 'fx-xi__bulk-text' }, `${rows.length} to check`),
|
|
187
|
+
canAct && sure.length ? h('button', { class: 'fx-btn fx-btn--sm fx-btn--primary', type: 'button', onclick: async (event) => {
|
|
188
|
+
event.currentTarget.disabled = true;
|
|
189
|
+
await each(sure, (row) => { const fit = flowFit(row); return act(row.xeroInvoiceId, fit.path, fit.body); });
|
|
190
|
+
renderReview();
|
|
191
|
+
} }, icon('check-check'), `Link ${sure.length} sure`) : null,
|
|
192
|
+
h('button', { class: 'fx-btn fx-btn--sm', type: 'button', onclick: () => setView('list') },
|
|
193
|
+
icon('list-checks'), 'Tick many'));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Every one to check, best fit beside it; ticked rows go from the bulk bar.
|
|
197
|
+
function reviewList(rows) {
|
|
198
|
+
subtitle.replaceChildren(`${rows.length} to check`);
|
|
199
|
+
const ticked = new Set();
|
|
200
|
+
const boxes = new Map();
|
|
201
|
+
const drawBar = () => {
|
|
202
|
+
const picked = rows.filter((r) => ticked.has(r.xeroInvoiceId));
|
|
203
|
+
bulk.draw({
|
|
204
|
+
count: canAct ? picked.length : 0,
|
|
205
|
+
onClear: () => { ticked.clear(); for (const b of boxes.values()) b.checked = false; all.checked = false; drawBar(); },
|
|
206
|
+
groups: [[{ icon: 'link-2', label: 'Link', title: 'Link each to its best match', primary: true, run: async () => {
|
|
207
|
+
await each(picked, (row) => { const fit = flowFit(row); return act(row.xeroInvoiceId, fit.path, fit.body); });
|
|
208
|
+
renderReview();
|
|
209
|
+
} }]],
|
|
210
|
+
});
|
|
211
|
+
};
|
|
212
|
+
const all = h('input', { type: 'checkbox', class: 'fx-tick', 'aria-label': 'Tick all', onchange: () => {
|
|
213
|
+
for (const r of rows) { if (all.checked) ticked.add(r.xeroInvoiceId); else ticked.delete(r.xeroInvoiceId); }
|
|
214
|
+
for (const b of boxes.values()) b.checked = all.checked;
|
|
215
|
+
drawBar();
|
|
216
|
+
} });
|
|
217
|
+
const sure = rows.filter((r) => r.sure);
|
|
218
|
+
host.replaceChildren(
|
|
219
|
+
h('div', { class: 'fx-xi__bulk' }, icon('list-checks'), h('span', { class: 'fx-xi__bulk-text' }, 'Tick rows, then use the bar below.'),
|
|
220
|
+
canAct && sure.length ? h('button', { class: 'fx-btn fx-btn--sm', type: 'button', onclick: () => {
|
|
221
|
+
for (const r of sure) { ticked.add(r.xeroInvoiceId); boxes.get(r.xeroInvoiceId).checked = true; }
|
|
222
|
+
drawBar();
|
|
223
|
+
} }, icon('check-check'), `Tick ${sure.length} sure`) : null),
|
|
224
|
+
h('div', { class: 'fx-xi__scroll' }, h('table', { class: 'fx-table fx-table--rows fx-xi-list' },
|
|
225
|
+
h('thead', {}, h('tr', {},
|
|
226
|
+
canAct ? h('th', { class: 'fx-tick-cell' }, all) : null,
|
|
227
|
+
h('th', { colspan: '4' }, xeroMark()), h('th', { class: 'fx-xi-list__mid' }), h('th', { colspan: '3' }, flowMark()))),
|
|
228
|
+
h('tbody', {}, rows.map((row) => {
|
|
229
|
+
const fit = flowFit(row);
|
|
230
|
+
const box = h('input', { type: 'checkbox', class: 'fx-tick', 'aria-label': `Tick ${row.number}`, onchange: () => {
|
|
231
|
+
if (box.checked) ticked.add(row.xeroInvoiceId); else ticked.delete(row.xeroInvoiceId);
|
|
232
|
+
drawBar();
|
|
233
|
+
} });
|
|
234
|
+
boxes.set(row.xeroInvoiceId, box);
|
|
235
|
+
return h('tr', {},
|
|
236
|
+
canAct ? h('td', { class: 'fx-tick-cell' }, box) : null,
|
|
237
|
+
h('td', { class: 'fx-num' }, row.number ?? '—'),
|
|
238
|
+
h('td', {}, row.clientName ?? '—'),
|
|
239
|
+
h('td', { class: 'fx-dim' }, shortDate(row.issuedOn)),
|
|
240
|
+
h('td', { class: 'fx-num fx-strong' }, money(row.totalCents)),
|
|
241
|
+
h('td', { class: 'fx-xi-list__mid' }, row.sure ? icon('check-check', 'Sure') : fitIcon(fit), fit.count > 1 ? h('span', { class: 'fx-dim' }, `${fit.count}`) : null),
|
|
242
|
+
h('td', { class: 'fx-num' }, fit.name),
|
|
243
|
+
h('td', { class: 'fx-dim' }, shortDate(fit.date)),
|
|
244
|
+
h('td', { class: 'fx-num fx-strong' }, money(fit.totalCents)));
|
|
245
|
+
})))));
|
|
246
|
+
drawBar();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Every Xero invoice in the copy, newest first, with a search. Ticked rows
|
|
250
|
+
// that nothing in Flow fits get a Flow invoice from the bulk bar.
|
|
251
|
+
async function renderHistory() {
|
|
252
|
+
let q = '';
|
|
253
|
+
let offset = 0;
|
|
254
|
+
let shown = [];
|
|
255
|
+
const ticked = new Set();
|
|
256
|
+
const search = h('input', { class: 'fx-input fx-xi__search', type: 'search', autocomplete: 'off', placeholder: 'Number, client, Reference or amount', 'aria-label': 'Search Xero history' });
|
|
257
|
+
const body = h('tbody', {});
|
|
258
|
+
const all = h('input', { type: 'checkbox', class: 'fx-tick', 'aria-label': 'Tick all shown', onchange: () => {
|
|
259
|
+
for (const r of shown) { if (all.checked) ticked.add(r.xeroInvoiceId); else ticked.delete(r.xeroInvoiceId); }
|
|
260
|
+
for (const b of body.querySelectorAll('.fx-tick')) b.checked = all.checked;
|
|
261
|
+
drawBar();
|
|
262
|
+
} });
|
|
263
|
+
const more = h('div', {});
|
|
264
|
+
const STATE = { linked: ['check', 'In Flow'], invoice_fits: ['receipt', 'A Flow invoice fits'], job_fits: ['briefcase', 'A job fits'], none: ['circle-dashed', 'Not in Flow'] };
|
|
265
|
+
host.replaceChildren(
|
|
266
|
+
h('div', { class: 'fx-xi__bulk' }, icon('search'), search),
|
|
267
|
+
h('div', { class: 'fx-xi__scroll' }, h('table', { class: 'fx-table fx-table--rows fx-xi-list' },
|
|
268
|
+
h('thead', {}, h('tr', {},
|
|
269
|
+
canAct ? h('th', { class: 'fx-tick-cell' }, all) : null,
|
|
270
|
+
h('th', {}, ''), h('th', {}, 'Number'), h('th', {}, 'Client'), h('th', {}, 'Date'), h('th', { class: 'fx-num' }, 'Total'), h('th', {}, xeroMark()), h('th', {}, flowMark()))),
|
|
271
|
+
body)),
|
|
272
|
+
more);
|
|
273
|
+
let timer = null;
|
|
274
|
+
search.addEventListener('input', () => {
|
|
275
|
+
clearTimeout(timer);
|
|
276
|
+
timer = setTimeout(() => { q = search.value; offset = 0; load(); }, 300);
|
|
277
|
+
});
|
|
278
|
+
function drawBar() {
|
|
279
|
+
const picked = shown.filter((r) => ticked.has(r.xeroInvoiceId));
|
|
280
|
+
const free = picked.filter((r) => r.state === 'none');
|
|
281
|
+
bulk.draw({
|
|
282
|
+
count: canAct ? picked.length : 0,
|
|
283
|
+
onClear: () => { ticked.clear(); for (const b of body.querySelectorAll('.fx-tick')) b.checked = false; all.checked = false; drawBar(); },
|
|
284
|
+
groups: [[{ icon: 'file-plus-2', label: free.length === picked.length ? 'Make Flow invoices' : `Make ${free.length} Flow invoices`, title: 'Make a Flow invoice for each ticked one not in Flow', primary: true, run: async () => {
|
|
285
|
+
if (!free.length) { ui.toast('Each ticked one is in Flow or fits something. Use Review.', 'warn'); return; }
|
|
286
|
+
await each(free, (row) => act(row.xeroInvoiceId, 'make', {}));
|
|
287
|
+
ticked.clear();
|
|
288
|
+
offset = 0;
|
|
289
|
+
await load();
|
|
290
|
+
} }]],
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
const rowOf = (row) => {
|
|
294
|
+
const [glyph, label] = STATE[row.state];
|
|
295
|
+
const box = h('input', { type: 'checkbox', class: 'fx-tick', 'aria-label': `Tick ${row.number}`, ...(ticked.has(row.xeroInvoiceId) ? { checked: true } : {}), onchange: () => {
|
|
296
|
+
if (box.checked) ticked.add(row.xeroInvoiceId); else ticked.delete(row.xeroInvoiceId);
|
|
297
|
+
drawBar();
|
|
298
|
+
} });
|
|
299
|
+
return h('tr', { 'data-state': row.state },
|
|
300
|
+
canAct ? h('td', { class: 'fx-tick-cell' }, box) : null,
|
|
301
|
+
h('td', { class: 'fx-xi-list__mid' }, row.state === 'linked' || row.state === 'none' ? icon(glyph, label) : h('a', { href: '/xero-import', onclick: (event) => ui.go(event), 'aria-label': label, title: label }, icon(glyph))),
|
|
302
|
+
h('td', { class: 'fx-num' }, row.number ?? '—'),
|
|
303
|
+
h('td', {}, row.clientName ?? '—'),
|
|
304
|
+
h('td', { class: 'fx-dim' }, shortDate(row.issuedOn)),
|
|
305
|
+
h('td', { class: 'fx-num fx-strong' }, money(row.totalCents)),
|
|
306
|
+
h('td', {}, pill(row)),
|
|
307
|
+
h('td', { class: 'fx-num' }, row.state === 'linked' ? go(`/invoices/${row.link.invoiceId}`, row.link.number ?? 'Draft') : ''));
|
|
308
|
+
};
|
|
309
|
+
async function load() {
|
|
310
|
+
let page;
|
|
311
|
+
try {
|
|
312
|
+
page = await read('all', { q, offset, limit: 50 });
|
|
313
|
+
} catch (error) {
|
|
314
|
+
body.replaceChildren(h('tr', {}, h('td', { colspan: '8', class: 'fx-empty' }, error.message)));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const total = Object.values(page.counts).reduce((a, b) => a + b, 0);
|
|
318
|
+
subtitle.replaceChildren(h('span', { class: 'fx-xi__count' }, icon('check', 'In Flow'), `${page.counts.linked} / ${total}`));
|
|
319
|
+
shown = offset === 0 ? page.rows : [...shown, ...page.rows];
|
|
320
|
+
const rows = page.rows.map(rowOf);
|
|
321
|
+
if (offset === 0) body.replaceChildren(...(rows.length ? rows : [h('tr', {}, h('td', { colspan: '8', class: 'fx-empty' }, q.trim() ? 'No match.' : 'No Xero invoices copied yet.'))]));
|
|
322
|
+
else body.append(...rows);
|
|
323
|
+
const seen = offset + page.rows.length;
|
|
324
|
+
more.replaceChildren(...(seen < page.total ? [h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', onclick: () => { offset = seen; load(); } }, `Show more (${page.total - seen})`)] : []));
|
|
325
|
+
drawBar();
|
|
326
|
+
}
|
|
327
|
+
await load();
|
|
328
|
+
}
|
|
329
|
+
}
|