@arcforgelabs/flow 2026.10.46 → 2026.10.48-1

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 (64) hide show
  1. package/README.md +1 -0
  2. package/VISION.md +10 -10
  3. package/npm-shrinkwrap.json +11 -11
  4. package/package.json +1 -1
  5. package/server/api/invoicing.js +89 -22
  6. package/server/auth/people.js +3 -1
  7. package/server/auth/routes.js +4 -1
  8. package/server/core/contacts.js +160 -0
  9. package/server/core/directory.js +4 -9
  10. package/server/core/invoice-rules.js +34 -4
  11. package/server/core/invoices.js +93 -1
  12. package/server/core/invoicing-rules.js +20 -3
  13. package/server/core/job-board.js +1 -1
  14. package/server/core/sending.js +141 -25
  15. package/server/db/doctor.js +1 -1
  16. package/server/db/index.js +19 -0
  17. package/server/db/migrations/0021_flow_schema.sql +68 -0
  18. package/server/db/migrations/0022_contacts.sql +11 -0
  19. package/server/db/migrations/meta/0021_snapshot.json +4377 -0
  20. package/server/db/migrations/meta/0022_snapshot.json +4446 -0
  21. package/server/db/migrations/meta/_journal.json +14 -0
  22. package/server/db/schema.js +15 -0
  23. package/server/integrations/port.js +2 -0
  24. package/server/integrations/schema.js +3 -2
  25. package/server/integrations/seazona/sweep.js +11 -8
  26. package/server/integrations/sentry/index.js +14 -7
  27. package/server/integrations/setup.js +1 -0
  28. package/server/integrations/xero/catalogue.js +82 -14
  29. package/server/integrations/xero/client.js +37 -0
  30. package/server/integrations/xero/create-contact.js +126 -0
  31. package/server/integrations/xero/delete-invoice.js +104 -0
  32. package/server/integrations/xero/drafts.js +19 -8
  33. package/server/integrations/xero/index.js +9 -0
  34. package/server/integrations/xero/migrations/0011_xero_invoice_deleted.sql +11 -0
  35. package/server/integrations/xero/migrations/0012_xero_contact_details.sql +16 -0
  36. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  37. package/server/integrations/xero/routes.js +21 -0
  38. package/server/integrations/xero/settings.js +3 -0
  39. package/server/ui/alerts.js +2 -1
  40. package/server/ui/app.js +113 -28
  41. package/server/ui/bulk-bar.js +18 -5
  42. package/server/ui/columns.js +5 -2
  43. package/server/ui/contacts.js +260 -0
  44. package/server/ui/custom-fields.js +22 -7
  45. package/server/ui/flow.css +275 -24
  46. package/server/ui/invoicing.js +374 -70
  47. package/server/ui/job-board.js +10 -0
  48. package/server/ui/job-change.js +4 -3
  49. package/server/ui/job-view.js +3 -1
  50. package/server/ui/jobs.js +150 -19
  51. package/server/ui/kit.js +220 -45
  52. package/server/ui/list-views.js +50 -10
  53. package/server/ui/mobile.js +729 -0
  54. package/server/ui/monitoring.js +51 -0
  55. package/server/ui/nav-pins.js +44 -0
  56. package/server/ui/pager.js +57 -10
  57. package/server/ui/price-list.js +1 -1
  58. package/server/ui/price-reconcile.js +12 -12
  59. package/server/ui/reconcile.js +18 -17
  60. package/server/ui/routes.js +1 -1
  61. package/server/ui/saved-views.js +2 -1
  62. package/server/ui/sidecar.js +106 -6
  63. package/server/ui/view-bar.js +22 -11
  64. package/server/ui/xero-import.js +48 -19
@@ -192,3 +192,13 @@ export function matches(job, q) {
192
192
  return [job.caseRef, job.sourceJobRef, job.clientName, job.workClientName, job.practitioner, job.obligation?.invoiceNumber]
193
193
  .some((value) => value && String(value).toLowerCase().includes(needle));
194
194
  }
