@arcforgelabs/flow 2026.10.47 → 2026.10.48

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 (38) hide show
  1. package/README.md +1 -0
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +1 -1
  4. package/server/api/invoicing.js +35 -8
  5. package/server/core/contacts.js +160 -0
  6. package/server/core/directory.js +4 -9
  7. package/server/core/invoices.js +84 -31
  8. package/server/core/sending.js +65 -3
  9. package/server/db/doctor.js +1 -1
  10. package/server/db/index.js +19 -0
  11. package/server/db/migrations/0021_flow_schema.sql +68 -0
  12. package/server/db/migrations/0022_contacts.sql +11 -0
  13. package/server/db/migrations/meta/0021_snapshot.json +4377 -0
  14. package/server/db/migrations/meta/0022_snapshot.json +4446 -0
  15. package/server/db/migrations/meta/_journal.json +14 -0
  16. package/server/db/schema.js +15 -0
  17. package/server/integrations/port.js +2 -0
  18. package/server/integrations/schema.js +3 -2
  19. package/server/integrations/setup.js +1 -0
  20. package/server/integrations/xero/catalogue.js +82 -14
  21. package/server/integrations/xero/client.js +37 -0
  22. package/server/integrations/xero/create-contact.js +126 -0
  23. package/server/integrations/xero/delete-invoice.js +104 -0
  24. package/server/integrations/xero/drafts.js +19 -8
  25. package/server/integrations/xero/index.js +9 -0
  26. package/server/integrations/xero/migrations/0011_xero_invoice_deleted.sql +11 -0
  27. package/server/integrations/xero/migrations/0012_xero_contact_details.sql +16 -0
  28. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  29. package/server/integrations/xero/routes.js +21 -0
  30. package/server/integrations/xero/settings.js +3 -0
  31. package/server/ui/app.js +8 -3
  32. package/server/ui/contacts.js +260 -0
  33. package/server/ui/flow.css +40 -0
  34. package/server/ui/invoicing.js +159 -54
  35. package/server/ui/kit.js +28 -8
  36. package/server/ui/mobile.js +6 -3
  37. package/server/ui/nav-pins.js +5 -2
  38. package/server/ui/routes.js +1 -1
@@ -250,6 +250,9 @@ export async function renderInvoiceList(main, ui) {
250
250
  state.values = new Map((values?.values ?? []).map((v) => [valueKey(v, v.fieldId), v.value]));
251
251
  let body = null;
252
252
  let raw = null;
253
+ // Xero's status from Flow's copy, filled after the list draws. Delete on
254
+ // an issued invoice waits for this: it shows only while the copy says Draft.
255
+ let xeroById = new Map();
253
256
  const openTab = (id) => { state.status = id; state.offset = 0; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); };
254
257
  const alerts = alertStrip(ui, 'invoices', { onTab: openTab });
255
258
  // The tabs and search stay at the top while the rows scroll up under them;
@@ -355,13 +358,19 @@ export async function renderInvoiceList(main, ui) {
355
358
  run: () => voidAll(voidable),
356
359
  });
357
360
  }
