@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
package/server/ui/price-list.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// Settings, Price list (/price-list; server/core/price-list.js,
|
|
2
2
|
// docs/PRICE_LIST.md). Flow's own codes and prices, which work with no
|
|
3
|
-
// integration connected
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// The caller passes in app.js's shared pieces as `ui`.
|
|
3
|
+
// integration connected: one table row per code, with its price and tax, and
|
|
4
|
+
// the line price setting below. Edit opens a dialog with the code's fields, its
|
|
5
|
+
// links and, when something differs, Mark resolved. While an integration with
|
|
6
|
+
// its own price list is on, Reconcile (price-reconcile.js) says how many
|
|
7
|
+
// records need a person and opens the work. Everyone can read it; an owner or
|
|
8
|
+
// admin changes it. The caller passes in app.js's shared pieces as `ui`.
|
|
10
9
|
import { alertStrip } from '/assets/ui/alerts.js';
|
|
10
|
+
import { reconcileRows } from '/assets/ui/price-match.js';
|
|
11
11
|
|
|
12
12
|
const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/PRICE_LIST.md';
|
|
13
13
|
const TAX_LABELS = { GST: 'GST', GST_FREE: 'GST free' };
|
|
@@ -25,14 +25,16 @@ const toCents = (text) => {
|
|
|
25
25
|
export async function renderPriceList(main, ui) {
|
|
26
26
|
const { h, flow, toast, notice, dialog, pageHeader, me } = ui;
|
|
27
27
|
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
28
|
-
const host = h('div', { class: 'fx-
|
|
28
|
+
const host = h('div', { class: 'fx-price' }, h('div', { class: 'fx-skeleton' }));
|
|
29
29
|
const addButton = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled }, 'Add code');
|
|
30
|
-
const
|
|
30
|
+
const reconcileButton = h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', hidden: true, onclick: () => ui.go('/price-list/reconcile') }, 'Reconcile');
|
|
31
31
|
const alerts = alertStrip(ui, 'price-list');
|
|
32
|
-
main.append(pageHeader('Price list', { subtitle: 'The codes and prices that Flow puts on invoice lines.', actions: [
|
|
32
|
+
main.append(pageHeader('Price list', { subtitle: 'The codes and prices that Flow puts on invoice lines.', actions: [reconcileButton, addButton] }), alerts.element, host);
|
|
33
33
|
|
|
34
34
|
let data = null;
|
|
35
35
|
let query = '';
|
|
36
|
+
let tab = 'active';
|
|
37
|
+
let opened = new URLSearchParams(location.search).get('code');
|
|
36
38
|
const systemName = (id) => data.systems.find((s) => s.id === id)?.name ?? id;
|
|
37
39
|
|
|
38
40
|
async function send(path, method, payload, done = 'Saved.') {
|
|
@@ -50,22 +52,6 @@ export async function renderPriceList(main, ui) {
|
|
|
50
52
|
h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, name), hint),
|
|
51
53
|
h('div', { class: 'fx-set__control fx-rule__controls' }, control));
|
|
52
54
|
|
|
53
|
-
// One chip for each connected system: agrees, differs (with its price), or
|
|
54
|
-
// has no record for this code.
|
|
55
|
-
function chips(item) {
|
|
56
|
-
return data.systems.map((system) => {
|
|
57
|
-
const mine = item.findings.filter((f) => f.system === system.id);
|
|
58
|
-
const linked = item.links.some((l) => l.system === system.id);
|
|
59
|
-
if (mine.some((f) => f.kind === 'not_linked')) return h('span', { class: 'fx-status', 'data-tone': 'warn' }, `Not in ${system.name}`);
|
|
60
|
-
if (!linked) return null;
|
|
61
|
-
const price = mine.find((f) => f.kind === 'price');
|
|
62
|
-
if (price) return h('span', { class: 'fx-status', 'data-tone': 'danger' }, `${system.name} ${money(price.other)}`);
|
|
63
|
-
if (mine.some((f) => f.kind === 'tax')) return h('span', { class: 'fx-status', 'data-tone': 'danger' }, `${system.name} tax`);
|
|
64
|
-
if (mine.some((f) => f.kind === 'missing')) return h('span', { class: 'fx-status', 'data-tone': 'danger' }, `${system.name} removed`);
|
|
65
|
-
return h('span', { class: 'fx-status', 'data-tone': 'success' }, system.name);
|
|
66
|
-
}).filter(Boolean);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
55
|
const findingText = (f) => {
|
|
70
56
|
const name = systemName(f.system);
|
|
71
57
|
if (f.kind === 'price') return `${name} price is ${money(f.other)}. Flow price is ${money(f.flow)}.`;
|
|
@@ -141,33 +127,6 @@ export async function renderPriceList(main, ui) {
|
|
|
141
127
|
return load();
|
|
142
128
|
}
|
|
143
129
|
|
|
144
|
-
// A system's record that links to no code: a menu of codes to link it to,
|
|
145
|
-
// or "New code" to add it to the list as it is.
|
|
146
|
-
function unlinkedRow(system, record, items) {
|
|
147
|
-
const select = h('select', { class: 'fx-select fx-price__link', 'aria-label': `Code for ${record.name ?? record.code}`, disabled },
|
|
148
|
-
h('option', { value: '' }, 'Link to a code…'),
|
|
149
|
-
h('option', { value: 'new' }, 'Add as a new code'),
|
|
150
|
-
...items.filter((i) => !i.archivedAt).map((i) => h('option', { value: i.id }, `${i.code} · ${i.name} · ${money(i.unitAmountCents)}`)));
|
|
151
|
-
select.addEventListener('change', async () => {
|
|
152
|
-
if (!select.value) return;
|
|
153
|
-
select.disabled = true;
|
|
154
|
-
let itemId = select.value;
|
|
155
|
-
if (itemId === 'new') {
|
|
156
|
-
const made = await send('/api/invoicing/price-items', 'POST', {
|
|
157
|
-
code: record.code || record.id, name: record.name || record.code || record.id,
|
|
158
|
-
unitAmountCents: record.unitAmountCents ?? 0, taxCode: record.taxCode ?? 'GST',
|
|
159
|
-
}, null);
|
|
160
|
-
itemId = made?.item.id ?? null;
|
|
161
|
-
}
|
|
162
|
-
if (itemId && await send(`/api/invoicing/price-items/${itemId}/links`, 'POST', { system: system.id, externalId: record.id }, 'Linked. Jobs with this item update in a few minutes.')) return load();
|
|
163
|
-
select.disabled = disabled;
|
|
164
|
-
select.value = '';
|
|
165
|
-
return null;
|
|
166
|
-
});
|
|
167
|
-
const hint = [record.code, money(record.unitAmountCents), TAX_LABELS[record.taxCode]].filter(Boolean).join(' · ');
|
|
168
|
-
return row(record.name ?? record.code ?? record.id, h('span', { class: 'fx-set__hint' }, hint), select);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
130
|
async function linePriceRow() {
|
|
172
131
|
const select = h('select', { class: 'fx-select', 'aria-label': 'Line price from', disabled }, LINE_PRICES.map((p) => h('option', { value: p.value }, p.label)));
|
|
173
132
|
try {
|
|
@@ -188,40 +147,46 @@ export async function renderPriceList(main, ui) {
|
|
|
188
147
|
|
|
189
148
|
function draw() {
|
|
190
149
|
const words = query.trim().toLowerCase();
|
|
191
|
-
const
|
|
192
|
-
const
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
};
|
|
150
|
+
const live = data.items.filter((i) => !i.archivedAt);
|
|
151
|
+
const archived = data.items.filter((i) => i.archivedAt);
|
|
152
|
+
const shown = (tab === 'archived' ? archived : live).filter((i) => !words || `${i.code} ${i.name}`.toLowerCase().includes(words));
|
|
153
|
+
const todo = data.systems.length ? reconcileRows(data).length : 0;
|
|
154
|
+
reconcileButton.hidden = !data.systems.length;
|
|
155
|
+
reconcileButton.replaceChildren('Reconcile', todo ? h('span', { class: 'fx-tabs__count', 'data-tone': 'warn' }, String(todo)) : null);
|
|
156
|
+
reconcileButton.title = todo ? `${todo} records need a person` : 'Every record has a code, and every price agrees';
|
|
157
|
+
|
|
200
158
|
const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a code', value: query, 'aria-label': 'Find a code' });
|
|
201
159
|
search.addEventListener('input', () => { query = search.value; draw(); search.focus(); search.setSelectionRange(query.length, query.length); });
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
160
|
+
const tabs = h('div', { class: 'fx-tabs', role: 'tablist' }, [['active', 'Active', live.length], ['archived', 'Archived', archived.length]].map(([id, label, count]) => h('button', {
|
|
161
|
+
type: 'button', role: 'tab', 'aria-selected': String(tab === id), onclick: () => { tab = id; draw(); },
|
|
162
|
+
}, label, h('span', { class: 'fx-tabs__count' }, String(count)))));
|
|
163
|
+
const itemRow = (item) => h('tr', { onclick: () => edit(item), tabindex: '0', onkeydown: (event) => { if (event.key === 'Enter') edit(item); } },
|
|
164
|
+
h('td', { class: 'fx-price__code' }, item.code),
|
|
165
|
+
h('td', {}, item.name),
|
|
166
|
+
h('td', { class: 'fx-num' }, money(item.unitAmountCents)),
|
|
167
|
+
h('td', { class: 'fx-dim' }, TAX_LABELS[item.taxCode] ?? item.taxCode));
|
|
168
|
+
const table = shown.length
|
|
169
|
+
? h('div', { class: 'fx-price__scroll' }, h('table', { class: 'fx-table fx-table--rows fx-price__table' },
|
|
170
|
+
h('thead', {}, h('tr', {}, h('th', {}, 'Code'), h('th', {}, 'Name'), h('th', { class: 'fx-num' }, 'Price'), h('th', {}, 'Tax'))),
|
|
171
|
+
h('tbody', {}, shown.map(itemRow))))
|
|
172
|
+
: h('p', { class: 'fx-empty' }, words ? 'No code matches.' : tab === 'archived' ? 'No archived codes.' : 'No codes yet. Add the first one.');
|
|
205
173
|
const sections = [
|
|
206
174
|
disabled ? notice('Only an owner or admin can change these.', 'info') : null,
|
|
207
|
-
h('
|
|
208
|
-
|
|
175
|
+
h('div', { class: 'fx-toolbar fx-price__head' }, tabs, search),
|
|
176
|
+
table,
|
|
209
177
|
];
|
|
210
|
-
|
|
211
|
-
if (!records.length) continue;
|
|
212
|
-
const system = data.systems.find((s) => s.id === id);
|
|
213
|
-
sections.push(h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, `${system.name} · not on the list · ${records.length}`),
|
|
214
|
-
...records.slice(0, 200).map((r) => unlinkedRow(system, r, data.items)),
|
|
215
|
-
records.length > 200 ? h('p', { class: 'fx-dim' }, `${records.length - 200} more. Link these first.`) : null));
|
|
216
|
-
}
|
|
217
|
-
if (archived.length) {
|
|
218
|
-
sections.push(h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, `Archived · ${archived.length}`), ...archived.map(itemRow)));
|
|
219
|
-
}
|
|
220
|
-
const settings = h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, 'Invoice lines'), h('div', { class: 'fx-skeleton' }));
|
|
178
|
+
const settings = h('section', { class: 'fx-set fx-price__settings' }, h('h2', { class: 'fx-set__title' }, 'Invoice lines'), h('div', { class: 'fx-skeleton' }));
|
|
221
179
|
linePriceRow().then((r) => settings.replaceChildren(settings.firstChild, r,
|
|
222
180
|
h('p', { class: 'fx-dim', style: { margin: '8px 0 0' } }, h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How the price list works'))));
|
|
223
181
|
sections.push(settings);
|
|
224
182
|
host.replaceChildren(...sections.filter(Boolean));
|
|
183
|
+
// Opened from Reconcile's "Edit code": that code's dialog, once.
|
|
184
|
+
if (opened) {
|
|
185
|
+
const item = data.items.find((i) => i.id === opened);
|
|
186
|
+
opened = null;
|
|
187
|
+
window.history.replaceState(null, '', '/price-list');
|
|
188
|
+
if (item) edit(item);
|
|
189
|
+
}
|
|
225
190
|
}
|
|
226
191
|
|
|
227
192
|
async function load() {
|
|
@@ -235,13 +200,5 @@ export async function renderPriceList(main, ui) {
|
|
|
235
200
|
}
|
|
236
201
|
|
|
237
202
|
addButton.addEventListener('click', () => edit(null));
|
|
238
|
-
checkButton.addEventListener('click', async () => {
|
|
239
|
-
checkButton.disabled = true;
|
|
240
|
-
const body = await send('/api/invoicing/price-list/check', 'POST', {}, null);
|
|
241
|
-
if (body) toast(body.summary.differing ? `${body.summary.differing} codes differ.` : 'Every linked price agrees.', body.summary.differing ? 'info' : 'success');
|
|
242
|
-
checkButton.disabled = disabled;
|
|
243
|
-
alerts.refresh();
|
|
244
|
-
await load();
|
|
245
|
-
});
|
|
246
203
|
await load();
|
|
247
204
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Likely Flow codes for a connected system's record (Price list › Reconcile).
|
|
2
|
+
// Pure: the page passes the record and Flow's codes. A code's score comes
|
|
3
|
+
// from its name (and code) against the record's name, then its price and tax.
|
|
4
|
+
// "Sure" means name, price and tax all agree and no other code comes close,
|
|
5
|
+
// so a person can link every sure one in one step.
|
|
6
|
+
|
|
7
|
+
const IGNORED = new Set(['a', 'an', 'and', 'the', 'of', 'for', 'to', 'with', 'per', 'than', 'more', 'unit', 'each']);
|
|
8
|
+
|
|
9
|
+
export function nameWords(text) {
|
|
10
|
+
return String(text ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').split(' ')
|
|
11
|
+
.filter((w) => w && !IGNORED.has(w));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Two words agree when they are the same, or one starts the other and the
|
|
15
|
+
// shorter has 4 letters or more ("frame" and "framework").
|
|
16
|
+
const wordsAgree = (a, b) => a === b || (Math.min(a.length, b.length) >= 4 && (a.startsWith(b) || b.startsWith(a)));
|
|
17
|
+
|
|
18
|
+
// 0 to 1: how much of both names the shared words cover (Dice).
|
|
19
|
+
export function nameScore(a, b) {
|
|
20
|
+
const left = nameWords(a);
|
|
21
|
+
const right = nameWords(b);
|
|
22
|
+
if (!left.length || !right.length) return 0;
|
|
23
|
+
const free = [...right];
|
|
24
|
+
let shared = 0;
|
|
25
|
+
for (const word of left) {
|
|
26
|
+
const at = free.findIndex((other) => wordsAgree(word, other));
|
|
27
|
+
if (at >= 0) { shared += 1; free.splice(at, 1); }
|
|
28
|
+
}
|
|
29
|
+
return (2 * shared) / (left.length + right.length);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const SAME_NAME = 0.8;
|
|
33
|
+
const LIKELY_NAME = 0.5;
|
|
34
|
+
|
|
35
|
+
const fact = (left, right, same) => (left == null || right == null || left === '' || right === '' ? null : same ? 'same' : 'differs');
|
|
36
|
+
|
|
37
|
+
// The codes that may be this record, best first: { item, score, name, price,
|
|
38
|
+
// tax, sure }. name, price and tax are 'same', 'differs' or null (one side
|
|
39
|
+
// has no value). `limit` keeps the first few.
|
|
40
|
+
export function rankCodes(record, items, { limit = 3 } = {}) {
|
|
41
|
+
const ranked = [];
|
|
42
|
+
for (const item of items) {
|
|
43
|
+
if (item.archivedAt) continue;
|
|
44
|
+
const sameCode = Boolean(record.code) && String(record.code).trim().toLowerCase() === String(item.code).trim().toLowerCase();
|
|
45
|
+
const score = sameCode ? 1 : Math.max(nameScore(record.name, item.name), nameScore(record.name ?? record.code, item.code) * 0.9);
|
|
46
|
+
const price = fact(record.unitAmountCents, item.unitAmountCents, record.unitAmountCents === item.unitAmountCents);
|
|
47
|
+
const tax = fact(record.taxCode, item.taxCode, record.taxCode === item.taxCode);
|
|
48
|
+
if (score < LIKELY_NAME && !(price === 'same' && score >= 0.3)) continue;
|
|
49
|
+
ranked.push({ item, score, name: score >= SAME_NAME ? 'same' : 'differs', price, tax, rank: score * 2 + (price === 'same' ? 0.6 : 0) + (tax === 'same' ? 0.2 : 0) });
|
|
50
|
+
}
|
|
51
|
+
ranked.sort((a, b) => b.rank - a.rank || a.item.code.localeCompare(b.item.code));
|
|
52
|
+
const [best, next] = ranked;
|
|
53
|
+
if (best) best.sure = best.name === 'same' && best.price === 'same' && best.tax !== 'differs' && !(next && next.rank >= best.rank - 0.3);
|
|
54
|
+
return ranked.slice(0, limit).map(({ rank: _rank, ...rest }) => ({ sure: false, ...rest }));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The search under "Other code": codes whose code or name holds every word.
|
|
58
|
+
export function findCodes(query, items, { limit = 8 } = {}) {
|
|
59
|
+
const words = String(query ?? '').toLowerCase().split(/\s+/).filter(Boolean);
|
|
60
|
+
if (!words.length) return [];
|
|
61
|
+
return items.filter((i) => !i.archivedAt && words.every((w) => `${i.code} ${i.name}`.toLowerCase().includes(w))).slice(0, limit);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// What a person still has to do on the price list, from GET /price-list:
|
|
65
|
+
// each system record that links to no code ('link'), then each code that
|
|
66
|
+
// differs from a linked record ('differs'). Records come sure first, then
|
|
67
|
+
// likely, then with no likely code. Every row has a stable key.
|
|
68
|
+
export function reconcileRows(data) {
|
|
69
|
+
const systems = new Map(data.systems.map((s) => [s.id, s]));
|
|
70
|
+
const rows = [];
|
|
71
|
+
for (const { system, records } of data.unlinked) {
|
|
72
|
+
const owner = systems.get(system);
|
|
73
|
+
if (!owner) continue;
|
|
74
|
+
for (const record of records) {
|
|
75
|
+
const candidates = rankCodes(record, data.items);
|
|
76
|
+
rows.push({ key: `link:${system}:${record.id}`, kind: 'link', system: owner, record, candidates, sure: Boolean(candidates[0]?.sure) });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Sure ones first, then likely ones, then records no code fits.
|
|
80
|
+
const order = (row) => (row.sure ? 0 : row.candidates[0] ? 1 : 2);
|
|
81
|
+
rows.sort((a, b) => order(a) - order(b));
|
|
82
|
+
for (const item of data.items) {
|
|
83
|
+
if (item.archivedAt || !item.findings?.length) continue;
|
|
84
|
+
for (const system of new Set(item.findings.map((f) => f.system))) {
|
|
85
|
+
const owner = systems.get(system);
|
|
86
|
+
if (!owner) continue;
|
|
87
|
+
const findings = item.findings.filter((f) => f.system === system);
|
|
88
|
+
const link = item.links.find((l) => l.system === system && findings.some((f) => f.externalId === String(l.externalId))) ?? null;
|
|
89
|
+
rows.push({ key: `differs:${system}:${item.id}`, kind: 'differs', system: owner, item, findings, link, record: link?.record ?? null, sure: false });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return rows;
|
|
93
|
+
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// Price list › Reconcile (/price-list/reconcile). Only while an integration
|
|
2
|
+
// with its own price list is on (Xero items, Seazona products). It lays the
|
|
3
|
+
// connected systems' records against Flow's own codes, in the two views of
|
|
4
|
+
// Xero › Import › Review:
|
|
5
|
+
// card one record beside its likely Flow code, one fact per row, ✓ or ≠
|
|
6
|
+
// between them; the invoice pager steps through them. Link, Other
|
|
7
|
+
// match, Find a code, Add as new code, Skip.
|
|
8
|
+
// list every record and code to check, ticked rows acted on together from
|
|
9
|
+
// the bulk bar.
|
|
10
|
+
// A record with no link offers likely codes (price-match.js). A code that
|
|
11
|
+
// differs from its linked record is an alert only: Mark resolved, or fix it
|
|
12
|
+
// at its source. It reads Flow's copies only: no call to Xero or Seazona.
|
|
13
|
+
// The price check itself runs on each pipeline pass, so there is no button.
|
|
14
|
+
import { bulkBar } from '/assets/ui/bulk-bar.js';
|
|
15
|
+
import { lucide } from '/assets/ui/custom-fields.js';
|
|
16
|
+
import { findCodes, nameScore, reconcileRows, SAME_NAME } from '/assets/ui/price-match.js';
|
|
17
|
+
|
|
18
|
+
const TAX_LABELS = { GST: 'GST', GST_FREE: 'GST free' };
|
|
19
|
+
const money = (cents) => (cents === null || cents === undefined ? '—' : `$${(cents / 100).toFixed(2)}`);
|
|
20
|
+
const taxWords = (code) => (code ? TAX_LABELS[code] ?? code : '—');
|
|
21
|
+
const LOGOS = { xero: '/assets/ui/logos/xero.svg', seazona: '/assets/ui/logos/seazona.svg' };
|
|
22
|
+
|
|
23
|
+
export async function renderPriceReconcile(main, ui) {
|
|
24
|
+
const { h, flow, toast } = ui;
|
|
25
|
+
const canAct = ['owner', 'admin'].includes(ui.me?.person?.role);
|
|
26
|
+
const icon = (name, label) => lucide(h, name, label);
|
|
27
|
+
let view = new URLSearchParams(location.search).get('view') === 'list' ? 'list' : 'card';
|
|
28
|
+
const host = h('div', { class: 'fx-xi fx-prc' }, h('p', { class: 'fx-dim' }, 'Loading…'));
|
|
29
|
+
const subtitle = h('span', {});
|
|
30
|
+
const toggle = (name, label, glyph) => h('button', {
|
|
31
|
+
class: 'fx-xi__view', type: 'button', role: 'tab', 'aria-selected': String(view === name), 'aria-label': label, title: label, 'data-view': name,
|
|
32
|
+
onclick: () => setView(name),
|
|
33
|
+
}, icon(glyph));
|
|
34
|
+
const bulk = bulkBar(ui, { label: 'Ticked records' });
|
|
35
|
+
main.append(
|
|
36
|
+
h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
|
|
37
|
+
h('a', { href: '/price-list', onclick: (event) => ui.go(event) }, 'Price list'), h('span', { 'aria-hidden': 'true' }, '›'), h('span', {}, 'Reconcile')),
|
|
38
|
+
ui.pageHeader('Reconcile prices', {
|
|
39
|
+
subtitle,
|
|
40
|
+
actions: [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'))],
|
|
41
|
+
}),
|
|
42
|
+
host, bulk.element);
|
|
43
|
+
|
|
44
|
+
let data = null;
|
|
45
|
+
let rows = [];
|
|
46
|
+
let at = 0;
|
|
47
|
+
let pick = 0;
|
|
48
|
+
let chosen = null;
|
|
49
|
+
|
|
50
|
+
function setView(name) {
|
|
51
|
+
view = name;
|
|
52
|
+
window.history.replaceState(null, '', view === 'list' ? '/price-list/reconcile?view=list' : '/price-list/reconcile');
|
|
53
|
+
for (const b of main.querySelectorAll('.fx-xi__view')) b.setAttribute('aria-selected', String(b.dataset.view === view));
|
|
54
|
+
draw();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function load() {
|
|
58
|
+
try {
|
|
59
|
+
data = await flow('/api/invoicing/price-list');
|
|
60
|
+
} catch (error) {
|
|
61
|
+
host.replaceChildren(h('p', { class: 'fx-empty' }, error.message));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
rows = reconcileRows(data);
|
|
65
|
+
at = Math.min(at, Math.max(0, rows.length - 1));
|
|
66
|
+
draw();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function call(path, method, payload) {
|
|
70
|
+
try {
|
|
71
|
+
return await flow(path, { method, ...(payload ? { body: JSON.stringify(payload) } : {}) });
|
|
72
|
+
} catch (error) {
|
|
73
|
+
toast(error.message, 'danger');
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Links many records at once: one call, one remap. An accounting system
|
|
79
|
+
// takes one record for each code, so a second record for the same code
|
|
80
|
+
// waits for a person.
|
|
81
|
+
async function linkMany(pairs) {
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
const links = [];
|
|
84
|
+
for (const { row, item } of pairs) {
|
|
85
|
+
const key = `${row.system.id}:${item.id}`;
|
|
86
|
+
if (row.system.kind === 'accounting' && seen.has(key)) continue;
|
|
87
|
+
seen.add(key);
|
|
88
|
+
links.push({ priceItemId: item.id, system: row.system.id, externalId: String(row.record.id) });
|
|
89
|
+
}
|
|
90
|
+
if (!links.length) return 0;
|
|
91
|
+
const body = await call('/api/invoicing/price-links', 'POST', { links });
|
|
92
|
+
return body?.linked ?? 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const newCodeOf = (record) => ({
|
|
96
|
+
code: String(record.code || record.id).slice(0, 30), name: String(record.name || record.code || record.id).slice(0, 200),
|
|
97
|
+
unitAmountCents: record.unitAmountCents ?? 0, taxCode: record.taxCode ?? 'GST',
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
async function addAsNew(row) {
|
|
101
|
+
const draft = newCodeOf(row.record);
|
|
102
|
+
const input = (name, value, label, extra = {}) => h('label', { class: 'fx-field' }, h('span', { class: 'fx-label' }, label),
|
|
103
|
+
h('input', { class: 'fx-input', name, value, required: true, ...extra }));
|
|
104
|
+
const tax = h('select', { class: 'fx-select', name: 'taxCode' }, Object.entries(TAX_LABELS).map(([value, label]) => h('option', { value, selected: value === draft.taxCode }, label)));
|
|
105
|
+
const form = await ui.dialog({
|
|
106
|
+
title: 'Add as new code',
|
|
107
|
+
body: h('div', { class: 'fx-fields' },
|
|
108
|
+
input('code', draft.code, 'Code', { maxlength: '30' }), input('name', draft.name, 'Name', { maxlength: '200' }),
|
|
109
|
+
input('price', (draft.unitAmountCents / 100).toFixed(2), 'Price, before tax', { inputmode: 'decimal' }),
|
|
110
|
+
h('label', { class: 'fx-field' }, h('span', { class: 'fx-label' }, 'Tax'), tax)),
|
|
111
|
+
confirm: 'Add and link',
|
|
112
|
+
});
|
|
113
|
+
if (!form) return;
|
|
114
|
+
const cents = Math.round(Number(String(form.get('price')).replace(/[$,\s]/g, '')) * 100);
|
|
115
|
+
if (!Number.isInteger(cents) || cents < 0) { toast('Write the price as a number, for example 55.00.', 'danger'); return; }
|
|
116
|
+
const made = await call('/api/invoicing/price-items', 'POST', { code: form.get('code'), name: form.get('name'), unitAmountCents: cents, taxCode: form.get('taxCode') });
|
|
117
|
+
if (!made) return;
|
|
118
|
+
if (await linkMany([{ row, item: made.item }])) toast(`Added ${made.item.code} and linked.`, 'success');
|
|
119
|
+
await load();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function resolve(row) {
|
|
123
|
+
const body = await call(`/api/invoicing/price-items/${row.item.id}/resolve`, 'POST', {});
|
|
124
|
+
if (body) toast(body.state === 'agree' ? 'Prices agree now.' : 'Marked resolved.', 'success');
|
|
125
|
+
return body;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function draw() {
|
|
129
|
+
if (!data) return;
|
|
130
|
+
bulk.draw({ count: 0 });
|
|
131
|
+
if (!data.systems.length) {
|
|
132
|
+
subtitle.replaceChildren('');
|
|
133
|
+
host.replaceChildren(h('p', { class: 'fx-empty' }, 'No integration with a price list is on. Flow uses its own list alone.'),
|
|
134
|
+
h('a', { href: '/price-list', onclick: (event) => ui.go(event) }, '← Price list'));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (!rows.length) {
|
|
138
|
+
subtitle.replaceChildren('');
|
|
139
|
+
host.replaceChildren(h('div', { class: 'fx-xi__done' }, icon('check-check'), h('p', {}, 'Every record has a code, and every price agrees.'),
|
|
140
|
+
h('a', { href: '/price-list', onclick: (event) => ui.go(event) }, 'Price list')));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (view === 'list') drawList();
|
|
144
|
+
else drawCard();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const systemMark = (system) => (LOGOS[system.id] ? h('img', { class: 'fx-xi__mark', src: LOGOS[system.id], alt: system.name }) : h('span', { class: 'fx-strong' }, system.name));
|
|
148
|
+
const flowMark = () => h('img', { class: 'fx-xi__mark', src: '/assets/ui/flow-mark.svg', alt: 'Flow' });
|
|
149
|
+
const mark = (state) => (state === 'same' ? h('span', { class: 'fx-xi__agree', 'data-state': 'same' }, icon('equal', 'Same'))
|
|
150
|
+
: state === 'differs' ? h('span', { class: 'fx-xi__agree', 'data-state': 'differs' }, icon('equal-not', 'Different')) : h('span', {}));
|
|
151
|
+
const agree = (left, right) => (left == null || right == null || left === '' ? null : left === right ? 'same' : 'differs');
|
|
152
|
+
|
|
153
|
+
// The bulk shortcut above the card: every sure one at once, or the list.
|
|
154
|
+
function bulkStrip() {
|
|
155
|
+
const sure = rows.filter((r) => r.sure);
|
|
156
|
+
return h('div', { class: 'fx-xi__bulk' },
|
|
157
|
+
icon('list-checks'),
|
|
158
|
+
h('span', { class: 'fx-xi__bulk-text' }, `${rows.length} to check`),
|
|
159
|
+
canAct && sure.length ? h('button', { class: 'fx-btn fx-btn--sm fx-btn--primary', type: 'button', onclick: async (event) => {
|
|
160
|
+
event.currentTarget.disabled = true;
|
|
161
|
+
const linked = await linkMany(sure.map((row) => ({ row, item: row.candidates[0].item })));
|
|
162
|
+
if (linked) toast(`${linked} linked.`, 'success');
|
|
163
|
+
await load();
|
|
164
|
+
} }, icon('check-check'), `Link ${sure.length} sure`) : null,
|
|
165
|
+
h('button', { class: 'fx-btn fx-btn--sm', type: 'button', onclick: () => setView('list') }, icon('list-checks'), 'Tick many'));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// The invoice pager: Back to the price list, "n of N", previous and next.
|
|
169
|
+
function pager() {
|
|
170
|
+
const step = (to, label, path) => {
|
|
171
|
+
const button = h('button', {
|
|
172
|
+
class: 'fx-pager__step', type: 'button', disabled: to < 0 || to >= rows.length, 'aria-label': label, title: label,
|
|
173
|
+
onclick: () => { at = to; pick = 0; chosen = null; drawCard(); },
|
|
174
|
+
});
|
|
175
|
+
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>`;
|
|
176
|
+
return button;
|
|
177
|
+
};
|
|
178
|
+
return h('nav', { class: 'fx-pager', 'aria-label': 'Records to check' },
|
|
179
|
+
h('button', { class: 'fx-pager__back', type: 'button', onclick: () => ui.go('/price-list') }, '← Back'),
|
|
180
|
+
h('span', { class: 'fx-pager__count' }, `${at + 1} of ${rows.length}`),
|
|
181
|
+
h('span', { class: 'fx-pager__steps' }, step(at - 1, 'Previous', 'M14.5 6l-6 6 6 6'), step(at + 1, 'Next', 'M9.5 6l6 6-6 6')));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// One record (or one differing code) at a time.
|
|
185
|
+
function drawCard() {
|
|
186
|
+
const row = rows[at];
|
|
187
|
+
subtitle.replaceChildren(`${rows.length} to check`);
|
|
188
|
+
const next = () => { at = (at + 1) % rows.length; pick = 0; chosen = null; drawCard(); };
|
|
189
|
+
const record = row.record;
|
|
190
|
+
const item = row.kind === 'differs' ? row.item : chosen ?? row.candidates[pick]?.item ?? null;
|
|
191
|
+
const facts = [
|
|
192
|
+
['hash', 'Code', h('span', { class: 'fx-num' }, record?.code ?? record?.id ?? '—'), h('span', { class: 'fx-num' }, item?.code ?? '—'), null],
|
|
193
|
+
['tag', 'Name', record?.name ?? '—', item?.name ?? '—', item && record ? (row.kind === 'link' && !chosen ? row.candidates[pick]?.name : null) ?? (record.name && item.name ? (nameScore(record.name, item.name) >= SAME_NAME ? 'same' : 'differs') : null) : null],
|
|
194
|
+
['dollar-sign', 'Price', h('span', { class: 'fx-num' }, money(record?.unitAmountCents)), h('span', { class: 'fx-num' }, money(item?.unitAmountCents)), item ? agree(record?.unitAmountCents, item.unitAmountCents) : null],
|
|
195
|
+
['percent', 'Tax', taxWords(record?.taxCode), taxWords(item?.taxCode), item ? agree(record?.taxCode, item.taxCode) : null],
|
|
196
|
+
['circle-dot', 'Status',
|
|
197
|
+
row.kind === 'link' ? h('span', { class: 'fx-status', 'data-tone': 'warn' }, 'No code') : record ? h('span', { class: 'fx-status', 'data-tone': 'success' }, 'Linked') : h('span', { class: 'fx-status', 'data-tone': 'danger' }, `Not in ${row.system.name}`),
|
|
198
|
+
row.kind === 'link' ? (item ? h('span', { class: 'fx-status', 'data-tone': row.candidates[pick]?.sure && !chosen ? 'success' : 'neutral' }, chosen ? 'Your pick' : row.candidates[pick]?.sure ? 'Sure match' : 'Likely') : h('span', { class: 'fx-dim' }, 'No likely code'))
|
|
199
|
+
: h('span', { class: 'fx-status', 'data-tone': 'warn' }, 'Differs'), null],
|
|
200
|
+
];
|
|
201
|
+
const table = h('table', { class: 'fx-xi-cmp' },
|
|
202
|
+
h('thead', {}, h('tr', {}, h('th', {}), h('th', {}, systemMark(row.system)), h('th', {}), h('th', {}, flowMark()))),
|
|
203
|
+
h('tbody', {}, facts.map(([glyph, label, left, right, state]) => h('tr', {},
|
|
204
|
+
h('th', { scope: 'row' }, icon(glyph, label)),
|
|
205
|
+
h('td', {}, left), h('td', { class: 'fx-xi-cmp__mid' }, mark(state)), h('td', {}, right)))));
|
|
206
|
+
|
|
207
|
+
const acts = [];
|
|
208
|
+
const busy = (button, run) => async () => {
|
|
209
|
+
button.disabled = true;
|
|
210
|
+
try { await run(); } finally { button.disabled = false; }
|
|
211
|
+
};
|
|
212
|
+
if (row.kind === 'link') {
|
|
213
|
+
const count = row.candidates.length;
|
|
214
|
+
if (count > 1 && !chosen) {
|
|
215
|
+
acts.push(h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', title: 'Other match', 'aria-label': 'Other match', onclick: () => { pick = (pick + 1) % count; drawCard(); } },
|
|
216
|
+
icon('arrow-left-right'), `${pick + 1}/${count}`));
|
|
217
|
+
}
|
|
218
|
+
acts.push(h('button', { class: 'fx-btn', type: 'button', onclick: next }, icon('skip-forward'), 'Skip'));
|
|
219
|
+
if (canAct) {
|
|
220
|
+
const add = h('button', { class: 'fx-btn', type: 'button' }, icon('plus'), 'Add as new code');
|
|
221
|
+
add.onclick = busy(add, () => addAsNew(row));
|
|
222
|
+
acts.push(add);
|
|
223
|
+
if (item) {
|
|
224
|
+
const link = h('button', { class: 'fx-btn fx-btn--primary', type: 'button' }, icon('link-2'), 'Link');
|
|
225
|
+
link.onclick = busy(link, async () => {
|
|
226
|
+
if (await linkMany([{ row, item }])) toast(`Linked to ${item.code}.`, 'success');
|
|
227
|
+
pick = 0;
|
|
228
|
+
chosen = null;
|
|
229
|
+
await load();
|
|
230
|
+
});
|
|
231
|
+
acts.push(link);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
acts.push(h('button', { class: 'fx-btn', type: 'button', onclick: next }, icon('skip-forward'), 'Skip'));
|
|
236
|
+
acts.push(h('button', { class: 'fx-btn', type: 'button', onclick: () => ui.go(`/price-list?code=${row.item.id}`) }, icon('pencil'), 'Edit code'));
|
|
237
|
+
if (canAct && row.link && !row.record) {
|
|
238
|
+
const unlink = h('button', { class: 'fx-btn', type: 'button' }, icon('unlink'), 'Remove link');
|
|
239
|
+
unlink.onclick = busy(unlink, async () => {
|
|
240
|
+
if (await call(`/api/invoicing/price-links/${row.link.id}`, 'DELETE')) toast('Link removed.', 'success');
|
|
241
|
+
await load();
|
|
242
|
+
});
|
|
243
|
+
acts.push(unlink);
|
|
244
|
+
}
|
|
245
|
+
if (canAct) {
|
|
246
|
+
const done = h('button', { class: 'fx-btn fx-btn--primary', type: 'button' }, icon('check'), 'Mark resolved');
|
|
247
|
+
done.onclick = busy(done, async () => { await resolve(row); await load(); });
|
|
248
|
+
acts.push(done);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Find a code: any Flow code by code or name, when no likely one fits.
|
|
253
|
+
const finder = row.kind === 'link' ? (() => {
|
|
254
|
+
const results = h('div', { class: 'fx-prc__found', role: 'listbox', 'aria-label': 'Codes found' });
|
|
255
|
+
const input = h('input', { class: 'fx-input fx-xi__search', type: 'search', autocomplete: 'off', placeholder: 'Find another code: code or name', 'aria-label': 'Find another code' });
|
|
256
|
+
input.addEventListener('input', () => {
|
|
257
|
+
results.replaceChildren(...findCodes(input.value, data.items).map((code) => h('button', {
|
|
258
|
+
class: 'fx-pop__item', type: 'button', role: 'option',
|
|
259
|
+
onclick: () => { chosen = code; drawCard(); },
|
|
260
|
+
}, h('span', { class: 'fx-num' }, code.code), h('span', {}, code.name), h('span', { class: 'fx-dim fx-num' }, money(code.unitAmountCents)))));
|
|
261
|
+
});
|
|
262
|
+
return h('div', { class: 'fx-prc__finder' }, h('div', { class: 'fx-xi__bulk' }, icon('search'), input), results);
|
|
263
|
+
})() : null;
|
|
264
|
+
|
|
265
|
+
host.replaceChildren(...[pager(), bulkStrip(), table, finder, h('div', { class: 'fx-xi__acts' }, acts)].filter(Boolean));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Every record and code to check; ticked rows go from the bulk bar.
|
|
269
|
+
function drawList() {
|
|
270
|
+
subtitle.replaceChildren(`${rows.length} to check`);
|
|
271
|
+
const ticked = new Set();
|
|
272
|
+
const boxes = new Map();
|
|
273
|
+
const picked = () => rows.filter((r) => ticked.has(r.key));
|
|
274
|
+
const clear = () => { ticked.clear(); for (const b of boxes.values()) b.checked = false; all.checked = false; drawBar(); };
|
|
275
|
+
const drawBar = () => {
|
|
276
|
+
const chosenRows = picked();
|
|
277
|
+
const linkable = chosenRows.filter((r) => r.kind === 'link' && r.candidates[0]);
|
|
278
|
+
const unlinked = chosenRows.filter((r) => r.kind === 'link');
|
|
279
|
+
const differing = chosenRows.filter((r) => r.kind === 'differs');
|
|
280
|
+
bulk.draw({
|
|
281
|
+
count: canAct ? chosenRows.length : 0,
|
|
282
|
+
onClear: clear,
|
|
283
|
+
groups: [[
|
|
284
|
+
{ icon: 'link-2', label: 'Link', title: 'Link each to its likely code', primary: true, run: async () => {
|
|
285
|
+
if (!linkable.length) { toast('No ticked record has a likely code. Use Add as new codes, or one at a time.', 'warn'); return; }
|
|
286
|
+
const linked = await linkMany(linkable.map((row) => ({ row, item: row.candidates[0].item })));
|
|
287
|
+
if (linked) toast(linked === unlinked.length ? `${linked} linked.` : `${linked} linked. The rest stay here.`, 'success');
|
|
288
|
+
await load();
|
|
289
|
+
} },
|
|
290
|
+
{ icon: 'plus', label: 'Add as new codes', title: 'Add each ticked record as a new code, then link it', run: async () => {
|
|
291
|
+
if (!unlinked.length) { toast('Tick records with no code first.', 'warn'); return; }
|
|
292
|
+
const pairs = [];
|
|
293
|
+
for (const row of unlinked) {
|
|
294
|
+
const made = await call('/api/invoicing/price-items', 'POST', newCodeOf(row.record));
|
|
295
|
+
if (made) pairs.push({ row, item: made.item });
|
|
296
|
+
}
|
|
297
|
+
const linked = await linkMany(pairs);
|
|
298
|
+
toast(`${linked} added and linked.`, linked === unlinked.length ? 'success' : 'warn');
|
|
299
|
+
await load();
|
|
300
|
+
} },
|
|
301
|
+
{ icon: 'check', label: 'Mark resolved', title: 'Accept each ticked difference', run: async () => {
|
|
302
|
+
if (!differing.length) { toast('Tick codes that differ first.', 'warn'); return; }
|
|
303
|
+
for (const id of new Set(differing.map((r) => r.item.id))) await call(`/api/invoicing/price-items/${id}/resolve`, 'POST', {});
|
|
304
|
+
toast('Marked resolved.', 'success');
|
|
305
|
+
await load();
|
|
306
|
+
} },
|
|
307
|
+
]],
|
|
308
|
+
});
|
|
309
|
+
};
|
|
310
|
+
const all = h('input', { type: 'checkbox', class: 'fx-tick', 'aria-label': 'Tick all', onchange: () => {
|
|
311
|
+
for (const r of rows) { if (all.checked) ticked.add(r.key); else ticked.delete(r.key); }
|
|
312
|
+
for (const b of boxes.values()) b.checked = all.checked;
|
|
313
|
+
drawBar();
|
|
314
|
+
} });
|
|
315
|
+
const sure = rows.filter((r) => r.sure);
|
|
316
|
+
const state = (row) => {
|
|
317
|
+
if (row.kind === 'differs') return [icon('equal-not', 'Differs'), 'differs'];
|
|
318
|
+
if (row.sure) return [icon('check-check', 'Sure match'), 'sure'];
|
|
319
|
+
if (row.candidates[0]) return [icon('link-2', 'Likely code'), 'likely'];
|
|
320
|
+
return [icon('circle-dashed', 'No likely code'), 'none'];
|
|
321
|
+
};
|
|
322
|
+
host.replaceChildren(
|
|
323
|
+
h('div', { class: 'fx-xi__bulk' }, icon('list-checks'), h('span', { class: 'fx-xi__bulk-text' }, 'Tick rows, then use the bar below.'),
|
|
324
|
+
canAct && sure.length ? h('button', { class: 'fx-btn fx-btn--sm', type: 'button', onclick: () => {
|
|
325
|
+
for (const r of sure) { ticked.add(r.key); boxes.get(r.key).checked = true; }
|
|
326
|
+
drawBar();
|
|
327
|
+
} }, icon('check-check'), `Tick ${sure.length} sure`) : null),
|
|
328
|
+
h('div', { class: 'fx-xi__scroll' }, h('table', { class: 'fx-table fx-table--rows fx-xi-list fx-prc-list' },
|
|
329
|
+
h('thead', {}, h('tr', {},
|
|
330
|
+
canAct ? h('th', { class: 'fx-tick-cell' }, all) : null,
|
|
331
|
+
h('th', { colspan: '4' }, 'Record'), h('th', { class: 'fx-xi-list__mid' }), h('th', { colspan: '3' }, h('span', { class: 'fx-prc-list__flow' }, flowMark(), 'Flow code')))),
|
|
332
|
+
h('tbody', {}, rows.map((row, index) => {
|
|
333
|
+
const box = h('input', { type: 'checkbox', class: 'fx-tick', 'aria-label': `Tick ${row.record?.name ?? row.item?.code}`, onclick: (event) => event.stopPropagation(), onchange: () => {
|
|
334
|
+
if (box.checked) ticked.add(row.key); else ticked.delete(row.key);
|
|
335
|
+
drawBar();
|
|
336
|
+
} });
|
|
337
|
+
boxes.set(row.key, box);
|
|
338
|
+
const item = row.kind === 'differs' ? row.item : row.candidates[0]?.item ?? null;
|
|
339
|
+
const [glyph, tone] = state(row);
|
|
340
|
+
const differs = (kind) => row.kind === 'differs' && row.findings.some((f) => f.kind === kind);
|
|
341
|
+
return h('tr', { 'data-state': tone, onclick: () => { at = index; pick = 0; chosen = null; setView('card'); } },
|
|
342
|
+
canAct ? h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, box) : null,
|
|
343
|
+
h('td', { class: 'fx-prc-list__sys' }, systemMark(row.system)),
|
|
344
|
+
h('td', { class: 'fx-prc-list__name' }, row.record?.name ?? h('span', { class: 'fx-dim' }, row.kind === 'differs' ? `Not in ${row.system.name}` : '—'),
|
|
345
|
+
row.record?.code ? h('span', { class: 'fx-dim fx-num' }, ` · ${row.record.code}`) : null),
|
|
346
|
+
h('td', { class: `fx-num${differs('price') ? ' fx-prc-list__bad' : ''}` }, money(row.record?.unitAmountCents)),
|
|
347
|
+
h('td', { class: differs('tax') ? 'fx-prc-list__bad' : 'fx-dim' }, taxWords(row.record?.taxCode)),
|
|
348
|
+
h('td', { class: 'fx-xi-list__mid' }, glyph),
|
|
349
|
+
h('td', { class: 'fx-num' }, item?.code ?? h('span', { class: 'fx-dim' }, 'No likely code')),
|
|
350
|
+
h('td', { class: 'fx-prc-list__name' }, item?.name ?? ''),
|
|
351
|
+
h('td', { class: 'fx-num fx-strong' }, item ? money(item.unitAmountCents) : ''));
|
|
352
|
+
})))));
|
|
353
|
+
drawBar();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
await load();
|
|
357
|
+
}
|