@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.
Files changed (43) hide show
  1. package/VISION.md +18 -9
  2. package/npm-shrinkwrap.json +11 -11
  3. package/package.json +1 -1
  4. package/server/api/invoicing.js +7 -3
  5. package/server/core/automations.js +34 -0
  6. package/server/core/invoice-rules.js +17 -3
  7. package/server/core/job-board.js +1 -1
  8. package/server/core/pipeline.js +46 -6
  9. package/server/core/sending.js +27 -9
  10. package/server/integrations/port.js +1 -1
  11. package/server/integrations/seazona/feed.js +41 -4
  12. package/server/integrations/seazona/sweep.js +11 -8
  13. package/server/integrations/sentry/index.js +2 -2
  14. package/server/integrations/xero/client.js +23 -0
  15. package/server/integrations/xero/connections.js +2 -0
  16. package/server/integrations/xero/contact-sync.js +289 -0
  17. package/server/integrations/xero/create-contact.js +42 -2
  18. package/server/integrations/xero/drafts.js +14 -6
  19. package/server/integrations/xero/import.js +5 -1
  20. package/server/integrations/xero/index.js +16 -2
  21. package/server/integrations/xero/lookup.js +15 -2
  22. package/server/integrations/xero/migrations/0013_xero_contact_sync.sql +15 -0
  23. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  24. package/server/integrations/xero/oauth.js +10 -3
  25. package/server/integrations/xero/routes.js +74 -42
  26. package/server/integrations/xero/runtime.js +7 -4
  27. package/server/integrations/xero/settings.js +18 -1
  28. package/server/ui/app-page.js +1 -1
  29. package/server/ui/app.js +48 -40
  30. package/server/ui/automations.js +2 -4
  31. package/server/ui/contacts.js +20 -27
  32. package/server/ui/flow.css +125 -41
  33. package/server/ui/invoice-rules.js +3 -5
  34. package/server/ui/invoicing-settings.js +3 -7
  35. package/server/ui/invoicing.js +69 -84
  36. package/server/ui/job-board.js +3 -1
  37. package/server/ui/jobs.js +1 -1
  38. package/server/ui/kit.js +101 -10
  39. package/server/ui/line-mappings.js +2 -4
  40. package/server/ui/mobile.js +49 -4
  41. package/server/ui/price-list.js +3 -5
  42. package/server/ui/routes.js +1 -1
  43. package/server/ui/xero-contact.js +101 -0
@@ -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
- const TABS = [['pending', 'Pending'], ['ready', 'Ready'], ['draft', 'Draft'], ['approved', 'Approved'], ['sent', 'Sent'], ['void', 'Void']];
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 and Ready. An issued
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 (isNew) {
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 === 'xero_contact_missing' && p.offerCreate);
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
- const createClient = offer && context.canIssue && invoice?.contact?.id && organisationId
1214
- ? h('button', {
1215
- class: 'fx-btn fx-btn--primary', type: 'button', title: 'Create this client in Xero',
1216
- onclick: async (event) => {
1217
- const button = event.currentTarget;
1218
- const name = invoice.contact.name;
1219
- const confirmed = await ui.dialog({
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 ? (createClient ? h('div', { class: 'fx-offer' }, problemNotice, createClient) : problemNotice) : null,
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 { h, flow, toast, go } = ui;
1572
- let jobs;
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
- body: h('div', { class: 'fx-pick__body' },
1599
- h('p', { class: 'fx-dim' }, 'Flow fills in the client, Reference and lines from the invoice rules, and links the invoice to the job.'),
1600
- search, list),
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 = picked?.get('job');
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: '{}' });
@@ -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
- export function notice(text, tone = 'info', title = null) {
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)), 'notice');
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
- // One setting, laid out the same on every settings screen: a short name, one
224
- // line of smaller text under it, and the control on the right. Longer help
225
- // lives behind a link, not here. A wide control (a grid of choices) sits on
226
- // its own line under the name. `tone: 'danger'` marks a row that needs a person.
227
- export function settingRow({ label, hint, control, wide = false, tone = null }) {
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
- id ? h('label', { class: 'fx-setting__name', for: id }, label) : h('span', { class: 'fx-setting__name' }, label),
233
- hint ? h('p', { class: 'fx-setting__hint' }, hint) : null),
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 = (name, hint, control) => h('div', { class: 'fx-set__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;
@@ -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": "tabs"
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",
@@ -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 = (name, hint, control) => h('div', { class: 'fx-set__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', h('span', { class: 'fx-set__hint' }, 'Where an invoice line takes its price.'), select);
166
+ return row('Line price from', 'Where an invoice line takes its price.', select);
169
167
  }
170
168
 
171
169
  function draw() {