358
- // A draft that was never issued is deleted, not voided. Pending and Ready
359
- // hold those drafts; an issued invoice on Pending is retried, not deleted.
360
- const deletable = rows.filter((i) => i.status === 'draft' && i.issuedVersion === 0);
361
- if (['pending', 'ready'].includes(state.status) && context.canIssue && deletable.length) {
361
+ // A draft that was never issued is deleted on Pending and Ready. An issued
362
+ // invoice is deleted only while Flow's copy of Xero says Draft. Approved
363
+ // or paid stays hidden. A mix runs every ticked row and reports each one.
364
+ const fresh = rows.filter((i) => i.status === 'draft' && i.issuedVersion === 0);
365
+ const issuedXero = rows.filter((i) => i.status === 'issued' && xeroById.has(i.id));
366
+ const xeroDraft = issuedXero.filter((i) => xeroById.get(i.id)?.state === 'draft');
367
+ const xeroApproved = issuedXero.filter((i) => ['approved', 'part_paid', 'paid'].includes(xeroById.get(i.id)?.state));
368
+ const deletable = [...fresh, ...xeroDraft, ...xeroApproved];
369
+ const onDraftTabs = ['pending', 'ready'].includes(state.status) && fresh.length;
370
+ if (context.canIssue && (onDraftTabs || xeroDraft.length)) {
362
371
  danger.push({
363
372
  icon: 'trash-2', label: 'Delete', iconOnly: true, tone: 'danger', title: 'Delete',
364
- run: () => deleteAll(deletable),
373
+ run: () => deleteAll(deletable, { xero: xeroDraft.length + xeroApproved.length > 0 }),
365
374
  });
366
375
  }
367
376
  const all = body.invoices.map((i) => i.id);
@@ -439,23 +448,46 @@ export async function renderInvoiceList(main, ui) {
439
448
  alerts.refresh();
440
449
  }
441
450
 
442
- // Delete, one never-issued draft after another, as on one invoice's page.
443
- async function deleteAll(rows) {
451
+ // Delete, one invoice after another, as on one invoice's page. A Xero
452
+ // draft reports each row: deleted, or refused with the reason.
453
+ async function deleteAll(rows, { xero = false } = {}) {
454
+ const fresh = rows.some((i) => i.status === 'draft' && i.issuedVersion === 0);
444
455
  const ok = await ui.dialog({
445
456
  title: `Delete ${plural(rows.length, 'invoice', 'invoices')}?`,
446
- body: h('p', {}, 'A draft that was never issued is removed. Its job can be invoiced again, and the rules decide.'),
457
+ body: xero
458
+ ? h('div', {},
459
+ fresh ? h('p', {}, 'A draft that was never issued is removed. Its job can be invoiced again, and the rules decide.') : null,
460
+ h('p', {}, 'Also deletes the draft in Xero.'))
461
+ : h('p', {}, 'A draft that was never issued is removed. Its job can be invoiced again, and the rules decide.'),
447
462
  confirm: `Delete ${rows.length}`, tone: 'danger',
448
463
  });
449
464
  if (!ok) return;
450
- let done = 0;
451
- let error = null;
452
- for (const invoice of rows) {
453
- try {
454
- await flow(`/api/invoicing/invoices/${invoice.id}`, { method: 'DELETE' });
455
- done += 1;
456
- } catch (e) { error = e; }
465
+ if (xero) {
466
+ const results = [];
467
+ for (const invoice of rows) {
468
+ const name = invoice.number ?? 'Draft';
469
+ try {
470
+ await flow(`/api/invoicing/invoices/${invoice.id}`, { method: 'DELETE' });
471
+ results.push(`${name} deleted.`);
472
+ } catch (error) {
473
+ results.push(`${name} refused: ${error.message}`);
474
+ }
475
+ }
476
+ const refused = results.some((line) => line.includes(' refused: '));
477
+ // The result stays until it is closed. A success is one short line, and
478
+ // a refusal names each invoice, so neither should vanish on its own.
479
+ toast(results.join(' '), refused ? 'danger' : 'success', { stay: true });
480
+ } else {
481
+ let done = 0;
482
+ let error = null;
483
+ for (const invoice of rows) {
484
+ try {
485
+ await flow(`/api/invoicing/invoices/${invoice.id}`, { method: 'DELETE' });
486
+ done += 1;
487
+ } catch (e) { error = e; }
488
+ }
489
+ toast(error ? `${done} of ${rows.length} deleted. ${error.message}` : `${plural(done, 'invoice', 'invoices')} deleted.`, error ? 'danger' : 'success', { stay: true });
457
490
  }
458
- toast(error ? `${done} of ${rows.length} deleted. ${error.message}` : `${plural(done, 'invoice', 'invoices')} deleted.`, error ? 'danger' : 'success');
459
491
  state.ticked.clear();
460
492
  seen.clear();
461
493
  await load();
@@ -527,6 +559,7 @@ export async function renderInvoiceList(main, ui) {
527
559
  raw = answer;
528
560
  filterMenu.draw();
529
561
  body = answer;
562
+ xeroById = new Map();
530
563
  // A page past the end (rows went to another tab): show the last one.
531
564
  if (!answer.invoices.length && answer.total && answer.offset) {
532
565
  state.offset = Math.max(0, Math.floor((answer.total - 1) / state.limit) * state.limit);
@@ -634,16 +667,22 @@ export async function renderInvoiceList(main, ui) {
634
667
  rowCells(h, columns, invoice),
635
668
  h('td', { class: 'fx-c-end' })))),
636
669
  }));
637
- // Issued and void invoices also show Xero's status, from Flow's copy:
638
- // in the Linked column every one gets an icon, so one not in Xero
639
- // stands out.
640
- if (numbered) {
670
+ // Issued invoices carry Xero's status on every tab, so Delete can follow
671
+ // Flow's copy. A draft that is out of sync sits on Pending, which has no
672
+ // number column, and is still a draft there. Icons stay on the numbered
673
+ // tabs, where one invoice not in Xero stands out.
674
+ const issuedIds = body.invoices.filter((i) => i.issuedVersion > 0).map((i) => i.id);
675
+ if (numbered || issuedIds.length) {
641
676
  const shownBody = body;
642
- const statuses = await xeroStatuses(ui, context.organisations?.[0]?.id, body.invoices.map((i) => i.id));
677
+ const statuses = await xeroStatuses(ui, context.organisations?.[0]?.id, numbered ? body.invoices.map((i) => i.id) : issuedIds);
643
678
  if (shownBody !== body) return;
644
- for (const [invoiceId, status] of statuses) {
645
- if (linked) { if (accountingSystem) statusCells.get(invoiceId)?.append(accountingIcon(ui, accountingSystem, status)); }
646
- else if (status.state !== 'not_sent' || status.finding) statusCells.get(invoiceId)?.append(' ', xeroTag(ui, status));
679
+ xeroById = statuses;
680
+ if (state.ticked.size) drawBar();
681
+ if (numbered) {
682
+ for (const [invoiceId, status] of statuses) {
683
+ if (linked) { if (accountingSystem) statusCells.get(invoiceId)?.append(accountingIcon(ui, accountingSystem, status)); }
684
+ else if (status.state !== 'not_sent' || status.finding) statusCells.get(invoiceId)?.append(' ', xeroTag(ui, status));
685
+ }
647
686
  }
648
687
  }
649
688
  }
@@ -1155,29 +1194,10 @@ function draftView(invoice, ui, context, redraw) {
1155
1194
  const fromJob = isNew && context.canIssue
1156
1195
  ? h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', onclick: () => pickJob(ui) }, 'From a job') : null;
1157
1196
  // A draft that was never issued is deleted. An issued invoice, including
1158
- // one opened for a revision, is voided instead.
1197
+ // one opened for a revision, is voided instead, unless its Xero copy is a
1198
+ // draft: that Delete is added on the issued page after the copy loads.
1159
1199
  const deleteButton = !isNew && context.canIssue && invoice.status === 'draft' && invoice.issuedVersion === 0
1160
- ? h('button', {
1161
- class: 'fx-btn fx-btn--ghost fx-btn--danger', type: 'button',
1162
- onclick: async () => {
1163
- const ok = await ui.dialog({
1164
- title: 'Delete this invoice?',
1165
- body: h('p', {}, invoice.job
1166
- ? 'It was never issued. The job can be invoiced again, and the rules decide.'
1167
- : 'It was never issued, so it is removed.'),
1168
- confirm: 'Delete', tone: 'danger',
1169
- });
1170
- if (!ok) return;
1171
- try {
1172
- await flow(`/api/invoicing/invoices/${invoice.id}`, { method: 'DELETE' });
1173
- toast('Invoice deleted.', 'success');
1174
- sessionStorage.setItem(NAV_BACK, '1');
1175
- go('/invoices');
1176
- } catch (error) {
1177
- toast(error.message, 'danger');
1178
- }
1179
- },
1180
- }, 'Delete') : null;
1200
+ ? deleteControl(ui, invoice, { xeroDraft: false }) : null;
1181
1201
 
1182
1202
  // A draft with no problem is in Ready by itself; one with a problem is
1183
1203
  // Pending. A changed job is fixed at its source or accepted here.
@@ -1185,13 +1205,44 @@ function draftView(invoice, ui, context, redraw) {
1185
1205
  // The job changed after the invoice was made: its own banner shows what
1186
1206
  // changed. Other problems stay one line.
1187
1207
  const otherProblems = (invoice?.problems ?? []).filter((p) => !(jobChange && p.code === 'job_review'));
1208
+ const offer = otherProblems.find((p) => p.code === 'xero_contact_missing' && p.offerCreate);
1209
+ const problemNotice = otherProblems.length
1210
+ ? notice([...new Set(otherProblems.map((p) => brief(p.message)))].join(' '), 'danger', 'Problem.')
1211
+ : null;
1212
+ 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;
1188
1239
  const problems = [
1189
1240
  // Accepting changes the draft for the business: owners and admins only.
1190
1241
  jobChange ? jobChangeReview(ui, jobChange, { money: formatCents, canAccept: context.canIssue, unsaved: changed, done: redraw }) : null,
1191
- otherProblems.length ? notice([...new Set(otherProblems.map((p) => brief(p.message)))].join(' '), 'danger', 'Problem.') : null,
1242
+ problemNotice ? (createClient ? h('div', { class: 'fx-offer' }, problemNotice, createClient) : problemNotice) : null,
1192
1243
  ];
1193
1244
  // A red field names the banner as its reason (for a screen reader).
1194
- const banner = problems[1];
1245
+ const banner = problemNotice;
1195
1246
  if (banner) banner.id = 'fx-invoice-problem';
1196
1247
  for (const name of failed) {
1197
1248
  if (!boxes[name]) continue;
@@ -1280,6 +1331,35 @@ function previewButton(ui, invoiceId, title = 'Shows the invoice as a PDF.') {
1280
1331
  }, 'Preview');
1281
1332
  }
1282
1333
 
1334
+ // Delete asks first. A Xero draft says so; a never-issued draft says the job
1335
+ // can be invoiced again. Then the list opens.
1336
+ function deleteControl(ui, invoice, { xeroDraft }) {
1337
+ const { h, flow, toast, go } = ui;
1338
+ return h('button', {
1339
+ class: 'fx-btn fx-btn--ghost fx-btn--danger', type: 'button', 'data-delete': '',
1340
+ onclick: async () => {
1341
+ const ok = await ui.dialog({
1342
+ title: 'Delete this invoice?',
1343
+ body: h('p', {}, xeroDraft
1344
+ ? 'Also deletes the draft in Xero.'
1345
+ : invoice.job
1346
+ ? 'It was never issued. The job can be invoiced again, and the rules decide.'
1347
+ : 'It was never issued, so it is removed.'),
1348
+ confirm: 'Delete', tone: 'danger',
1349
+ });
1350
+ if (!ok) return;
1351
+ try {
1352
+ await flow(`/api/invoicing/invoices/${invoice.id}`, { method: 'DELETE' });
1353
+ toast('Invoice deleted.', 'success', { stay: true });
1354
+ sessionStorage.setItem(NAV_BACK, '1');
1355
+ go('/invoices');
1356
+ } catch (error) {
1357
+ toast(error.message, 'danger', { stay: true });
1358
+ }
1359
+ },
1360
+ }, 'Delete');
1361
+ }
1362
+
1283
1363
  // An issued or void invoice: the document as sent, and what can happen next.
1284
1364
  function issuedView(invoice, ui, context, redraw) {
1285
1365
  const { h, flow, toast } = ui;
@@ -1301,14 +1381,28 @@ function issuedView(invoice, ui, context, redraw) {
1301
1381
  const linkedHost = h('div', {}, linkedCard(ui, invoice) ?? '');
1302
1382
  let xeroStatus = null;
1303
1383
  const send = invoice.status === 'issued' && context.canIssue ? sendToXero(invoice, ui, context) : null;
1384
+ let actionBar = null;
1385
+ let deleteButton = null;
1386
+ let recordSync = () => {};
1304
1387
  const loadXero = async () => {
1305
1388
  if (!context.xero) return;
1306
1389
  xeroStatus = (await xeroStatuses(ui, organisationId, [invoice.id])).get(invoice.id) ?? null;
1307
1390
  xeroHost.replaceChildren(xeroCard(invoice, ui, context, xeroStatus, loadXero) ?? '');
1308
1391
  linkedHost.replaceChildren(linkedCard(ui, invoice, xeroStatus) ?? '');
1309
1392
  if (xeroStatus && xeroStatus.state !== 'not_sent') showSent(send);
1393
+ // Delete only while Flow's copy says Draft. Approved and paid stay on Void.
1394
+ const canDelete = invoice.status === 'issued' && context.canIssue && xeroStatus?.state === 'draft';
1395
+ const remove = actionBar?.querySelector('[data-delete]');
1396
+ if (canDelete && !remove && actionBar) {
1397
+ deleteButton = deleteControl(ui, invoice, { xeroDraft: true });
1398
+ actionBar.prepend(deleteButton);
1399
+ }
1400
+ if (!canDelete && remove) {
1401
+ remove.remove();
1402
+ deleteButton = null;
1403
+ }
1404
+ recordSync();
1310
1405
  };
1311
- loadXero();
1312
1406
  // Voiding in Flow never changes Xero: say so while Xero still holds it live.
1313
1407
  const voidBody = () => {
1314
1408
  const live = xeroStatus?.xero && !['VOIDED', 'DELETED'].includes(xeroStatus.xero.status);
@@ -1323,16 +1417,29 @@ function issuedView(invoice, ui, context, redraw) {
1323
1417
  const reviseButton = invoice.status === 'issued' && context.canIssue
1324
1418
  ? h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise') : null;
1325
1419
  const preview = previewButton(ui, invoice.id);
1420
+ actionBar = h('div', { class: 'fx-ph__actions' }, [voidButton, reviseButton, preview, send].filter(Boolean));
1326
1421
  const header = h('header', { class: 'fx-ph' },
1327
1422
  h('div', { class: 'fx-ph__text' },
1328
1423
  h('h1', { class: 'fx-title' }, invoice.number),
1329
1424
  h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] }, TAB_LABEL[invoice.tab] ?? 'Approved')),
1330
- h('div', { class: 'fx-ph__actions' }, [voidButton, reviseButton, preview, send].filter(Boolean)));
1425
+ actionBar);
1331
1426
  const chrome = mountRecord(ui, invoice, header, () => send, (skip) => [
1427
+ menuEntry(deleteButton, 'trash-2', skip),
1332
1428
  menuEntry(voidButton, 'ban', skip),
1333
1429
  menuEntry(reviseButton, 'pencil', skip),
1334
1430
  menuEntry(preview, 'eye', skip),
1335
1431
  ]);
1432
+ recordSync = chrome.sync;
1433
+ loadXero();
1434
+ const history = [
1435
+ ...(invoice.versions ?? []).map((v) => h('li', {},
1436
+ h('span', { class: 'fx-strong' }, v.version === 1 ? 'Version 1' : `Revised (version ${v.version})`),
1437
+ h('span', { class: 'fx-dim' }, new Date(v.issuedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })))),
1438
+ // Void, including a delete that removed the Xero draft, sits on the same list.
1439
+ ...(invoice.status === 'void' ? [h('li', {},
1440
+ h('span', { class: 'fx-strong' }, 'Void'),
1441
+ h('span', { class: 'fx-dim' }, new Date(invoice.updatedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })))] : []),
1442
+ ];
1336
1443
  return [
1337
1444
  chrome.record,
1338
1445
  chrome.pager,
@@ -1357,11 +1464,9 @@ function issuedView(invoice, ui, context, redraw) {
1357
1464
  h('aside', { class: 'fx-invoice__aside' },
1358
1465
  linkedHost,
1359
1466
  xeroHost,
1360
- invoice.versions?.length ? h('section', { class: 'fx-card fx-side-card' },
1467
+ history.length ? h('section', { class: 'fx-card fx-side-card' },
1361
1468
  h('h3', { class: 'fx-label' }, 'History'),
1362
- h('ol', { class: 'fx-timeline' }, invoice.versions.map((v) => h('li', {},
1363
- h('span', { class: 'fx-strong' }, v.version === 1 ? 'Version 1' : `Revised (version ${v.version})`),
1364
- h('span', { class: 'fx-dim' }, new Date(v.issuedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })))))) : null)),
1469
+ h('ol', { class: 'fx-timeline' }, history)) : null)),
1365
1470
  chrome.foot,
