@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.
@@ -1,10 +1,24 @@
1
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).
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
- const { h, flow, pageHeader } = ui;
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 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);
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 lists;
195
+ let rows;
87
196
  try {
88
- lists = await flow('/api/invoicing/reconcile');
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
- 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;
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
- 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);
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
- search.addEventListener('input', () => {
140
- clearTimeout(timer);
141
- timer = setTimeout(() => load(search.value), 300);
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 detail.querySelectorAll('button')) b.disabled = true;
342
+ for (const b of card.querySelectorAll('button')) b.disabled = true;
146
343
  try {
147
344
  await run();
148
- ui.toast(done, 'success');
345
+ toast(done, 'success');
149
346
  next();
150
347
  } catch (error) {
151
348
  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;
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
- const position = index >= 0 ? `${index + 1} of ${queue.length}` : '';
200
- if (tab === 'jobs') renderJob(body, position, q);
201
- else renderInvoice(body, position, q);
396
+ if (kind === 'job') renderJob(body, q);
397
+ else renderInvoice(body, q);
202
398
  }
203
399
 
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
- }
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 }, position, q) {
402
+ function renderJob({ job, invoices }, q) {
215
403
  const system = job.sourceName ?? job.sourceSystem;
216
- renderHead(position, job.clientName ?? 'No client', null);
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. Search for one, or make it.'),
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
- 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));
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 }, position, q) {
249
- renderHead(position, invoice.number ?? 'Draft invoice', inFlow(`/invoices/${invoice.id}`, 'Open invoice'));
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
- 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));
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();
@@ -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';