195
+
196
+ // How long a press on a phone card is held before Move to opens.
197
+ export const PHONE_HOLD_MS = 500;
198
+
199
+ // The lane showing when one lane fills the phone and the board snaps.
200
+ // A lane just past halfway counts as the next. `count` is how many lanes.
201
+ export function phoneLaneIndex(scrollLeft, laneWidth, count) {
202
+ if (!(laneWidth > 0) || !(count > 0)) return 0;
203
+ return Math.max(0, Math.min(count - 1, Math.round(scrollLeft / laneWidth)));
204
+ }
@@ -5,6 +5,7 @@
5
5
  // and a recheck button that reads the job from the work system again. Flow
6
6
  // never writes to the work system. Kept apart from
7
7
  // invoicing.js so tests can load it without a browser.
8
+ import { applyPhoneRule } from './mobile.js';
8
9
 
9
10
  // Rows shown before "Show all".
10
11
  export const FIRST_ROWS = 5;
@@ -55,7 +56,7 @@ export function jobChangeBanner(ui, change, { money, onAccept = null, onRecheck
55
56
  }, `Open in ${system} ↗`)
56
57
  : null;
57
58
  const accept = change.canAccept && onAccept
58
- ? h('button', { class: 'fx-btn fx-btn--sm fx-btn--primary', type: 'button', title: `This invoice takes the Now in ${system} column.`, onclick: onAccept }, 'Accept change')
59
+ ? h('button', { class: 'fx-btn fx-btn--sm fx-btn--primary', type: 'button', 'data-action': 'accept', title: `This invoice takes the Now in ${system} column.`, onclick: onAccept }, 'Accept change')
59
60
  : null;
60
61
  const recheck = onRecheck