1366
1471
  chrome.menuPanel,
1367
1472
  ];
package/server/ui/kit.js CHANGED
@@ -381,14 +381,19 @@ function inPlace(event) {
381
381
  export function tileCard({ href, mark, title, status, blurb, meta, action = 'Manage', row = false }) {
382
382
  const logo = typeof mark === 'string' ? h('span', { class: 'fx-logo fx-logo--icon', 'aria-hidden': 'true' }, lucide(h, mark)) : mark;
383
383
  const pill = status ? h('span', { class: 'fx-status', 'data-tone': status.tone ?? 'mute' }, status.label) : null;
384
+ // The name is cut with "…" on a narrow row; its title is the full name.
385
+ // The › shows only on a phone, where every card is one row.
386
+ const name = h('h3', { title }, title);
387
+ const go = h('span', { class: 'fx-tile__go', 'aria-hidden': 'true' }, lucide(h, 'chevron-right'));
384
388
  if (row) {
385
389
  return applyPhoneRule(h('a', { class: 'fx-tile', href, onclick: inPlace },
386
390
  logo,
387
- h('h3', {}, title),
388
- pill ?? (meta ? h('span', { class: 'fx-tile__meta' }, meta) : null)), 'tileCard');
391
+ name,
392
+ pill ?? (meta ? h('span', { class: 'fx-tile__meta' }, meta) : null),
393
+ go), 'tileCard');
389
394
  }
