@arcforgelabs/flow 2026.10.39 → 2026.10.41
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/core/job-match.js +2 -0
- package/server/integrations/call-guard.js +57 -15
- package/server/integrations/xero/settings.js +2 -1
- package/server/ui/app.js +7 -3
- package/server/ui/flow.css +24 -38
- package/server/ui/invoicing.js +15 -53
- package/server/ui/jobs.js +15 -172
- package/server/ui/monitoring.js +13 -12
- package/server/ui/reconcile.js +279 -95
- package/server/ui/routes.js +1 -1
- package/server/ui/view-bar.js +236 -0
package/server/ui/reconcile.js
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
// Reconcile: Flow's jobs with no Flow invoice, and Flow invoices with no job.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
2
|
+
// Laid out like Jobs and Invoices:
|
|
3
|
+
// /reconcile every gap in one list, with the Jobs page's Filter chip
|
|
4
|
+
// (show jobs, invoices, with or without a possible match)
|
|
5
|
+
// and a search. Ticked jobs with no possible invoice get
|
|
6
|
+
// their drafts from the bulk bar. A row opens it.
|
|
7
|
+
// /reconcile/:id one gap, as an invoice opens from Invoices: the invoice
|
|
8
|
+
// pager on top ("← Back" to the list as it was, "n of N",
|
|
9
|
+
// previous and next), then the gap beside its possible
|
|
10
|
+
// other side, each fact marked same (✓) or different (≠),
|
|
11
|
+
// and a search for any other. A person links one, or for a
|
|
12
|
+
// job makes its invoice; then the next one opens.
|
|
13
|
+
// ?kind=invoice opens a Flow invoice with no job.
|
|
14
|
+
// Flow's own records only: matching a Flow invoice to an accounting system's
|
|
15
|
+
// is that integration's work. API: /api/invoicing/reconcile,
|
|
16
|
+
// /jobs/:id/reconcile, /invoices/:id/reconcile, /jobs/:id/link,
|
|
17
|
+
// /jobs/:id/no-invoice (docs/JOB_INVOICE_LINKS.md).
|
|
18
|
+
import { bulkBar } from '/assets/ui/bulk-bar.js';
|
|
19
|
+
import { lucide } from '/assets/ui/custom-fields.js';
|
|
20
|
+
import { filterJobs, MATCHES } from '/assets/ui/job-view.js';
|
|
21
|
+
import { findBox, today, viewBar } from '/assets/ui/view-bar.js';
|
|
8
22
|
import { formatCents } from '/assets/ui/invoicing.js';
|
|
9
23
|
|
|
10
24
|
const STATUS = { draft: 'Draft', issued: 'Approved', void: 'Void' };
|
|
@@ -76,81 +90,264 @@ function jobCandidateFacts(job) {
|
|
|
76
90
|
];
|
|
77
91
|
}
|
|
78
92
|
|
|
93
|
+
|
|
94
|
+
const CHEVRONS = { prev: 'M14.5 6l-6 6 6 6', next: 'M9.5 6l6 6-6 6' };
|
|
95
|
+
// The list's filter rows (view-bar.js), kept per viewer in this browser.
|
|
96
|
+
// The list a person opened a gap from, for the pager.
|
|
97
|
+
const FILTER_KEY = 'flow_reconcile_filters';
|
|
98
|
+
const NAV_KEY = 'flow_reconcile_nav';
|
|
99
|
+
// The fields a filter row can use. One word each; Type's choices have icons.
|
|
100
|
+
const FILTER_FIELDS = {
|
|
101
|
+
type: { label: 'Type', kind: 'choice' },
|
|
102
|
+
client: { label: 'Client', kind: 'choice' },
|
|
103
|
+
practitioner: { label: 'Practitioner', kind: 'choice' },
|
|
104
|
+
date: { label: 'Date', kind: 'date' },
|
|
105
|
+
total: { label: 'Total', kind: 'number' },
|
|
106
|
+
match: { label: 'Match', kind: 'check' },
|
|
107
|
+
};
|
|
108
|
+
const MATCH_OPS = { yes: 'has one', no: 'has none' };
|
|
109
|
+
const TYPES = [{ value: 'job', label: 'Job', icon: 'briefcase' }, { value: 'invoice', label: 'Invoice', icon: 'file-text' }];
|
|
110
|
+
|
|
111
|
+
function readStored(store, key, fallback) {
|
|
112
|
+
try { return JSON.parse(store.getItem(key)) ?? fallback; } catch { return fallback; }
|
|
113
|
+
}
|
|
114
|
+
function writeStored(store, key, value) {
|
|
115
|
+
try { store.setItem(key, JSON.stringify(value)); } catch { /* the page still works, it forgets */ }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// One row for each gap, jobs and invoices together, oldest first.
|
|
119
|
+
export function reconcileRows({ jobs = [], invoices = [] }) {
|
|
120
|
+
return [
|
|
121
|
+
...jobs.map((j) => ({ kind: 'job', id: j.id, client: j.clientName, who: j.practitioner, date: j.jobDate, totalCents: j.totalCents, possible: j.possible, unclear: j.unclear })),
|
|
122
|
+
...invoices.map((i) => ({ kind: 'invoice', id: i.id, client: i.clientName, who: i.number ?? 'Draft', status: i.status, date: i.issueDate, totalCents: i.totalCents, possible: i.possible })),
|
|
123
|
+
].sort((a, b) => String(a.date ?? '9999').localeCompare(String(b.date ?? '9999')));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// A row's value for a filter field, as the filter compares it.
|
|
127
|
+
export function rowValue(row, field) {
|
|
128
|
+
switch (field) {
|
|
129
|
+
case 'type': return row.kind;
|
|
130
|
+
case 'client': return row.client ?? null;
|
|
131
|
+
case 'practitioner': return row.kind === 'job' ? row.who ?? null : null;
|
|
132
|
+
case 'date': return row.date ?? null;
|
|
133
|
+
case 'total': return row.totalCents == null ? null : row.totalCents / 100;
|
|
134
|
+
case 'match': return row.possible > 0;
|
|
135
|
+
default: return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The rows a view keeps: its filter rows, then the search words.
|
|
140
|
+
export function keptRows(rows, view, q = '') {
|
|
141
|
+
const words = q.trim().toLowerCase();
|
|
142
|
+
return filterJobs(rows, view, { valueOf: rowValue, kindOf: (field) => FILTER_FIELDS[field]?.kind ?? null, today: today() })
|
|
143
|
+
.filter((r) => !words || [r.client, r.who, money(r.totalCents)].some((v) => String(v ?? '').toLowerCase().includes(words)));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function readView() {
|
|
147
|
+
const saved = readStored(localStorage, FILTER_KEY, {});
|
|
148
|
+
const filters = Array.isArray(saved.filters) ? saved.filters.filter((f) => f && Object.hasOwn(FILTER_FIELDS, f.field)) : [];
|
|
149
|
+
return { match: MATCHES.includes(saved.match) ? saved.match : 'all', filters };
|
|
150
|
+
}
|
|
151
|
+
|
|
79
152
|
export async function renderReconcile(main, ui, id = null) {
|
|
80
|
-
|
|
153
|
+
if (id) return renderGap(main, ui, id);
|
|
154
|
+
return renderList(main, ui);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const pathOf = (row) => `/reconcile/${row.id}${row.kind === 'invoice' ? '?kind=invoice' : ''}`;
|
|
158
|
+
const KIND_ICON = { job: ['briefcase', 'Job'], invoice: ['file-text', 'Invoice'] };
|
|
159
|
+
|
|
160
|
+
async function renderList(main, ui) {
|
|
161
|
+
const { h, flow, toast, pageHeader } = ui;
|
|
162
|
+
const icon = (name, label) => lucide(h, name, label);
|
|
81
163
|
const canAct = ['owner', 'admin'].includes(ui.me?.person?.role);
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
|
|
164
|
+
const back = readStored(sessionStorage, NAV_KEY, null);
|
|
165
|
+
const state = { view: readView(), q: back?.q ?? '', ticked: new Set() };
|
|
166
|
+
const find = findBox(h, { label: 'Find a client, practitioner or amount', value: state.q, oninput: (words) => { state.q = words; draw(); } });
|
|
167
|
+
const controls = h('div', { class: 'fx-viewbar', role: 'group', 'aria-label': 'Filter' });
|
|
168
|
+
const host = h('div', { class: 'fx-rec' }, h('div', { class: 'fx-skeleton' }));
|
|
169
|
+
const bulk = bulkBar(ui, { label: 'Ticked jobs' });
|
|
170
|
+
main.append(
|
|
171
|
+
pageHeader('Reconcile'),
|
|
172
|
+
h('div', { class: 'fx-toolbar fx-toolbar--plain' }, controls, h('div', { class: 'fx-toolbar__end' }, find.element)),
|
|
173
|
+
host, bulk.element);
|
|
174
|
+
|
|
175
|
+
// The Filter chip: the same rows as on Jobs (view-bar.js).
|
|
176
|
+
const labels = (list) => [...new Set(list.filter(Boolean))].sort((a, b) => a.localeCompare(b)).map((v) => ({ value: v, label: v }));
|
|
177
|
+
const filterMenu = viewBar(h).filterMenu({
|
|
178
|
+
fields: () => Object.entries(FILTER_FIELDS).map(([value, f]) => ({ value, label: f.label })),
|
|
179
|
+
kindOf: (field) => FILTER_FIELDS[field]?.kind ?? null,
|
|
180
|
+
opsOf: (field) => (field === 'match' ? MATCH_OPS : null),
|
|
181
|
+
choicesOf: (field) => (field === 'type' ? TYPES
|
|
182
|
+
: field === 'client' ? labels(rows.map((r) => r.client))
|
|
183
|
+
: field === 'practitioner' ? labels(rows.filter((r) => r.kind === 'job').map((r) => r.who)) : []),
|
|
184
|
+
view: () => state.view,
|
|
185
|
+
change: (patch) => {
|
|
186
|
+
state.view = { ...state.view, ...patch };
|
|
187
|
+
writeStored(localStorage, FILTER_KEY, state.view);
|
|
188
|
+
draw();
|
|
189
|
+
},
|
|
190
|
+
first: 'type',
|
|
191
|
+
empty: 'No filters. Every job and invoice shows.',
|
|
192
|
+
});
|
|
193
|
+
controls.append(...filterMenu.node);
|
|
85
194
|
|
|
86
|
-
let
|
|
195
|
+
let rows;
|
|
87
196
|
try {
|
|
88
|
-
|
|
197
|
+
rows = reconcileRows(await flow('/api/invoicing/reconcile'));
|
|
89
198
|
} catch (error) {
|
|
90
199
|
host.replaceChildren(h('p', { class: 'fx-empty' }, error.message));
|
|
91
200
|
return;
|
|
92
201
|
}
|
|
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
202
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
203
|
+
function draw() {
|
|
204
|
+
filterMenu.draw();
|
|
205
|
+
const shown = keptRows(rows, state.view, state.q);
|
|
206
|
+
const seen = new Set(shown.map((r) => r.id));
|
|
207
|
+
for (const key of state.ticked) if (!seen.has(key)) state.ticked.delete(key);
|
|
208
|
+
const remember = () => writeStored(sessionStorage, NAV_KEY, { q: state.q, rows: shown.map(({ kind, id }) => ({ kind, id })) });
|
|
209
|
+
if (!rows.length) {
|
|
210
|
+
host.replaceChildren(h('div', { class: 'fx-xi__done' }, icon('check-check'), h('p', {}, 'Every job has an invoice, and every invoice has a job.')));
|
|
211
|
+
bulk.draw({ count: 0 });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (!shown.length) {
|
|
215
|
+
host.replaceChildren(h('p', { class: 'fx-empty' }, 'Nothing matches the filter or the search.'));
|
|
216
|
+
bulk.draw({ count: 0 });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const tickable = shown.filter((r) => r.kind === 'job');
|
|
220
|
+
const boxes = new Map();
|
|
221
|
+
const drawBar = () => {
|
|
222
|
+
const picked = tickable.filter((r) => state.ticked.has(r.id));
|
|
223
|
+
const plain = picked.filter((r) => !r.possible);
|
|
224
|
+
bulk.draw({
|
|
225
|
+
count: canAct ? picked.length : 0,
|
|
226
|
+
onClear: () => { state.ticked.clear(); draw(); },
|
|
227
|
+
groups: [[
|
|
228
|
+
{ icon: 'file-plus', label: 'Make invoices', title: 'Make a draft invoice for each ticked job with no possible invoice', primary: true, run: async () => {
|
|
229
|
+
if (!plain.length) { toast('Each ticked job has a possible invoice. Open it, and check it first.', 'warn'); return; }
|
|
230
|
+
let made = 0;
|
|
231
|
+
for (const row of plain) {
|
|
232
|
+
try {
|
|
233
|
+
if (row.unclear) await flow(`/api/invoicing/jobs/${row.id}/no-invoice`, { method: 'POST', body: '{}' });
|
|
234
|
+
await flow(`/api/invoicing/jobs/${row.id}/draft`, { method: 'POST', body: '{}' });
|
|
235
|
+
made += 1;
|
|
236
|
+
} catch (error) {
|
|
237
|
+
toast(error.message, 'danger');
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const kept = picked.length - plain.length;
|
|
241
|
+
toast(`${made} draft${made === 1 ? '' : 's'} made.${kept ? ` ${kept} with a possible invoice stay here.` : ''}`, made ? 'success' : 'warn');
|
|
242
|
+
ui.go('/reconcile');
|
|
243
|
+
} },
|
|
244
|
+
]],
|
|
245
|
+
});
|
|
246
|
+
};
|
|
247
|
+
const all = h('input', { type: 'checkbox', class: 'fx-tick', 'aria-label': 'Tick every job', checked: tickable.length > 0 && tickable.every((r) => state.ticked.has(r.id)), disabled: !tickable.length, onchange: () => {
|
|
248
|
+
for (const r of tickable) { if (all.checked) state.ticked.add(r.id); else state.ticked.delete(r.id); }
|
|
249
|
+
for (const b of boxes.values()) b.checked = all.checked;
|
|
250
|
+
drawBar();
|
|
251
|
+
} });
|
|
252
|
+
const possible = (r) => (r.possible
|
|
253
|
+
? h('span', { class: 'fx-rec__possible', 'data-state': 'some' }, icon('link-2', 'Possible match'), String(r.possible))
|
|
254
|
+
: h('span', { class: 'fx-rec__possible' }, icon('circle-dashed', 'No possible match')));
|
|
255
|
+
host.replaceChildren(h('div', { class: 'fx-panel' }, h('table', { class: 'fx-table fx-table--rows fx-rec-list' },
|
|
256
|
+
h('thead', {}, h('tr', {},
|
|
257
|
+
canAct ? h('th', { class: 'fx-tick-cell' }, all) : null,
|
|
258
|
+
h('th', { class: 'fx-rec-list__kind' }, h('span', { class: 'fx-sr' }, 'Type')),
|
|
259
|
+
h('th', {}, 'Client'),
|
|
260
|
+
h('th', {}, 'Detail'),
|
|
261
|
+
h('th', {}, 'Date'),
|
|
262
|
+
h('th', { class: 'fx-num' }, 'Total'),
|
|
263
|
+
h('th', { class: 'fx-rec-list__mid' }, 'Possible'))),
|
|
264
|
+
h('tbody', {}, shown.map((r) => {
|
|
265
|
+
const box = r.kind === 'job' ? h('input', { type: 'checkbox', class: 'fx-tick', 'aria-label': `Tick ${r.client ?? 'job'}`, checked: state.ticked.has(r.id), onclick: (event) => event.stopPropagation(), onchange: () => {
|
|
266
|
+
if (box.checked) state.ticked.add(r.id); else state.ticked.delete(r.id);
|
|
267
|
+
drawBar();
|
|
268
|
+
} }) : null;
|
|
269
|
+
if (box) boxes.set(r.id, box);
|
|
270
|
+
const [glyph, label] = KIND_ICON[r.kind];
|
|
271
|
+
return h('tr', { 'data-kind': r.kind, onclick: () => { remember(); ui.go(pathOf(r)); } },
|
|
272
|
+
canAct ? h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, box) : null,
|
|
273
|
+
h('td', { class: 'fx-rec-list__kind' }, icon(glyph, label)),
|
|
274
|
+
h('td', { class: 'fx-strong' }, r.client ?? h('span', { class: 'fx-dim' }, 'No client')),
|
|
275
|
+
h('td', { class: r.kind === 'invoice' ? 'fx-num' : 'fx-dim' }, r.who ?? '—', r.kind === 'invoice' ? h('span', { class: 'fx-dim' }, ` · ${statusWords(r.status)}`) : null),
|
|
276
|
+
h('td', { class: 'fx-dim' }, shortDate(r.date)),
|
|
277
|
+
h('td', { class: 'fx-num' }, money(r.totalCents)),
|
|
278
|
+
h('td', { class: 'fx-rec-list__mid' }, possible(r)));
|
|
279
|
+
})))));
|
|
280
|
+
drawBar();
|
|
125
281
|
}
|
|
282
|
+
draw();
|
|
283
|
+
}
|
|
126
284
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
285
|
+
// One gap, opened from the list (or from a job's Reconcile button).
|
|
286
|
+
async function renderGap(main, ui, id) {
|
|
287
|
+
const { h, flow, toast } = ui;
|
|
288
|
+
const icon = (name, label) => lucide(h, name, label);
|
|
289
|
+
const canAct = ['owner', 'admin'].includes(ui.me?.person?.role);
|
|
290
|
+
const params = new URLSearchParams(location.search);
|
|
291
|
+
const kind = params.get('kind') === 'invoice' || params.get('tab') === 'invoices' ? 'invoice' : 'job';
|
|
292
|
+
|
|
293
|
+
// The pager steps through the list as it was; opened from elsewhere, it
|
|
294
|
+
// steps through every gap of the same kind.
|
|
295
|
+
let order = readStored(sessionStorage, NAV_KEY, null)?.rows ?? [];
|
|
296
|
+
if (!order.some((r) => r.id === id)) {
|
|
297
|
+
const lists = await flow('/api/invoicing/reconcile').catch(() => null);
|
|
298
|
+
order = lists ? reconcileRows(lists).filter((r) => r.kind === kind).map(({ kind: k, id: i }) => ({ kind: k, id: i })) : [];
|
|
299
|
+
}
|
|
300
|
+
const at = order.findIndex((r) => r.id === id);
|
|
301
|
+
const step = (to, label, path) => {
|
|
302
|
+
const button = h('button', { class: 'fx-pager__step', type: 'button', disabled: !to, 'aria-label': label, title: label, onclick: () => ui.go(pathOf(to)) });
|
|
303
|
+
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>`;
|
|
304
|
+
return button;
|
|
305
|
+
};
|
|
306
|
+
const pager = h('nav', { class: 'fx-pager', 'aria-label': 'Reconcile' },
|
|
307
|
+
h('button', { class: 'fx-pager__back', type: 'button', onclick: () => ui.go('/reconcile') }, '← Back'),
|
|
308
|
+
h('span', { class: 'fx-pager__count' }, at >= 0 ? `${at + 1} of ${order.length}` : ''),
|
|
309
|
+
h('span', { class: 'fx-pager__steps' }, step(at > 0 ? order[at - 1] : null, 'Previous', CHEVRONS.prev), step(at >= 0 ? order[at + 1] : null, 'Next', CHEVRONS.next)));
|
|
310
|
+
const next = () => {
|
|
311
|
+
const rest = order.filter((r) => r.id !== id);
|
|
312
|
+
writeStored(sessionStorage, NAV_KEY, { ...readStored(sessionStorage, NAV_KEY, {}), rows: rest });
|
|
313
|
+
const to = rest[Math.min(Math.max(at, 0), rest.length - 1)];
|
|
314
|
+
ui.go(to ? pathOf(to) : '/reconcile');
|
|
315
|
+
};
|
|
137
316
|
|
|
317
|
+
const title = h('h1', { class: 'fx-title' }, kind === 'job' ? 'Job with no invoice' : 'Invoice with no job');
|
|
318
|
+
const sub = h('p', { class: 'fx-ph__sub' });
|
|
319
|
+
const actions = h('div', { class: 'fx-ph__actions' });
|
|
320
|
+
const results = h('div', { class: 'fx-rec__results' }, h('div', { class: 'fx-skeleton' }));
|
|
138
321
|
let timer = null;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
322
|
+
const find = findBox(h, {
|
|
323
|
+
label: kind === 'job' ? 'Find another invoice: number, client, Reference, amount' : 'Find another job: client, practitioner, case',
|
|
324
|
+
oninput: (words) => {
|
|
325
|
+
clearTimeout(timer);
|
|
326
|
+
timer = setTimeout(() => load(words), 300);
|
|
327
|
+
},
|
|
142
328
|
});
|
|
329
|
+
const card = h('section', { class: 'fx-rec fx-rec__gap', 'aria-label': 'Compare' },
|
|
330
|
+
pager,
|
|
331
|
+
h('header', { class: 'fx-ph' }, h('div', { class: 'fx-ph__text' }, title, sub), actions),
|
|
332
|
+
// The search sits where it does on Jobs and Invoices: the right of the
|
|
333
|
+
// toolbar over the rows it searches.
|
|
334
|
+
h('div', { class: 'fx-toolbar fx-toolbar--plain' },
|
|
335
|
+
h('p', { class: 'fx-rows__title' }, kind === 'job' ? 'Possible invoices' : 'Possible jobs'),
|
|
336
|
+
h('div', { class: 'fx-toolbar__end' }, find.element)),
|
|
337
|
+
results);
|
|
338
|
+
main.append(card);
|
|
339
|
+
|
|
143
340
|
|
|
144
341
|
async function act(run, done) {
|
|
145
|
-
for (const b of
|
|
342
|
+
for (const b of card.querySelectorAll('button')) b.disabled = true;
|
|
146
343
|
try {
|
|
147
344
|
await run();
|
|
148
|
-
|
|
345
|
+
toast(done, 'success');
|
|
149
346
|
next();
|
|
150
347
|
} catch (error) {
|
|
151
348
|
const reasons = error.details?.reasons;
|
|
152
|
-
|
|
153
|
-
for (const b of
|
|
349
|
+
toast(reasons?.length ? `Not done: ${reasons.join('; ')}.` : error.message, 'danger');
|
|
350
|
+
for (const b of card.querySelectorAll('button')) b.disabled = false;
|
|
154
351
|
}
|
|
155
352
|
}
|
|
156
353
|
const markOf = (mark) => (mark === 'same' ? h('span', { class: 'fx-rec__mark', 'data-mark': 'same', 'aria-label': 'Same' }, '✓')
|
|
@@ -191,29 +388,20 @@ export async function renderReconcile(main, ui, id = null) {
|
|
|
191
388
|
async function load(q = '') {
|
|
192
389
|
let body;
|
|
193
390
|
try {
|
|
194
|
-
body = await flow(`/api/invoicing/${kind}/${id}/reconcile${q.trim() ? `?q=${encodeURIComponent(q.trim())}` : ''}`);
|
|
391
|
+
body = await flow(`/api/invoicing/${kind === 'job' ? 'jobs' : 'invoices'}/${id}/reconcile${q.trim() ? `?q=${encodeURIComponent(q.trim())}` : ''}`);
|
|
195
392
|
} catch (error) {
|
|
196
393
|
results.replaceChildren(h('p', { class: 'fx-empty' }, error.message));
|
|
197
394
|
return;
|
|
198
395
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
else renderInvoice(body, position, q);
|
|
396
|
+
if (kind === 'job') renderJob(body, q);
|
|
397
|
+
else renderInvoice(body, q);
|
|
202
398
|
}
|
|
203
399
|
|
|
204
|
-
|
|
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
|
-
}
|
|
400
|
+
const skip = () => (order.length > 1 ? h('button', { class: 'fx-btn', type: 'button', onclick: next }, icon('skip-forward'), 'Skip') : null);
|
|
213
401
|
|
|
214
|
-
function renderJob({ job, invoices },
|
|
402
|
+
function renderJob({ job, invoices }, q) {
|
|
215
403
|
const system = job.sourceName ?? job.sourceSystem;
|
|
216
|
-
|
|
404
|
+
sub.replaceChildren([job.clientName ?? 'No client', job.practitioner, fullDate(job.jobDate), money(job.totalCents)].filter(Boolean).join(' · '));
|
|
217
405
|
const candidates = invoices.map((invoice) => {
|
|
218
406
|
const facts = invoiceCandidateFacts(invoice);
|
|
219
407
|
return {
|
|
@@ -223,30 +411,29 @@ export async function renderReconcile(main, ui, id = null) {
|
|
|
223
411
|
class: `fx-btn${invoice.best ? ' fx-btn--primary' : ''}`, type: 'button',
|
|
224
412
|
onclick: () => act(() => flow(`/api/invoicing/jobs/${job.id}/link`, { method: 'POST', body: JSON.stringify({ invoiceId: invoice.id }) }),
|
|
225
413
|
`Linked to ${invoice.number ?? 'the draft'}.`),
|
|
226
|
-
}, 'Link this invoice') : null,
|
|
414
|
+
}, icon('link-2'), 'Link this invoice') : null,
|
|
227
415
|
link: () => inFlow(`/invoices/${invoice.id}`, 'Open invoice'),
|
|
228
416
|
};
|
|
229
417
|
});
|
|
230
418
|
results.replaceChildren(...[
|
|
231
419
|
job.open ? null : h('p', { class: 'fx-notice' }, 'This job has an invoice already.'),
|
|
232
420
|
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.
|
|
421
|
+
: h('p', { class: 'fx-empty' }, q.trim() ? 'No Flow invoice matches the search.' : 'Flow found no invoice that may be for this job. Find one, or make it.'),
|
|
234
422
|
].flat().filter(Boolean));
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
h('
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}, 'Make invoice') : null));
|
|
423
|
+
actions.replaceChildren(...[
|
|
424
|
+
skip(),
|
|
425
|
+
canAct && job.open ? h('button', {
|
|
426
|
+
class: `fx-btn${candidates.some((c) => c.best) ? '' : ' fx-btn--primary'}`, type: 'button',
|
|
427
|
+
onclick: () => act(async () => {
|
|
428
|
+
if (job.unclear) await flow(`/api/invoicing/jobs/${job.id}/no-invoice`, { method: 'POST', body: '{}' });
|
|
429
|
+
await flow(`/api/invoicing/jobs/${job.id}/draft`, { method: 'POST', body: '{}' });
|
|
430
|
+
}, 'Draft invoice made.'),
|
|
431
|
+
}, icon('file-plus'), 'Make invoice') : null,
|
|
432
|
+
].filter(Boolean));
|
|
246
433
|
}
|
|
247
434
|
|
|
248
|
-
function renderInvoice({ invoice, jobs },
|
|
249
|
-
|
|
435
|
+
function renderInvoice({ invoice, jobs }, q) {
|
|
436
|
+
sub.replaceChildren([invoice.number ?? 'Draft', invoice.clientName, fullDate(invoice.date), money(invoice.totalCents)].filter(Boolean).join(' · '));
|
|
250
437
|
const candidates = jobs.map((job) => {
|
|
251
438
|
const facts = jobCandidateFacts(job);
|
|
252
439
|
return {
|
|
@@ -256,7 +443,7 @@ export async function renderReconcile(main, ui, id = null) {
|
|
|
256
443
|
class: `fx-btn${job.best ? ' fx-btn--primary' : ''}`, type: 'button',
|
|
257
444
|
onclick: () => act(() => flow(`/api/invoicing/jobs/${job.id}/link`, { method: 'POST', body: JSON.stringify({ invoiceId: invoice.id }) }),
|
|
258
445
|
'Job linked.'),
|
|
259
|
-
}, 'Link this job') : null,
|
|
446
|
+
}, icon('link-2'), 'Link this job') : null,
|
|
260
447
|
link: null,
|
|
261
448
|
};
|
|
262
449
|
});
|
|
@@ -265,10 +452,7 @@ export async function renderReconcile(main, ui, id = null) {
|
|
|
265
452
|
candidates.length ? compareView({ ours: invoiceFacts(invoice), oursTitle: 'This invoice', oursSub: 'Flow', candidates })
|
|
266
453
|
: h('p', { class: 'fx-empty' }, q.trim() ? 'No job matches the search.' : 'Flow found no job that may be for this invoice.'),
|
|
267
454
|
].flat().filter(Boolean));
|
|
268
|
-
|
|
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));
|
|
455
|
+
actions.replaceChildren(...[skip(), h('a', { class: 'fx-btn', href: `/invoices/${invoice.id}`, onclick: (event) => ui.go(event) }, icon('file-text'), 'Open invoice')].filter(Boolean));
|
|
272
456
|
}
|
|
273
457
|
|
|
274
458
|
await load();
|
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', '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']);
|
|
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', 'view-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';
|