61
62
  ? h('button', {
@@ -77,7 +78,7 @@ export function jobChangeBanner(ui, change, { money, onAccept = null, onRecheck
77
78
  const more = rows.length > FIRST_ROWS
78
79
  ? h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', onclick: (event) => { draw(true); event.currentTarget.remove(); } }, `Show all ${rows.length}`)
79
80
  : null;
80
- return h('section', { class: 'fx-change', role: 'alert' },
81
+ return applyPhoneRule(h('section', { class: 'fx-change', role: 'alert' },
81
82
  h('div', { class: 'fx-change__head' },
82
83
  h('p', {}, h('strong', {}, 'Problem.'), ' ', changeSummary(change, rows.filter((row) => row.field !== 'Total').length)),
83
84
  h('div', { class: 'fx-change__acts' }, link, accept, recheck)),
@@ -85,7 +86,7 @@ export function jobChangeBanner(ui, change, { money, onAccept = null, onRecheck
85
86
  rows.length ? h('div', { class: 'fx-change__scroll' }, h('table', { class: 'fx-change__table' },
86
87
  h('thead', {}, h('tr', {}, h('th', { scope: 'col' }, 'Field'), h('th', { scope: 'col' }, 'Was'), h('th', { scope: 'col' }, `Now in ${system}`))),
87
88
  body)) : null,
88
- more);
89
+ more), 'jobChangeBanner');
89
90
  }
90
91
 
91
92
  // The banner with Accept change and the recheck wired, for the invoice page
@@ -304,7 +304,9 @@ export function filterJobs(jobs, view, { valueOf, kindOf = (field) => FILTER_FIE
304
304
  const live = (f) => kindOf(f.field) && Object.hasOwn(OPERATORS[kindOf(f.field)], f.op) && filterIsOn(f, kindOf(f.field));
305
305
  const groups = (view.groups ?? []).map((g) => ({ match: g.match, rows: g.filters.filter(live) })).filter((g) => g.rows.length);
306
306
  if (!groups.length) return [...jobs];
307
- const pass = (job) => (f) => matchesFilter(f, valueOf(job, f.field), { today, me, kind: kindOf(f.field), late: f.field === 'due' ? () => late(job) : null });
307
+ // `late` is a job's own lateness (Jobs). Pass null when "due" is a date on
308
+ // the row, so Overdue means that day is before today (Invoices).
309
+ const pass = (job) => (f) => matchesFilter(f, valueOf(job, f.field), { today, me, kind: kindOf(f.field), late: f.field === 'due' && late ? () => late(job) : null });
308
310
  const passGroup = (job) => (g) => (g.match === 'any' ? g.rows.some(pass(job)) : g.rows.every(pass(job)));
309
311
  return jobs.filter((job) => (view.match === 'any' ? groups.some(passGroup(job)) : groups.every(passGroup(job))));
310
312
  }
package/server/ui/jobs.js CHANGED
@@ -9,8 +9,8 @@
9
9
  // (docs/JOB_BOARD_MOVES.md). Grouped by a custom field, the move sets the
10
10
  // job's value, in Flow only. Grouped by status, the move is a stage move
11
11
  // in the work system: Seazona cannot take one through its API, so Flow
12
- // shows what to do there and the card waits in its new lane. On a phone,
13
- // or with a keyboard, the job's panel has the same moves.
12
+ // shows what to do there and the card waits in its new lane. On a phone a
13
+ // long press opens Move to. With a keyboard, the job's panel has the same moves.
14
14
 
15
15
  import { bulkBar } from '/assets/ui/bulk-bar.js';
16
16
  import { columnTable, dateCell, fieldColumn, onRightButton, personCell, rowCells, viewSaver } from '/assets/ui/columns.js';
@@ -18,15 +18,17 @@ import { lucide } from '/assets/ui/custom-fields.js';
18
18
  import { formatCents } from '/assets/ui/invoicing.js';
19
19
  import { jobChangeReview } from '/assets/ui/job-change.js';
20
20
  import {
21
- caseNumber, FINAL_RANK, fixes, INVOICE_STAGES, invoiceStage, matches, NO_STATUS, orderStatuses, problemSummary, statusRank,
21
+ caseNumber, FINAL_RANK, fixes, INVOICE_STAGES, invoiceStage, matches, NO_STATUS, orderStatuses, phoneLaneIndex, PHONE_HOLD_MS, problemSummary, statusRank,
22
22
  } from '/assets/ui/job-board.js';
23
+ import { rowMenu } from '/assets/ui/kit.js';
24
+ import { isPhone } from '/assets/ui/mobile.js';
23
25
  import {
24
26
  COLUMNS, DEFAULT_VIEW, fieldGroup, fieldIdOf, fieldLanes, FILTER_FIELDS, filterJobs, groupJobs, GROUPS, KIND_OF_TYPE, keptStatuses, laneOf,
25
27
  normaliseJobsView, NOT_SET, OPERATORS, sortJobs, SORTS,
26
28
  } from '/assets/ui/job-view.js';
27
29
  import { clipLabel } from '/assets/ui/person.js';
28
30
  import { savedViews } from '/assets/ui/saved-views.js';
29
- import { sidecar } from '/assets/ui/sidecar.js';
31
+ import { recordBar, sidecar } from '/assets/ui/sidecar.js';
30
32
  import { findBox, ICONS, svg, today, viewBar } from '/assets/ui/view-bar.js';
31
33
 
32
34
  const LAYOUT_KEY = 'flow_jobs_layout';
@@ -38,7 +40,7 @@ const SYSTEM_NAME = { seazona: 'Seazona', xero: 'Xero' };
38
40
  // A job's events (server/core/ledger.js), in plain words.
39
41
  const ACTIVITY = {
40
42
  observed: 'Flow read the job', source_changed: 'The job changed in the lab system', ready: 'Marked ready to invoice',
41
- in_work: 'Put back in work', drafted: 'Draft invoice made', draft_refused: 'Draft invoice refused', invoice_linked: 'Linked to an invoice',
43
+ in_work: 'Put back in work', drafted: 'Draft invoice made', draft_deleted: 'Draft invoice deleted', draft_refused: 'Draft invoice refused', invoice_linked: 'Linked to an invoice',
42
44
  accounting_checked: 'Checked in the accounting system', payment_recorded: 'Payment recorded', paid: 'Paid in full',
43
45
  held: 'Put on hold', review: 'Sent to review', released: 'Hold removed',
44
46
  status_moved_back: 'The job status moved back in the lab system', source_lines_used: 'New job lines used on the draft',
@@ -324,6 +326,7 @@ export async function renderJobs(main, ui) {
324
326
 
325
327
  function draw() {
326
328
  if (!state.jobs.length) {
329
+ boardTabs.hidden = true;
327
330
  main.classList.remove('fx-main--board');
328
331
  host.replaceChildren(h('div', { class: 'fx-panel fx-empty' },
329
332
  h('p', {}, 'No jobs yet. Jobs appear here once the lab system is connected under Integrations.'),
@@ -339,6 +342,7 @@ export async function renderJobs(main, ui) {
339
342
  for (const key of state.ticked) if (!shown.has(key)) state.ticked.delete(key);
340
343
  state.found = found;
341
344
  frame.count(`${found.length} ${found.length === 1 ? 'job' : 'jobs'}`);
345
+ if (state.view.layout !== 'board') boardTabs.hidden = true;
342
346
  host.replaceChildren(state.view.layout === 'list' ? list(groups, found.length) : board(groups));
343
347
  drawBulk();
344
348
  }
@@ -389,7 +393,7 @@ export async function renderJobs(main, ui) {
389
393
  const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
390
394
  const card = h('article', {
391
395
  class: 'fx-job', tabindex: '0', role: 'button', 'aria-label': `${clientOf(job)}, open the job`,
392
- onclick: () => { if (!dragged) openJob(job); },
396
+ onclick: () => { if (dragged) { dragged = false; return; } openJob(job); },
393
397
  onkeydown: (event) => { if (event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); openJob(job); } },
394
398
  },
395
399
  h('div', { class: 'fx-job__client' }, clientOf(job)),
@@ -450,19 +454,108 @@ export async function renderJobs(main, ui) {
450
454
  const totalOf = (jobs) => jobs.reduce((sum, job) => sum + (job.totalCents ?? 0), 0);
451
455
 
452
456
  function board(groups) {
453
- if (!groups.length) return h('div', { class: 'fx-panel' }, h('p', { class: 'fx-empty' }, 'No job matches the search or filter.'));
457
+ if (!groups.length) {
458
+ boardTabs.hidden = true;
459
+ return h('div', { class: 'fx-panel' }, h('p', { class: 'fx-empty' }, 'No job matches the search or filter.'));
460
+ }
454
461
  const element = h('div', { class: 'fx-board' }, groups.map(({ label, jobs, rank, color }, index) => h('section', { class: 'fx-board__col', 'aria-label': label, 'data-lane': String(index) },
455
462
  h('header', { class: 'fx-board__head' },
456
463
  color ? h('span', { class: 'fx-cf__opt fx-board__lane', 'data-color': color }, label, h('span', { class: 'fx-stage__count' }, String(jobs.length))) : stagePill(label, jobs.length, rank),
457
464
  h('span', { class: 'fx-board__sum' }, jobs.length ? formatCents(totalOf(jobs)) : '')),
458
465
  h('div', { class: 'fx-board__cards' }, jobs.length ? jobs.map(jobCard) : h('p', { class: 'fx-board__none' }, 'None')))));
459
466
  if (groups.some((g) => g.drop)) enableDrag(element, groups);
467
+ phoneLanes(element, groups);
460
468
  // Lanes past the frame's sides, and cards past a lane's foot, fade.
461
469
  edgeFade(element, { target: frame.view, edges: ['left', 'right'] });
462
470
  element.querySelectorAll('.fx-board__cards').forEach((cards) => edgeFade(cards, { target: cards.parentElement, edges: ['top', 'bottom'] }));
463
471
  return element;
464
472
  }
465
473
 
474
+ // Stage tabs for the phone: one per lane, with its count. A tap brings
475
+ // that lane across; a swipe updates the tab. Hidden on a wider screen.
476
+ const boardTabs = h('div', { class: 'fx-board-tabs', role: 'tablist', 'aria-label': 'Stages', hidden: true });
477
+ function phoneLanes(element, groups) {
478
+ const bar = main.querySelector('.fx-toolbar');
479
+ if (bar && !boardTabs.isConnected) bar.prepend(boardTabs);
480
+ const buttons = groups.map((group, index) => {
481
+ const count = group.jobs.length;
482
+ const name = `${group.label}, ${count} ${count === 1 ? 'job' : 'jobs'}`;
483
+ return h('button', {
484
+ type: 'button', role: 'tab', id: `fx-lane-tab-${index}`,
485
+ 'aria-controls': `fx-lane-${index}`, 'aria-selected': 'false', title: name,
486
+ onclick: () => element.scrollTo({ left: element.querySelector(`#fx-lane-${index}`)?.offsetLeft ?? 0, behavior: 'smooth' }),
487
+ }, h('span', {}, group.label), h('span', { class: 'fx-tabs__count' }, String(count)));
488
+ });
489
+ boardTabs.replaceChildren(...buttons);
490
+ boardTabs.hidden = false;
491
+ const cols = [...element.querySelectorAll(':scope > .fx-board__col')];
492
+ cols.forEach((col, index) => {
493
+ col.id = `fx-lane-${index}`;
494
+ col.setAttribute('role', 'tabpanel');
495
+ col.setAttribute('aria-labelledby', `fx-lane-tab-${index}`);
496
+ });
497
+ let shown = -1;
498
+ const mark = () => {
499
+ const at = phoneLaneIndex(element.scrollLeft, element.clientWidth, cols.length);
500
+ if (at === shown) return;
501
+ shown = at;
502
+ buttons.forEach((button, index) => {
503
+ const on = index === at;
504
+ button.setAttribute('aria-selected', String(on));
505
+ if (on) button.scrollIntoView({ inline: 'nearest', block: 'nearest' });
506
+ });
507
+ };
508
+ element.addEventListener('scroll', () => requestAnimationFrame(mark), { passive: true });
509
+ mark();
510
+ enableHold(element, groups);
511
+ }
512
+
513
+ // A long press on a phone opens Move to. A drag stays a mouse or pen
514
+ // move on a wider screen (enableDrag). A small move cancels the press,
515
+ // so a swipe still changes lane.
516
+ function enableHold(element, groups) {
517
+ element.addEventListener('pointerdown', (event) => {
518
+ if (!isPhone() || event.pointerType !== 'touch' || event.button !== 0) return;
519
+ const card = event.target.closest('.fx-job');
520
+ if (!card || event.target.closest('button, a')) return;
521
+ const startX = event.clientX;
522
+ const startY = event.clientY;
523
+ let held = false;
524
+ const timer = setTimeout(() => {
525
+ held = true;
526
+ dragged = true;
527
+ const from = Number(card.closest('.fx-board__col')?.dataset.lane);
528
+ openMoveSheet(cardJobs.get(card), groups, from);
529
+ }, PHONE_HOLD_MS);
530
+ // The release may land outside the card (a tap still ends on the page),
531
+ // so the press is watched on the document.
532
+ const stop = (end) => {
533
+ if (end.type === 'pointermove' && Math.hypot(end.clientX - startX, end.clientY - startY) <= 10) return;
534
+ clearTimeout(timer);
535
+ document.removeEventListener('pointermove', stop);
536
+ document.removeEventListener('pointerup', stop);
537
+ document.removeEventListener('pointercancel', stop);
538
+ if (held) setTimeout(() => { dragged = false; }, 0);
539
+ };
540
+ document.addEventListener('pointermove', stop);
541
+ document.addEventListener('pointerup', stop);
542
+ document.addEventListener('pointercancel', stop);
543
+ card.addEventListener('contextmenu', (menu) => { if (held) menu.preventDefault(); }, { once: true });
544
+ });
545
+ }
546
+
547
+ async function openMoveSheet(job, groups, from) {
548
+ const choices = groups.flatMap((group, index) => {
549
+ if (index === from || !group.drop) return [];
550
+ if (group.drop.kind !== 'field' && !movesStages(job)) return [];
551
+ return [{ label: group.label, value: String(index), detail: group.jobs.length }];
552
+ });
553
+ if (!choices.length || !job) return;
554
+ const picked = await ui.dialog({ title: 'Move to', choices });
555
+ const target = picked == null ? null : groups[Number(picked)];
556
+ if (target?.drop) moveJob(job, target.drop);
557
+ }
558
+
466
559
  // ── Moves ────────────────────────────────────────────────────────────────
467
560
  // Drag with a mouse or pen: press on a card, move 6px, and a copy of the
468
561
  // card follows the pointer. A lane that takes the card lights up. Touch
@@ -486,7 +579,7 @@ export async function renderJobs(main, ui) {
486
579
  drag = null;
487
580
  };
488
581
  element.addEventListener('pointerdown', (event) => {
489
- if (event.button !== 0 || event.pointerType === 'touch') return;
582
+ if (event.button !== 0 || event.pointerType === 'touch' || isPhone()) return;
490
583
  const card = event.target.closest('.fx-job');
491
584
  if (!card || event.target.closest('button, a')) return;
492
585
  const rect = card.getBoundingClientRect();
@@ -1091,12 +1184,15 @@ export async function renderJobs(main, ui) {
1091
1184
 
1092
1185
  // The job's page: a large panel over the board, like a task page. A
1093
1186
  // breadcrumb, the client's name, the stage, the job's fields, what to fix
1094
- // and its lines. At the side (below on a phone), the sidecar: Activity,
1095
- // and Fix when the job has a problem (for a changed job: what changed,
1096
- // and Accept change). The sidecar closes, and the main area takes its room.
1097
- // Esc, the X or a click outside closes the panel. `atFix` opens the
1098
- // Fix tab on the first problem (or scrolls to the invoice choice). The job's custom fields can
1099
- // be changed here (in Flow only), and its stage moved (a hand-off to the
1187
+ // and its lines. At the side, the sidecar: Activity, and Fix when the job
1188
+ // has a problem (for a changed job: what changed, and Accept change). The
1189
+ // sidecar closes, and the main area takes its room. On a phone the same
1190
+ // panel is the whole screen: back (the board stays where it was scrolled),
1191
+ // the client, the previous and next job, a menu, the sidecar's tabs under
1192
+ // the bar, and the main action fixed at the foot. Esc, the X or a click
1193
+ // outside closes the panel. `atFix` opens the Fix tab on the first problem
1194
+ // (or scrolls to the invoice choice). The job's custom fields can be
1195
+ // changed here (in Flow only), and its stage moved (a hand-off to the
1100
1196
  // work system).
1101
1197
  function openJob(job, { atFix = false } = {}) {
1102
1198
  const o = job.obligation;
@@ -1131,11 +1227,9 @@ export async function renderJobs(main, ui) {
1131
1227
  class: 'fx-task__fixchip', type: 'button', 'aria-pressed': 'false', onclick: () => toggleFix(i),
1132
1228
  }, lucide(h, 'triangle-alert'), f.label));
1133
1229
  function toggleFix(i) {
1134
- if (picked === i && side.tab() === 'fix') { side.close(); return; }
1230
+ if (!isPhone() && picked === i && side.tab() === 'fix') { side.close(); return; }
1135
1231
  picked = i;
1136
1232
  side.open('fix');
1137
- // On a phone the sidecar is below the job: bring it into view.
1138
- if (matchMedia('(max-width: 720px)').matches) side.element.scrollIntoView({ block: 'start' });
1139
1233
  }
1140
1234
  // No invoice, and the match is unclear: one line and two buttons. The
1141
1235
  // possible matches are compared on the Reconcile page, not here.
@@ -1221,6 +1315,7 @@ export async function renderJobs(main, ui) {
1221
1315
  }, lucide(h, 'panel-right'));
1222
1316
  const side = sidecar(h, {
1223
1317
  label: 'Job side panel',
1318
+ phone: 'page',
1224
1319
  tabs: [
1225
1320
  { id: 'activity', label: 'Activity', icon: 'history', render: () => activityBody },
1226
1321
  hasFix ? { id: 'fix', label: 'Fix', icon: 'triangle-alert', mark: true, render: () => { picked ??= 0; return fixBody(); } } : null,
@@ -1233,7 +1328,40 @@ export async function renderJobs(main, ui) {
1233
1328
  },
1234
1329
  });
1235
1330
  side.element.classList.add('fx-task__side');
1236
- element.append(h('div', { class: 'fx-task__grid' },
1331
+ const jobs = state.found ?? [];
1332
+ const at = jobs.findIndex((item) => jobKey(item) === jobKey(job));
1333
+ // Back closes the page. The board is still underneath, at the same scroll.
1334
+ const stepTo = (delta) => {
1335
+ const other = jobs[at + delta];
1336
+ if (!other) return;
1337
+ const board = document.querySelector('.fx-board');
1338
+ const frame = document.querySelector('.fx-frame__body');
1339
+ const saved = { y: window.scrollY, frame: frame?.scrollTop ?? 0, board: board?.scrollLeft ?? 0 };
1340
+ close();
1341
+ openJob(other);
1342
+ const restore = () => {
1343
+ window.scrollTo(0, saved.y);
1344
+ const nextFrame = document.querySelector('.fx-frame__body');
1345
+ const nextBoard = document.querySelector('.fx-board');
1346
+ if (nextFrame) nextFrame.scrollTop = saved.frame;
1347
+ if (nextBoard) nextBoard.scrollLeft = saved.board;
1348
+ };
1349
+ restore();
1350
+ requestAnimationFrame(restore);
1351
+ };
1352
+ const menuItems = [];
1353
+ if (choose && o?.id) menuItems.push({ icon: 'git-compare', text: 'Reconcile', run: () => { close(); ui.go(`/reconcile/${o.id}`); } });
1354
+ if (job.link?.url) menuItems.push({ icon: 'external-link', text: `Open in ${system}`, run: () => window.open(job.link.url, '_blank', 'noopener') });
1355
+ const menu = menuItems.length ? rowMenu('More', menuItems, { className: 'fx-record__menu' }) : null;
1356
+ const bar = recordBar(h, {
1357
+ title: clientOf(job),
1358
+ label: 'Job',
1359
+ onBack: close,
1360
+ prev: { label: 'Previous job', disabled: at <= 0, onclick: () => stepTo(-1) },
1361
+ next: { label: 'Next job', disabled: at < 0 || at >= jobs.length - 1, onclick: () => stepTo(1) },
1362
+ menu: menu?.[0] ?? null,
1363
+ });
1364
+ element.append(bar, h('div', { class: 'fx-task__grid' },
1237
1365
  h('div', { class: 'fx-task__main' },
1238
1366
  h('header', { class: 'fx-task__head' },
1239
1367
  h('nav', { class: 'fx-task__crumb', 'aria-label': 'Breadcrumb' },
@@ -1276,12 +1404,15 @@ export async function renderJobs(main, ui) {
1276
1404
  : h('p', { class: 'fx-dim' }, 'No lines.')),
1277
1405
  h('div', { class: 'fx-task__actions' }, actions(job, close))),
1278
1406
  side.element));
1407
+ if (menu) element.append(menu[1]);
1279
1408
  element.addEventListener('click', (event) => { if (event.target === element) close(); });
1280
1409
  element.addEventListener('close', () => element.remove());
1281
1410
  document.body.append(element);
1282
1411
  element.showModal();
1283
1412
  const last = readSide();
1284
- if (atFix && hasFix) toggleFix(0);
1413
+ // On a phone the tabs are the page, so the sidecar stays open.
1414
+ if (isPhone()) side.open((atFix || last === 'fix') && hasFix ? 'fix' : 'activity');
1415
+ else if (atFix && hasFix) toggleFix(0);
1285
1416
  else if (last !== 'closed') side.open(last === 'fix' && hasFix ? 'fix' : 'activity');
1286
1417
  if (atFix && chooseBox) chooseBox.scrollIntoView({ block: 'start' });
1287
1418
  if (!o) return;