@arcforgelabs/flow 2026.10.48 → 2026.10.50
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/VISION.md +18 -9
- package/npm-shrinkwrap.json +11 -11
- package/package.json +1 -1
- package/server/api/invoicing.js +7 -3
- package/server/core/automations.js +34 -0
- package/server/core/invoice-rules.js +17 -3
- package/server/core/job-board.js +1 -1
- package/server/core/pipeline.js +46 -6
- package/server/core/sending.js +27 -9
- package/server/integrations/port.js +1 -1
- package/server/integrations/seazona/feed.js +41 -4
- package/server/integrations/seazona/sweep.js +11 -8
- package/server/integrations/sentry/index.js +2 -2
- package/server/integrations/xero/client.js +23 -0
- package/server/integrations/xero/connections.js +2 -0
- package/server/integrations/xero/contact-sync.js +289 -0
- package/server/integrations/xero/create-contact.js +42 -2
- package/server/integrations/xero/drafts.js +14 -6
- package/server/integrations/xero/import.js +5 -1
- package/server/integrations/xero/index.js +16 -2
- package/server/integrations/xero/lookup.js +15 -2
- package/server/integrations/xero/migrations/0013_xero_contact_sync.sql +15 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/oauth.js +10 -3
- package/server/integrations/xero/routes.js +74 -42
- package/server/integrations/xero/runtime.js +7 -4
- package/server/integrations/xero/settings.js +18 -1
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +48 -40
- package/server/ui/automations.js +2 -4
- package/server/ui/contacts.js +20 -27
- package/server/ui/flow.css +125 -41
- package/server/ui/invoice-rules.js +3 -5
- package/server/ui/invoicing-settings.js +3 -7
- package/server/ui/invoicing.js +69 -84
- package/server/ui/job-board.js +3 -1
- package/server/ui/jobs.js +1 -1
- package/server/ui/kit.js +101 -10
- package/server/ui/line-mappings.js +2 -4
- package/server/ui/mobile.js +49 -4
- package/server/ui/price-list.js +3 -5
- package/server/ui/routes.js +1 -1
- package/server/ui/xero-contact.js +101 -0
package/server/ui/invoicing.js
CHANGED
|
@@ -10,7 +10,7 @@ import { bulkBar } from '/assets/ui/bulk-bar.js';
|
|
|
10
10
|
import { draftNote, sendModeNote } from '/assets/ui/invoice-notes.js';
|
|
11
11
|
import { caseNumber } from '/assets/ui/job-board.js';
|
|
12
12
|
import { jobChangeReview } from '/assets/ui/job-change.js';
|
|
13
|
-
import { rowMenu } from '/assets/ui/kit.js';
|
|
13
|
+
import { cardRows, rowMenu } from '/assets/ui/kit.js';
|
|
14
14
|
import { isPhone, WIDTHS } from '/assets/ui/mobile.js';
|
|
15
15
|
import { recordBar, watchPhonePrimary } from '/assets/ui/sidecar.js';
|
|
16
16
|
import { columnTable, dateCell, fieldColumn, rowCells, viewSaver } from '/assets/ui/columns.js';
|
|
@@ -18,15 +18,17 @@ import { fieldGroup, fieldIdOf, filterCount, filterJobs, INVOICE_COLUMNS, invoic
|
|
|
18
18
|
import { INVOICE_FILTER_FIELDS, INVOICE_FILTER_STATUSES, invoiceFilterKind, invoiceKindOf, invoiceValue, normaliseInvoicesView } from '/assets/ui/list-views.js';
|
|
19
19
|
import { listPager, pager, PAGE_SIZES } from '/assets/ui/pager.js';
|
|
20
20
|
import { savedViews } from '/assets/ui/saved-views.js';
|
|
21
|
+
import { xeroContactActions } from '/assets/ui/xero-contact.js';
|
|
21
22
|
import { findBox, today, viewBar } from '/assets/ui/view-bar.js';
|
|
22
23
|
|
|
23
24
|
// The tabs, left to right (server/api/invoicing.js, TABS): Pending (a
|
|
24
25
|
// person must look), Ready (checks pass), then the accounting system's
|
|
25
26
|
// stages: Draft (a draft there, or on its way), Approved, Sent (to the
|
|
26
27
|
// customer). With no accounting system, an issued invoice is Approved.
|
|
27
|
-
|
|
28
|
+
// Non-chargeable: work a lab's rule keeps in Flow and never bills.
|
|
29
|
+
const TABS = [['pending', 'Pending'], ['ready', 'Ready'], ['draft', 'Draft'], ['approved', 'Approved'], ['sent', 'Sent'], ['non_chargeable', 'Non-chargeable'], ['void', 'Void']];
|
|
28
30
|
const TAB_LABEL = Object.fromEntries(TABS);
|
|
29
|
-
const TAB_TONE = { pending: 'danger', ready: 'success', draft: 'info', approved: 'info', sent: 'success', void: 'mute' };
|
|
31
|
+
const TAB_TONE = { pending: 'danger', ready: 'success', draft: 'info', approved: 'info', sent: 'success', non_chargeable: 'mute', void: 'mute' };
|
|
30
32
|
// Tab names an earlier Flow kept in this browser.
|
|
31
33
|
const OLD_TAB = { problem: 'pending', to_check: 'ready', issued: 'approved' };
|
|
32
34
|
const NUMBERED = ['draft', 'approved', 'sent', 'void'];
|
|
@@ -358,7 +360,8 @@ export async function renderInvoiceList(main, ui) {
|
|
|
358
360
|
run: () => voidAll(voidable),
|
|
359
361
|
});
|
|
360
362
|
}
|
|
361
|
-
// A draft that was never issued is deleted on Pending
|
|
363
|
+
// A draft that was never issued is deleted on Pending, Ready and
|
|
364
|
+
// Non-chargeable. An issued
|
|
362
365
|
// invoice is deleted only while Flow's copy of Xero says Draft. Approved
|
|
363
366
|
// or paid stays hidden. A mix runs every ticked row and reports each one.
|
|
364
367
|
const fresh = rows.filter((i) => i.status === 'draft' && i.issuedVersion === 0);
|
|
@@ -366,7 +369,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
366
369
|
const xeroDraft = issuedXero.filter((i) => xeroById.get(i.id)?.state === 'draft');
|
|
367
370
|
const xeroApproved = issuedXero.filter((i) => ['approved', 'part_paid', 'paid'].includes(xeroById.get(i.id)?.state));
|
|
368
371
|
const deletable = [...fresh, ...xeroDraft, ...xeroApproved];
|
|
369
|
-
const onDraftTabs = ['pending', 'ready'].includes(state.status) && fresh.length;
|
|
372
|
+
const onDraftTabs = ['pending', 'ready', 'non_chargeable'].includes(state.status) && fresh.length;
|
|
370
373
|
if (context.canIssue && (onDraftTabs || xeroDraft.length)) {
|
|
371
374
|
danger.push({
|
|
372
375
|
icon: 'trash-2', label: 'Delete', iconOnly: true, tone: 'danger', title: 'Delete',
|
|
@@ -553,6 +556,9 @@ export async function renderInvoiceList(main, ui) {
|
|
|
553
556
|
...(id === 'pending' && counts?.pending ? { 'data-tone': 'danger' } : {}),
|
|
554
557
|
onclick: () => { if (state.status !== id) openTab(id); },
|
|
555
558
|
}, label, h('span', { class: 'fx-tabs__count' }, String(counts?.[id] ?? 0)))));
|
|
559
|
+
// On a phone the tabs scroll, and a redraw starts them at the left: keep
|
|
560
|
+
// the selected one in view, so the page says which tab it shows.
|
|
561
|
+
tabs.children[TABS.findIndex(([id]) => id === state.status)]?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
556
562
|
}
|
|
557
563
|
|
|
558
564
|
async function draw(answer) {
|
|
@@ -586,7 +592,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
586
592
|
const open = (href) => { saveNav({ ...readNav(), scrollY: window.scrollY, frameY: frame.body.scrollTop }); go(href); };
|
|
587
593
|
const link = (href, label) => h('a', { class: 'fx-strong', href, onclick: (event) => { event.preventDefault(); event.stopPropagation(); open(href); } }, label);
|
|
588
594
|
const showProblem = state.status === 'pending';
|
|
589
|
-
const created = ['pending', 'ready'].includes(state.status);
|
|
595
|
+
const created = ['pending', 'ready', 'non_chargeable'].includes(state.status);
|
|
590
596
|
// An invoice gets its number when it is sent or issued, so only the
|
|
591
597
|
// Numbered tabs show a Number column; the others open on Client.
|
|
592
598
|
const numbered = !created;
|
|
@@ -619,7 +625,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
619
625
|
const statusCell = (invoice) => {
|
|
620
626
|
const cell = h('span', {}, invoice.sending
|
|
621
627
|
? h('span', { class: 'fx-status', 'data-tone': 'info' }, 'Sending')
|
|
622
|
-
: h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] ?? 'mute' }, TAB_LABEL[invoice.tab] ?? ''));
|
|
628
|
+
: h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] ?? 'mute', title: TAB_LABEL[invoice.tab] ?? null }, TAB_LABEL[invoice.tab] ?? ''));
|
|
623
629
|
if (!linked) statusCells.set(invoice.id, cell);
|
|
624
630
|
return cell;
|
|
625
631
|
};
|
|
@@ -914,6 +920,12 @@ function linkedCard(ui, invoice, accountingStatus = null) {
|
|
|
914
920
|
|
|
915
921
|
// The first sentence of a problem: what is wrong. The red field shows where,
|
|
916
922
|
// so the banner holds no how-to.
|
|
923
|
+
// An invoice line on a phone (CardRows): Item, Qty and Remove; Description;
|
|
924
|
+
// Unit price, Tax and Amount. An issued line: Description and Amount; Qty,
|
|
925
|
+
// Unit price and Tax.
|
|
926
|
+
const LINE_CARD = { areas: ['item item qty remove', 'desc desc desc desc', 'price gst amount amount'], columns: 'minmax(0, 1fr) minmax(0, 1fr) 72px 44px' };
|
|
927
|
+
const LINE_CARD_READ = { areas: ['desc desc amount', 'qty price gst'], columns: 'repeat(3, minmax(0, 1fr))' };
|
|
928
|
+
|
|
917
929
|
const brief = (text) => String(text ?? '').match(/^.*?[.!?](?=\s|$)/s)?.[0] ?? String(text ?? '');
|
|
918
930
|
|
|
919
931
|
// A draft, or a new invoice before its first save.
|
|
@@ -989,7 +1001,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
989
1001
|
|
|
990
1002
|
const drawLines = () => {
|
|
991
1003
|
tbody.replaceChildren(...lines.map((line, index) => {
|
|
992
|
-
const amount = h('td', { class: 'fx-num fx-strong' });
|
|
1004
|
+
const amount = h('td', { class: 'fx-num fx-strong', 'data-area': 'amount', 'data-label': 'Amount', 'data-end': '' });
|
|
993
1005
|
const showAmount = () => {
|
|
994
1006
|
amount.textContent = line.unitAmountCents == null || !(line.quantity > 0) ? '—' : formatCents(line.quantity * line.unitAmountCents);
|
|
995
1007
|
};
|
|
@@ -1027,13 +1039,13 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1027
1039
|
onclick: () => { badLines.clear(); lines.splice(index, 1); if (!lines.length) lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null }); drawLines(); preview(); },
|
|
1028
1040
|
}, '×');
|
|
1029
1041
|
return h('tr', {},
|
|
1030
|
-
h('td', { class: 'fx-lines__item' }, item),
|
|
1031
|
-
h('td', {}, description),
|
|
1032
|
-
h('td', { class: 'fx-lines__qty' }, quantity),
|
|
1033
|
-
h('td', { class: 'fx-lines__price' }, price),
|
|
1034
|
-
h('td', { class: 'fx-lines__gst' }, gst),
|
|
1042
|
+
h('td', { class: 'fx-lines__item', 'data-area': 'item', 'data-label': 'Item' }, item),
|
|
1043
|
+
h('td', { 'data-area': 'desc', 'data-label': 'Description' }, description),
|
|
1044
|
+
h('td', { class: 'fx-lines__qty', 'data-area': 'qty', 'data-label': 'Qty' }, quantity),
|
|
1045
|
+
h('td', { class: 'fx-lines__price', 'data-area': 'price', 'data-label': 'Unit price' }, price),
|
|
1046
|
+
h('td', { class: 'fx-lines__gst', 'data-area': 'gst', 'data-label': 'Tax' }, gst),
|
|
1035
1047
|
amount,
|
|
1036
|
-
h('td', {}, remove));
|
|
1048
|
+
h('td', { 'data-area': 'remove' }, remove));
|
|
1037
1049
|
}));
|
|
1038
1050
|
};
|
|
1039
1051
|
drawLines();
|
|
@@ -1101,7 +1113,10 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1101
1113
|
if (!saved) return;
|
|
1102
1114
|
const passes = saved.view === 'ready';
|
|
1103
1115
|
const why = brief(saved.problems?.[0]?.message);
|
|
1104
|
-
if (
|
|
1116
|
+
if (saved.view === 'non_chargeable') {
|
|
1117
|
+
toast('Saved. It is Non-chargeable.', 'success');
|
|
1118
|
+
if (isNew) go(`/invoices/${saved.id}`, { replace: true }); else redraw(saved);
|
|
1119
|
+
} else if (isNew) {
|
|
1105
1120
|
toast(passes ? 'Saved. It is Ready.' : `Saved. It is Pending. ${why}`, passes ? 'success' : 'warn');
|
|
1106
1121
|
go(`/invoices/${saved.id}`, { replace: true });
|
|
1107
1122
|
} else if (passes && keepsSave) {
|
|
@@ -1128,6 +1143,8 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1128
1143
|
// and keeps it. A Pending draft has no send: fix its problem first.
|
|
1129
1144
|
const to = invoice?.sendsTo ?? context.sendsTo ?? null;
|
|
1130
1145
|
const ready = invoice?.view === 'ready';
|
|
1146
|
+
// A lab's rule keeps a non-chargeable draft in Flow: nothing sends or approves it.
|
|
1147
|
+
const nonChargeable = invoice?.view === 'non_chargeable';
|
|
1131
1148
|
const nextNumber = `${context.organisations[0]?.invoicePrefix ?? ''}${String(context.organisations[0]?.nextInvoiceNumber ?? '').padStart(4, '0')}`;
|
|
1132
1149
|
const emails = invoice?.sendMode?.mode === 'approve_and_email';
|
|
1133
1150
|
const sendButton = context.canIssue && to && ready ? h('button', {
|
|
@@ -1152,7 +1169,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1152
1169
|
}, emails ? `Send to ${to} and email` : `Send to ${to}`) : null;
|
|
1153
1170
|
// With no accounting system, Approve gives the draft its number and locks
|
|
1154
1171
|
// it. A changed job blocks it.
|
|
1155
|
-
const issueButton = context.canIssue && !to ? h('button', {
|
|
1172
|
+
const issueButton = context.canIssue && !to && !nonChargeable ? h('button', {
|
|
1156
1173
|
class: 'fx-btn fx-btn--primary', type: 'button',
|
|
1157
1174
|
onclick: async () => {
|
|
1158
1175
|
const saved = await save();
|
|
@@ -1205,41 +1222,24 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1205
1222
|
// The job changed after the invoice was made: its own banner shows what
|
|
1206
1223
|
// changed. Other problems stay one line.
|
|
1207
1224
|
const otherProblems = (invoice?.problems ?? []).filter((p) => !(jobChange && p.code === 'job_review'));
|
|
1208
|
-
const offer = otherProblems.find((p) => p.code
|
|
1225
|
+
const offer = otherProblems.find((p) => ['xero_contact_missing', 'xero_contact_ambiguous'].includes(p.code) && (p.offerLink || p.offerCreate));
|
|
1226
|
+
// A phone shows each problem's short name ("Client not in Xero") on one line.
|
|
1227
|
+
const shorts = otherProblems.every((p) => p.short) ? [...new Set(otherProblems.map((p) => p.short))].join(', ') : null;
|
|
1209
1228
|
const problemNotice = otherProblems.length
|
|
1210
|
-
? notice([...new Set(otherProblems.map((p) => brief(p.message)))].join(' '), 'danger', 'Problem.')
|
|
1229
|
+
? notice([...new Set(otherProblems.map((p) => brief(p.message)))].join(' '), 'danger', 'Problem.', { short: shorts })
|
|
1211
1230
|
: null;
|
|
1212
1231
|
const organisationId = context.organisations[0]?.id;
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
title: `Create ${name} in Xero?`,
|
|
1221
|
-
body: h('p', {}, 'Flow adds this client to Xero from the contact.'),
|
|
1222
|
-
confirm: 'Create',
|
|
1223
|
-
});
|
|
1224
|
-
if (!confirmed) return;
|
|
1225
|
-
button.disabled = true;
|
|
1226
|
-
try {
|
|
1227
|
-
await flow('/api/xero/contacts', { method: 'POST', body: JSON.stringify({ organisationId, contactId: invoice.contact.id }) });
|
|
1228
|
-
toast(`${name} is in Xero.`, 'success');
|
|
1229
|
-
const body = await flow(`/api/invoicing/invoices/${invoice.id}`);
|
|
1230
|
-
redraw(body.invoice);
|
|
1231
|
-
} catch (error) {
|
|
1232
|
-
toast(error.message, error.status === 429 ? 'info' : 'danger');
|
|
1233
|
-
if (error.retryAfterMs) ui.holdButton(button, error.retryAfterMs);
|
|
1234
|
-
else button.disabled = false;
|
|
1235
|
-
}
|
|
1236
|
-
},
|
|
1237
|
-
}, 'Create')
|
|
1238
|
-
: null;
|
|
1232
|
+
// Link or Create the Xero client, then the invoice is checked again.
|
|
1233
|
+
const clientActions = offer && context.canIssue && invoice?.contact?.id && organisationId
|
|
1234
|
+
? xeroContactActions(ui, {
|
|
1235
|
+
organisationId, contact: invoice.contact, offerLink: offer.offerLink, offerCreate: offer.offerCreate,
|
|
1236
|
+
done: async () => redraw((await flow(`/api/invoicing/invoices/${invoice.id}`)).invoice),
|
|
1237
|
+
})
|
|
1238
|
+
: [];
|
|
1239
1239
|
const problems = [
|
|
1240
1240
|
// Accepting changes the draft for the business: owners and admins only.
|
|
1241
1241
|
jobChange ? jobChangeReview(ui, jobChange, { money: formatCents, canAccept: context.canIssue, unsaved: changed, done: redraw }) : null,
|
|
1242
|
-
problemNotice ? (
|
|
1242
|
+
problemNotice ? (clientActions.length ? h('div', { class: 'fx-offer' }, problemNotice, ...clientActions) : problemNotice) : null,
|
|
1243
1243
|
];
|
|
1244
1244
|
// A red field names the banner as its reason (for a screen reader).
|
|
1245
1245
|
const banner = problemNotice;
|
|
@@ -1252,7 +1252,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1252
1252
|
|
|
1253
1253
|
const previewBtn = isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.');
|
|
1254
1254
|
const showAccept = Boolean(jobChange?.canAccept && context.canIssue);
|
|
1255
|
-
const header = ui.pageHeader(isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Pending invoice', {
|
|
1255
|
+
const header = ui.pageHeader(isNew ? 'New invoice' : ready ? 'Ready invoice' : nonChargeable ? 'Non-chargeable invoice' : 'Pending invoice', {
|
|
1256
1256
|
actions: [fromJob, deleteButton, previewBtn, saveButton, sendButton ?? issueButton].filter(Boolean),
|
|
1257
1257
|
});
|
|
1258
1258
|
const primaryOf = () => (showAccept ? null : (sendButton ?? issueButton ?? (saveButton.hidden ? null : saveButton)));
|
|
@@ -1270,6 +1270,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1270
1270
|
chrome.pager,
|
|
1271
1271
|
header,
|
|
1272
1272
|
draftNote(to, invoice) ? ui.notice(draftNote(to, invoice), 'warn') : null,
|
|
1273
|
+
nonChargeable ? ui.notice(brief(invoice.nonChargeable?.[0]?.message ?? 'Non-chargeable.')) : null,
|
|
1273
1274
|
...problems,
|
|
1274
1275
|
h('div', { class: 'fx-invoice__grid' },
|
|
1275
1276
|
h('div', { class: 'fx-invoice__main' },
|
|
@@ -1279,12 +1280,12 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1279
1280
|
h('label', { class: 'fx-field' }, h('span', { class: 'fx-label' }, 'Due date'), dueDate)),
|
|
1280
1281
|
h('section', { class: 'fx-card fx-lines' },
|
|
1281
1282
|
datalist,
|
|
1282
|
-
h('table', { class: 'fx-lines__table' },
|
|
1283
|
+
cardRows(h('table', { class: 'fx-lines__table' },
|
|
1283
1284
|
h('thead', {}, h('tr', {},
|
|
1284
1285
|
h('th', {}, 'Item'), h('th', {}, 'Description'), h('th', { class: 'fx-num' }, 'Qty'),
|
|
1285
1286
|
h('th', { class: 'fx-num' }, 'Unit price'), h('th', {}, 'Tax'), h('th', { class: 'fx-num' }, 'Amount'),
|
|
1286
1287
|
h('th', {}, h('span', { class: 'fx-sr' }, 'Remove')))),
|
|
1287
|
-
tbody),
|
|
1288
|
+
tbody), LINE_CARD),
|
|
1288
1289
|
h('div', { class: 'fx-lines__foot' },
|
|
1289
1290
|
h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', onclick: () => { lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null }); drawLines(); } }, '+ Add line'),
|
|
1290
1291
|
totalsHost))),
|
|
@@ -1452,14 +1453,14 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
1452
1453
|
h('div', {}, h('dt', { class: 'fx-label' }, 'Reference'), h('dd', {}, invoice.reference ?? '—')),
|
|
1453
1454
|
h('div', {}, h('dt', { class: 'fx-label' }, 'Date'), h('dd', {}, showDate(invoice.issueDate))),
|
|
1454
1455
|
h('div', {}, h('dt', { class: 'fx-label' }, 'Due'), h('dd', {}, showDate(invoice.dueDate)))),
|
|
1455
|
-
h('table', { class: 'fx-lines__table fx-lines__table--read' },
|
|
1456
|
+
cardRows(h('table', { class: 'fx-lines__table fx-lines__table--read' },
|
|
1456
1457
|
h('thead', {}, h('tr', {}, h('th', {}, 'Description'), h('th', { class: 'fx-num' }, 'Qty'), h('th', { class: 'fx-num' }, 'Unit price'), h('th', {}, 'Tax'), h('th', { class: 'fx-num' }, 'Amount'))),
|
|
1457
1458
|
h('tbody', {}, invoice.lines.map((line) => h('tr', {},
|
|
1458
|
-
h('td', {}, line.description),
|
|
1459
|
-
h('td', { class: 'fx-num' }, String(line.quantity)),
|
|
1460
|
-
h('td', { class: 'fx-num' }, formatCents(line.unitAmountCents)),
|
|
1461
|
-
h('td', { class: 'fx-dim' }, line.taxCode === 'GST' ? 'GST' : 'GST free'),
|
|
1462
|
-
h('td', { class: 'fx-num fx-strong' }, formatCents(line.lineAmountCents)))))),
|
|
1459
|
+
h('td', { 'data-area': 'desc' }, line.description),
|
|
1460
|
+
h('td', { class: 'fx-num', 'data-area': 'qty', 'data-label': 'Qty' }, String(line.quantity)),
|
|
1461
|
+
h('td', { class: 'fx-num', 'data-area': 'price', 'data-label': 'Unit price' }, formatCents(line.unitAmountCents)),
|
|
1462
|
+
h('td', { class: 'fx-dim', 'data-area': 'gst', 'data-label': 'Tax' }, line.taxCode === 'GST' ? 'GST' : 'GST free'),
|
|
1463
|
+
h('td', { class: 'fx-num fx-strong', 'data-area': 'amount', 'data-end': '' }, formatCents(line.lineAmountCents)))))), LINE_CARD_READ),
|
|
1463
1464
|
h('div', { class: 'fx-lines__foot' }, h('span'), totalsBox(ui, invoice)))),
|
|
1464
1465
|
h('aside', { class: 'fx-invoice__aside' },
|
|
1465
1466
|
linkedHost,
|
|
@@ -1568,39 +1569,23 @@ function showSent(button) {
|
|
|
1568
1569
|
const PICKABLE = new Set(['in_work', 'ready', 'pending']);
|
|
1569
1570
|
|
|
1570
1571
|
async function pickJob(ui) {
|
|
1571
|
-
const {
|
|
1572
|
-
|
|
1573
|
-
try {
|
|
1574
|
-
jobs = (await flow('/api/invoicing/jobs?limit=500')).jobs.filter((job) => PICKABLE.has(job.view) && !job.invoiceId);
|
|
1575
|
-
} catch (error) {
|
|
1576
|
-
toast(error.message, 'danger');
|
|
1577
|
-
return;
|
|
1578
|
-
}
|
|
1579
|
-
const search = h('input', { class: 'fx-input', type: 'search', placeholder: 'Find a client or practitioner', 'aria-label': 'Find a job' });
|
|
1580
|
-
const rows = jobs.map((job) => {
|
|
1581
|
-
const label = h('label', { class: 'fx-pick__row' },
|
|
1582
|
-
h('input', { type: 'radio', name: 'job', value: job.id, required: true }),
|
|
1583
|
-
h('span', { class: 'fx-pick__main' },
|
|
1584
|
-
h('span', { class: 'fx-strong' }, job.contactName ?? 'No client yet'),
|
|
1585
|
-
h('span', { class: 'fx-dim' }, [job.practitioner, job.shippedOn ? showDate(job.shippedOn) : null].filter(Boolean).join(' · '))),
|
|
1586
|
-
h('span', { class: 'fx-num fx-strong' }, job.totalCents == null ? '—' : formatCents(job.totalCents)));
|
|
1587
|
-
label.dataset.find = `${job.contactName ?? ''} ${job.practitioner ?? ''}`.toLowerCase();
|
|
1588
|
-
return label;
|
|
1589
|
-
});
|
|
1590
|
-
const list = h('div', { class: 'fx-pick', role: 'radiogroup', 'aria-label': 'Jobs' },
|
|
1591
|
-
rows.length ? rows : h('p', { class: 'fx-dim' }, 'Every job has an invoice already.'));
|
|
1592
|
-
search.addEventListener('input', () => {
|
|
1593
|
-
const q = search.value.trim().toLowerCase();
|
|
1594
|
-
for (const row of rows) row.hidden = Boolean(q) && !row.dataset.find.includes(q);
|
|
1595
|
-
});
|
|
1596
|
-
const picked = await ui.dialog({
|
|
1572
|
+
const { flow, toast, go } = ui;
|
|
1573
|
+
const job = await ui.pickOne({
|
|
1597
1574
|
title: 'New invoice from a job',
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1575
|
+
intro: 'Flow fills in the client, Reference and lines from the invoice rules, and links the invoice to the job.',
|
|
1576
|
+
label: 'Jobs',
|
|
1577
|
+
find: 'Find a client or practitioner',
|
|
1578
|
+
load: async () => (await flow('/api/invoicing/jobs?limit=500')).jobs.filter((row) => PICKABLE.has(row.view) && !row.invoiceId),
|
|
1579
|
+
item: (row) => ({
|
|
1580
|
+
value: row.id,
|
|
1581
|
+
title: row.contactName ?? 'No client yet',
|
|
1582
|
+
meta: [row.practitioner, row.shippedOn ? showDate(row.shippedOn) : null].filter(Boolean).join(' · '),
|
|
1583
|
+
end: row.totalCents == null ? '—' : formatCents(row.totalCents),
|
|
1584
|
+
}),
|
|
1585
|
+
empty: 'Every job has an invoice already.',
|
|
1601
1586
|
confirm: 'Make draft',
|
|
1602
1587
|
});
|
|
1603
|
-
const jobId =
|
|
1588
|
+
const jobId = job?.id;
|
|
1604
1589
|
if (!jobId) return;
|
|
1605
1590
|
try {
|
|
1606
1591
|
const { invoice } = await flow(`/api/invoicing/jobs/${jobId}/draft`, { method: 'POST', body: '{}' });
|
package/server/ui/job-board.js
CHANGED
|
@@ -42,7 +42,7 @@ export function orderStatuses(statuses = [], jobs = []) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
export const INVOICE_STAGES = {
|
|
45
|
-
drafted: 'Drafted', problem: 'Pending', ready: 'Ready to invoice', invoiced: 'Invoiced', paid: 'Paid',
|
|
45
|
+
drafted: 'Drafted', problem: 'Pending', ready: 'Ready to invoice', invoiced: 'Invoiced', non_chargeable: 'Non-chargeable', paid: 'Paid',
|
|
46
46
|
};
|
|
47
47
|
|
|
48
48
|
const PROBLEM_VIEWS = ['pending', 'review_required', 'held'];
|
|
@@ -58,6 +58,7 @@ const isRule = (reason) => reason !== 'the job is not marked ready to invoice';
|
|
|
58
58
|
// that stops the draft, or a problem with the draft
|
|
59
59
|
// Ready to invoice a person marked the draft ready
|
|
60
60
|
// Invoiced issued, sent, or in the accounting system
|
|
61
|
+
// Non-chargeable a lab's rule keeps the draft in Flow, never billed
|
|
61
62
|
// Paid paid in full
|
|
62
63
|
export function invoiceStage(o) {
|
|
63
64
|
if (!o || o.view === 'in_work') return null;
|
|
@@ -66,6 +67,7 @@ export function invoiceStage(o) {
|
|
|
66
67
|
if (o.view === 'invoiced') return 'invoiced';
|
|
67
68
|
if (o.invoiceView === 'problem' || o.invoiceStatus === 'void') return 'problem';
|
|
68
69
|
if (o.invoiceStatus === 'issued') return 'invoiced';
|
|
70
|
+
if (o.invoiceView === 'non_chargeable') return 'non_chargeable';
|
|
69
71
|
if (o.invoiceView === 'ready') return 'ready';
|
|
70
72
|
if (o.view === 'ready') return (o.waitingFor ?? []).some(isRule) ? 'problem' : 'drafted';
|
|
71
73
|
if (o.view === 'drafted') return 'drafted';
|
package/server/ui/jobs.js
CHANGED
|
@@ -50,7 +50,7 @@ const ACTIVITY = {
|
|
|
50
50
|
const BY = { person: 'A person', automation: 'An automation' };
|
|
51
51
|
// Grouped by invoice stage: a job with no invoice stage yet is still in work.
|
|
52
52
|
const STAGE_GROUPS = { in_work: 'In work', ...INVOICE_STAGES };
|
|
53
|
-
const STAGE_RANK = { 'In work': 0, Drafted: 2, Problem: 2, 'Ready to invoice': 4, Invoiced: 5, Paid: 6 };
|
|
53
|
+
const STAGE_RANK = { 'In work': 0, Drafted: 2, Problem: 2, 'Ready to invoice': 4, Invoiced: 5, 'Non-chargeable': 5, Paid: 6 };
|
|
54
54
|
|
|
55
55
|
const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short' }) : '—');
|
|
56
56
|
const fullDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) : '—');
|
package/server/ui/kit.js
CHANGED
|
@@ -149,10 +149,31 @@ export function sectionTitle(text, help = null) {
|
|
|
149
149
|
return h('h2', { class: 'fx-set__title' }, text, help ? helpLink(help) : null);
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
|
|
152
|
+
// With `short`, a phone shows only that one line ("Client not in Xero") in
|
|
153
|
+
// place of the title and text.
|
|
154
|
+
export function notice(text, tone = 'info', title = null, { short = null } = {}) {
|
|
153
155
|
return applyPhoneRule(h('div', { class: 'fx-notice', 'data-tone': tone, role: tone === 'danger' ? 'alert' : 'status' },
|
|
154
|
-
title ? h('strong', {}, title) : null,
|
|
155
|
-
h('span', {}, text)
|
|
156
|
+
title ? h('strong', { class: short ? 'fx-notice__long' : null }, title) : null,
|
|
157
|
+
h('span', { class: short ? 'fx-notice__long' : null }, text),
|
|
158
|
+
short ? h('span', { class: 'fx-notice__short' }, short) : null), 'notice');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// A table that is a list of small cards on a phone (CardRows card). On a
|
|
162
|
+
// tablet and desk it stays a table. `areas` places each cell on the card
|
|
163
|
+
// (CSS grid-template-areas, one string per line) and `columns` sizes the
|
|
164
|
+
// card's columns. A cell names its place with `data-area` and its column
|
|
165
|
+
// name with `data-label` (shown above it on a phone). Rows drawn later are
|
|
166
|
+
// placed too.
|
|
167
|
+
export function cardRows(table, { areas, columns }) {
|
|
168
|
+
table.classList.add('fx-cardrows');
|
|
169
|
+
table.style.setProperty('--fx-cardrows-areas', areas.map((line) => `"${line}"`).join(' '));
|
|
170
|
+
table.style.setProperty('--fx-cardrows-columns', columns);
|
|
171
|
+
const place = () => {
|
|
172
|
+
for (const cell of table.querySelectorAll('td[data-area]')) cell.style.gridArea = cell.dataset.area;
|
|
173
|
+
};
|
|
174
|
+
place();
|
|
175
|
+
if (typeof MutationObserver === 'function') new MutationObserver(place).observe(table, { childList: true, subtree: true });
|
|
176
|
+
return applyPhoneRule(table, 'cardRows');
|
|
156
177
|
}
|
|
157
178
|
|
|
158
179
|
// A field that fails a check glows red, like a required field, until the
|
|
@@ -220,17 +241,87 @@ export function dialog({ title, body, confirm, tone = 'primary', choices = null
|
|
|
220
241
|
});
|
|
221
242
|
}
|
|
222
243
|
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
|
|
244
|
+
// A dialog that picks one row of a long list (Pick card). A search box over
|
|
245
|
+
// the list; `load(q)` answers the rows for its text (once with '' on open, and
|
|
246
|
+
// again as the person types when `remote`; else the rows filter here).
|
|
247
|
+
// `item(row)` draws a row as { value, title, meta, end }. Answers the picked
|
|
248
|
+
// row, or null on Cancel.
|
|
249
|
+
export async function pickOne({ title, intro = null, label, find = 'Find', load, item, empty, confirm, remote = false }) {
|
|
250
|
+
const search = h('input', { class: 'fx-input', type: 'search', placeholder: find, 'aria-label': find });
|
|
251
|
+
const list = h('div', { class: 'fx-pick', role: 'radiogroup', 'aria-label': label }, h('div', { class: 'fx-skeleton' }));
|
|
252
|
+
let rows = [];
|
|
253
|
+
const draw = (found) => {
|
|
254
|
+
rows = found.map((row) => {
|
|
255
|
+
const { value, title: name, meta = null, end = null } = item(row);
|
|
256
|
+
const element = h('label', { class: 'fx-pick__row' },
|
|
257
|
+
h('input', { type: 'radio', name: 'pick', value: String(value), required: true }),
|
|
258
|
+
h('span', { class: 'fx-pick__main' },
|
|
259
|
+
h('span', { class: 'fx-strong' }, name),
|
|
260
|
+
meta ? h('span', { class: 'fx-dim' }, meta) : null),
|
|
261
|
+
end == null ? h('span') : h('span', { class: 'fx-num fx-strong' }, end));
|
|
262
|
+
element.dataset.find = `${name} ${meta ?? ''}`.toLowerCase();
|
|
263
|
+
return { row, value: String(value), element };
|
|
264
|
+
});
|
|
265
|
+
list.replaceChildren(...(rows.length ? rows.map((r) => r.element) : [h('p', { class: 'fx-dim' }, empty)]));
|
|
266
|
+
};
|
|
267
|
+
let asked = 0;
|
|
268
|
+
const fill = async (q) => {
|
|
269
|
+
const turn = ++asked;
|
|
270
|
+
let found;
|
|
271
|
+
try {
|
|
272
|
+
found = await load(q);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
found = [];
|
|
275
|
+
toast(error.message, 'danger');
|
|
276
|
+
}
|
|
277
|
+
if (turn === asked) draw(found);
|
|
278
|
+
};
|
|
279
|
+
let wait = null;
|
|
280
|
+
search.addEventListener('input', () => {
|
|
281
|
+
const q = search.value.trim().toLowerCase();
|
|
282
|
+
if (remote) {
|
|
283
|
+
clearTimeout(wait);
|
|
284
|
+
wait = setTimeout(() => fill(q), 250);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
for (const r of rows) r.element.hidden = Boolean(q) && !r.element.dataset.find.includes(q);
|
|
288
|
+
});
|
|
289
|
+
fill('');
|
|
290
|
+
const picked = await dialog({
|
|
291
|
+
title,
|
|
292
|
+
body: applyPhoneRule(h('div', { class: 'fx-pick__body' }, intro ? h('p', { class: 'fx-dim' }, intro) : null, search, list), 'pickOne'),
|
|
293
|
+
confirm,
|
|
294
|
+
});
|
|
295
|
+
clearTimeout(wait);
|
|
296
|
+
const value = picked?.get('pick');
|
|
297
|
+
return value ? rows.find((r) => r.value === value)?.row ?? null : null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// One setting, laid out the same on every settings screen: a short name and
|
|
301
|
+
// the control on the right. `hint` is a short status line under the name
|
|
302
|
+
// ("3 to check"), never an explanation. `help` is the explanation: a ? beside
|
|
303
|
+
// the name says it on hover, and a press shows it under the name (a phone has
|
|
304
|
+
// no hover). A wide control (a grid of choices) sits on its own line under the
|
|
305
|
+
// name. `tone: 'danger'` marks a row that needs a person.
|
|
306
|
+
export function settingRow({ label, hint, help = null, control, wide = false, tone = null }) {
|
|
228
307
|
const target = control?.matches?.('input, select, textarea') ? control : control?.querySelector?.('input:not([type=color]), select, textarea');
|
|
229
308
|
const id = target ? (target.id ||= `fx-set-${++settingRow.next}`) : null;
|
|
309
|
+
const helpLine = help ? h('p', { class: 'fx-setting__hint fx-setting__help', hidden: true }, help) : null;
|
|
310
|
+
const helpButton = help ? named(h('button', {
|
|
311
|
+
class: 'fx-help', type: 'button', 'aria-expanded': 'false',
|
|
312
|
+
onclick: (event) => {
|
|
313
|
+
const open = helpLine.hidden;
|
|
314
|
+
helpLine.hidden = !open;
|
|
315
|
+
event.currentTarget.setAttribute('aria-expanded', String(open));
|
|
316
|
+
},
|
|
317
|
+
}, lucide(h, 'circle-help')), `About ${label}`, help) : null;
|
|
230
318
|
return applyPhoneRule(h('div', { class: `fx-setting${wide ? ' fx-setting--wide' : ''}`, 'data-tone': tone },
|
|
231
319
|
h('div', { class: 'fx-setting__text' },
|
|
232
|
-
|
|
233
|
-
|
|
320
|
+
h('span', { class: 'fx-setting__head' },
|
|
321
|
+
id ? h('label', { class: 'fx-setting__name', for: id }, label) : h('span', { class: 'fx-setting__name' }, label),
|
|
322
|
+
helpButton),
|
|
323
|
+
hint ? h('p', { class: 'fx-setting__hint' }, hint) : null,
|
|
324
|
+
helpLine),
|
|
234
325
|
h('div', { class: 'fx-setting__control' }, control)), 'settingRow');
|
|
235
326
|
}
|
|
236
327
|
settingRow.next = 0;
|
|
@@ -9,14 +9,12 @@
|
|
|
9
9
|
const money = (cents) => (cents === null || cents === undefined ? '' : `$${(cents / 100).toFixed(2)}`);
|
|
10
10
|
|
|
11
11
|
export function lineMappingSection(ui) {
|
|
12
|
-
const { h, flow, toast, notice, me } = ui;
|
|
12
|
+
const { h, flow, toast, notice, me, settingRow } = ui;
|
|
13
13
|
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
14
14
|
const body = h('div', {}, h('div', { class: 'fx-skeleton' }));
|
|
15
15
|
const section = h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, 'Price codes'), body);
|
|
16
16
|
|
|
17
|
-
const row = (
|
|
18
|
-
h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, name), h('span', { class: 'fx-set__hint' }, hint)),
|
|
19
|
-
h('div', { class: 'fx-set__control' }, control));
|
|
17
|
+
const row = (label, hint, control) => settingRow({ label, hint, control });
|
|
20
18
|
|
|
21
19
|
async function load() {
|
|
22
20
|
let labels;
|
package/server/ui/mobile.js
CHANGED
|
@@ -68,6 +68,26 @@ export const RULES = {
|
|
|
68
68
|
"mark": false,
|
|
69
69
|
"card": "Button"
|
|
70
70
|
},
|
|
71
|
+
"cardRows": {
|
|
72
|
+
"component": "cardRows",
|
|
73
|
+
"direction": "card per row",
|
|
74
|
+
"phone": {
|
|
75
|
+
"layout": "cards"
|
|
76
|
+
},
|
|
77
|
+
"tablet": {
|
|
78
|
+
"layout": "table"
|
|
79
|
+
},
|
|
80
|
+
"desk": {
|
|
81
|
+
"layout": "table"
|
|
82
|
+
},
|
|
83
|
+
"touch": 44,
|
|
84
|
+
"pageOverflow": "none",
|
|
85
|
+
"screens": [
|
|
86
|
+
"invoice",
|
|
87
|
+
"invoice-issued"
|
|
88
|
+
],
|
|
89
|
+
"card": "CardRows"
|
|
90
|
+
},
|
|
71
91
|
"columnTable": {
|
|
72
92
|
"component": "columnTable",
|
|
73
93
|
"direction": "two-line row",
|
|
@@ -281,6 +301,7 @@ export const RULES = {
|
|
|
281
301
|
},
|
|
282
302
|
"touch": 44,
|
|
283
303
|
"pageOverflow": "none",
|
|
304
|
+
"scrollX": ".fx-frame, .fx-board, .fx-board-tabs",
|
|
284
305
|
"screens": [
|
|
285
306
|
"jobs-board",
|
|
286
307
|
"jobs-list",
|
|
@@ -308,6 +329,7 @@ export const RULES = {
|
|
|
308
329
|
},
|
|
309
330
|
"touch": 44,
|
|
310
331
|
"pageOverflow": "none",
|
|
332
|
+
"scrollX": ".fx-toolbar__tools",
|
|
311
333
|
"screens": [
|
|
312
334
|
"jobs-board",
|
|
313
335
|
"jobs-list",
|
|
@@ -366,7 +388,8 @@ export const RULES = {
|
|
|
366
388
|
"component": "notice",
|
|
367
389
|
"direction": "note",
|
|
368
390
|
"phone": {
|
|
369
|
-
"layout": "note"
|
|
391
|
+
"layout": "note",
|
|
392
|
+
"short": "one line in place of the title and text"
|
|
370
393
|
},
|
|
371
394
|
"tablet": {
|
|
372
395
|
"layout": "note"
|
|
@@ -376,7 +399,9 @@ export const RULES = {
|
|
|
376
399
|
},
|
|
377
400
|
"touch": 44,
|
|
378
401
|
"pageOverflow": "none",
|
|
379
|
-
"screens": [
|
|
402
|
+
"screens": [
|
|
403
|
+
"invoice-offer"
|
|
404
|
+
],
|
|
380
405
|
"card": "Notice"
|
|
381
406
|
},
|
|
382
407
|
"optionPill": {
|
|
@@ -452,6 +477,23 @@ export const RULES = {
|
|
|
452
477
|
"selector": ".fx-pager",
|
|
453
478
|
"card": "Pager"
|
|
454
479
|
},
|
|
480
|
+
"pickOne": {
|
|
481
|
+
"component": "pickOne",
|
|
482
|
+
"direction": "sheet list",
|
|
483
|
+
"phone": {
|
|
484
|
+
"layout": "sheet"
|
|
485
|
+
},
|
|
486
|
+
"tablet": {
|
|
487
|
+
"layout": "dialog"
|
|
488
|
+
},
|
|
489
|
+
"desk": {
|
|
490
|
+
"layout": "dialog"
|
|
491
|
+
},
|
|
492
|
+
"touch": 44,
|
|
493
|
+
"pageOverflow": "none",
|
|
494
|
+
"screens": [],
|
|
495
|
+
"card": "Pick"
|
|
496
|
+
},
|
|
455
497
|
"rowMenu": {
|
|
456
498
|
"component": "rowMenu",
|
|
457
499
|
"direction": "menu",
|
|
@@ -511,7 +553,9 @@ export const RULES = {
|
|
|
511
553
|
"touch": 44,
|
|
512
554
|
"pageOverflow": "none",
|
|
513
555
|
"screens": [
|
|
514
|
-
"appearance"
|
|
556
|
+
"appearance",
|
|
557
|
+
"integration-xero",
|
|
558
|
+
"integration-seazona"
|
|
515
559
|
],
|
|
516
560
|
"card": "SettingRow"
|
|
517
561
|
},
|
|
@@ -541,13 +585,14 @@ export const RULES = {
|
|
|
541
585
|
"layout": "scroll"
|
|
542
586
|
},
|
|
543
587
|
"tablet": {
|
|
544
|
-
"layout": "
|
|
588
|
+
"layout": "scroll"
|
|
545
589
|
},
|
|
546
590
|
"desk": {
|
|
547
591
|
"layout": "tabs"
|
|
548
592
|
},
|
|
549
593
|
"touch": 44,
|
|
550
594
|
"pageOverflow": "none",
|
|
595
|
+
"scrollX": ".fx-tabs",
|
|
551
596
|
"screens": [
|
|
552
597
|
"invoices-ready",
|
|
553
598
|
"invoices-pending",
|
package/server/ui/price-list.js
CHANGED
|
@@ -34,7 +34,7 @@ const toCents = (text) => {
|
|
|
34
34
|
};
|
|
35
35
|
|
|
36
36
|
export async function renderPriceList(main, ui) {
|
|
37
|
-
const { h, flow, toast, notice, dialog, pageHeader, me } = ui;
|
|
37
|
+
const { h, flow, toast, notice, dialog, pageHeader, me, settingRow } = ui;
|
|
38
38
|
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
39
39
|
const host = h('div', { class: 'fx-price' }, h('div', { class: 'fx-skeleton' }));
|
|
40
40
|
let view = normaliseListView(me?.views?.['price-list'], PRICE_COLUMNS);
|
|
@@ -71,9 +71,7 @@ export async function renderPriceList(main, ui) {
|
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
const row = (
|
|
75
|
-
h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, name), hint),
|
|
76
|
-
h('div', { class: 'fx-set__control fx-rule__controls' }, control));
|
|
74
|
+
const row = (label, hint, control) => settingRow({ label, hint, control });
|
|
77
75
|
|
|
78
76
|
const findingText = (f) => {
|
|
79
77
|
const name = systemName(f.system);
|
|
@@ -165,7 +163,7 @@ export async function renderPriceList(main, ui) {
|
|
|
165
163
|
else select.value = saved;
|
|
166
164
|
select.disabled = disabled;
|
|
167
165
|
});
|
|
168
|
-
return row('Line price from',
|
|
166
|
+
return row('Line price from', 'Where an invoice line takes its price.', select);
|
|
169
167
|
}
|
|
170
168
|
|
|
171
169
|
function draw() {
|