390
395
  return applyPhoneRule(h('a', { class: `fx-int-card${blurb ? '' : ' fx-int-card--bare'}`, href, onclick: inPlace },
391
- h('header', { class: 'fx-int-card__head' }, logo, h('h3', {}, title), pill),
396
+ h('header', { class: 'fx-int-card__head' }, logo, name, pill, go),
392
397
  blurb ? h('p', { class: 'fx-int-card__blurb' }, blurb) : null,
393
398
  h('span', { class: 'fx-int-card__btn' }, action)), 'tileCard');
394
399
  }
@@ -406,16 +411,24 @@ export function tileSection(title, cards, { href, more = 'Show all' } = {}) {
406
411
  // A dialog that fills the screen has no pixel outside it, so an accessibility
407
412
  // check cannot tell it is modal and reads the page underneath. While one is
408
413
  // open, every other top-level node is inert. The attribute is what the check
409
- // reads; the browser's own modal behaviour is unchanged.
410
- function shieldBehindDialogs() {
411
- const top = [...document.querySelectorAll('dialog[open]')].at(-1) ?? null;
414
+ // reads; the browser's own modal behaviour is unchanged. `ignore` is a dialog
415
+ // that is closing: a browser may still mark it open while it fires close.
416
+ function shieldBehindDialogs(ignore = null) {
417
+ const top = [...document.querySelectorAll('dialog[open]')].filter((el) => el !== ignore).at(-1) ?? null;
412
418
  for (const child of document.body.children) {
413
419
  if (top && child !== top) child.setAttribute('inert', '');
414
420
  else child.removeAttribute('inert');
415
421
  }
416
422
  }
417
423
  // Node tests import this module and have no HTMLDialogElement. The wrap only
418
- // matters in the browser, where showModal and close are the three call sites.
424
+ // matters in the browser. showModal is patched because nothing else announces
425
+ // that a dialog opened. close() drops the shield for a dialog closed in
426
+ // script. A form with method="dialog", and Escape, close the dialog inside
427
+ // the browser and never call close(). Chrome then fires toggle, and close,
428
+ // on a later turn. Until one of those runs, the inert attribute would stay
429
+ // and no click would land (an invoice Delete did this, until a reload).
430
+ // close does not bubble, so the listeners are on the capture path. A popover
431
+ // toggle is ignored: only a dialog clears the shield.
419
432
  if (typeof HTMLDialogElement !== 'undefined') {
420
433
  const openDialog = HTMLDialogElement.prototype.showModal;
421
434
  const closeDialog = HTMLDialogElement.prototype.close;
@@ -426,9 +439,16 @@ if (typeof HTMLDialogElement !== 'undefined') {
426
439
  };
427
440
  HTMLDialogElement.prototype.close = function close(...args) {
428
441
  const result = closeDialog.apply(this, args);
429
- shieldBehindDialogs();
442
+ shieldBehindDialogs(this);
430
443
  return result;
431
444
  };
445
+ const onDialogClosed = (event) => {
446
+ if (event.target instanceof HTMLDialogElement) shieldBehindDialogs(event.target);
447
+ };
448
+ document.addEventListener('close', onDialogClosed, true);
449
+ document.addEventListener('toggle', (event) => {
450
+ if (event.newState === 'closed') onDialogClosed(event);
451
+ }, true);
432
452
  }
433
453
 
434
454
  // The trail above a page one level down: crumbs(['/integrations', 'Integrations'], 'Xero').
@@ -564,7 +564,8 @@ export const RULES = {
564
564
  "component": "tileCard",
565
565
  "direction": "one-line rows",
566
566
  "phone": {
567
- "layout": "one-line"
567
+ "layout": "one-line",
568
+ "rowHeight": 56
568
569
  },
569
570
  "tablet": {
570
571
  "layout": "cards"
@@ -575,7 +576,8 @@ export const RULES = {
575
576
  "touch": 44,
576
577
  "pageOverflow": "none",
577
578
  "screens": [
578
- "rules"
579
+ "rules",
580
+ "integrations"
579
581
  ],
580
582
  "card": "Tiles"
581
583
  },
@@ -583,7 +585,8 @@ export const RULES = {
583
585
  "component": "tileSection",
584
586
  "direction": "one-line rows",
585
587
  "phone": {
586
- "layout": "one-line"
588
+ "layout": "one-line",
589
+ "rowHeight": 56
587
590
  },
588
591
  "tablet": {
589
592
  "layout": "cards"
@@ -6,6 +6,7 @@
6
6
  export const NAV_PAGES = Object.freeze([
7
7
  'jobs',
8
8
  'invoices',
9
+ 'contacts',
9
10
  'reconcile',
10
11
  'price-list',
11
12
  'invoicing-settings',
@@ -16,11 +17,13 @@ export const NAV_PAGES = Object.freeze([
16
17
 
17
18
  export const NAV_LOCKED = Object.freeze(['jobs', 'invoices']);
18
19
 
19
- // Today's sidebar, before a person has chosen: daily work, Price list and
20
- // Rules. Automations, Custom fields and Integrations start in More.
20
+ // Today's sidebar, before a person has chosen: daily work (Jobs, Invoices,
21
+ // Contacts, Reconcile), Price list and Rules. Automations, Custom fields
22
+ // and Integrations start in More.
21
23
  export const NAV_DEFAULT = Object.freeze([
22
24
  'jobs',
23
25
  'invoices',
26
+ 'contacts',
24
27
  'reconcile',
25
28
  'price-list',
26
29
  'invoicing-settings',
@@ -21,7 +21,7 @@ const themeDir = join(here, '..', 'theme');
21
21
  const logoDir = join(here, 'logos');
22
22
  const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
23
23
  const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
24
- export const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'sidecar.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'xero-usage.js', 'xero-usage-model.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'mobile.js', 'person.js', 'pager.js', 'nav-pins.js', 'flow-mark.svg']);
24
+ export const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'sidecar.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'contacts.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'xero-usage.js', 'xero-usage-model.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'mobile.js', 'person.js', 'pager.js', 'nav-pins.js', 'flow-mark.svg']);
25
25
  const iconDir = join(dirname(require.resolve('lucide-static/package.json')), 'icons');
26
26
  const ICON_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/;
27
27
  const MAX_AGE = '1h';