@arcforgelabs/flow 2026.10.45 → 2026.10.47

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 (49) hide show
  1. package/README.md +1 -0
  2. package/VISION.md +10 -10
  3. package/npm-shrinkwrap.json +104 -2
  4. package/package.json +6 -2
  5. package/server/api/invoicing.js +56 -16
  6. package/server/app.js +4 -2
  7. package/server/auth/people.js +5 -3
  8. package/server/auth/routes.js +4 -1
  9. package/server/core/invoice-rules.js +34 -4
  10. package/server/core/invoices.js +40 -1
  11. package/server/core/invoicing-rules.js +20 -3
  12. package/server/core/job-board.js +1 -1
  13. package/server/core/sending.js +76 -22
  14. package/server/integrations/call-guard.js +21 -4
  15. package/server/integrations/sentry/index.js +14 -7
  16. package/server/integrations/xero/client.js +12 -2
  17. package/server/integrations/xero/migrations/0010_xero_call_usage.sql +11 -0
  18. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  19. package/server/integrations/xero/routes.js +29 -5
  20. package/server/integrations/xero/runtime.js +5 -0
  21. package/server/integrations/xero/usage.js +74 -0
  22. package/server/ui/alerts.js +2 -1
  23. package/server/ui/app.js +110 -27
  24. package/server/ui/bulk-bar.js +18 -5
  25. package/server/ui/columns.js +54 -2
  26. package/server/ui/custom-fields.js +22 -7
  27. package/server/ui/flow.css +353 -45
  28. package/server/ui/invoicing.js +286 -64
  29. package/server/ui/job-board.js +10 -0
  30. package/server/ui/job-change.js +4 -3
  31. package/server/ui/job-view.js +14 -4
  32. package/server/ui/jobs.js +156 -56
  33. package/server/ui/kit.js +212 -51
  34. package/server/ui/list-views.js +50 -10
  35. package/server/ui/mobile.js +726 -0
  36. package/server/ui/monitoring.js +51 -0
  37. package/server/ui/nav-pins.js +41 -0
  38. package/server/ui/page.js +110 -5
  39. package/server/ui/pager.js +57 -10
  40. package/server/ui/price-list.js +1 -1
  41. package/server/ui/price-reconcile.js +12 -12
  42. package/server/ui/reconcile.js +19 -18
  43. package/server/ui/routes.js +1 -1
  44. package/server/ui/saved-views.js +6 -5
  45. package/server/ui/sidecar.js +106 -6
  46. package/server/ui/view-bar.js +22 -11
  47. package/server/ui/xero-import.js +48 -19
  48. package/server/ui/xero-usage-model.js +106 -0
  49. package/server/ui/xero-usage.js +133 -0
@@ -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
  }
@@ -473,7 +475,14 @@ export function inOrder(columns, ids) {
473
475
  // shows the invoice's problem; Number shows on the tabs with numbers only.
474
476
  export const INVOICE_COLUMNS = Object.freeze({
475
477
  number: 'Number', client: 'Client', reference: 'Reference', date: 'Date', total: 'Total', status: 'Status', linked: 'Linked',
478
+ due: 'Due', case: 'Case', practitioner: 'Practitioner', shipped: 'Shipped',
476
479
  });
480
+ // The Invoices columns a person sees before they choose: the others (the
481
+ // invoice's due date, its job's case, practitioner and shipped date, and
482
+ // the lab's custom fields) wait in the + menu.
483
+ export const INVOICE_SHOWN = Object.freeze(['number', 'client', 'reference', 'date', 'total', 'status', 'linked']);
484
+ // A person's Invoices columns: custom fields ('field:<id>') are columns too.
485
+ export const invoiceColumnsView = (input) => normaliseListView(input, INVOICE_COLUMNS, { shown: INVOICE_SHOWN, fields: true });
477
486
  export const PRICE_COLUMNS = Object.freeze({
478
487
  code: 'Code', name: 'Name', price: 'Price', tax: 'Tax',
479
488
  });
@@ -482,12 +491,13 @@ export const RECONCILE_COLUMNS = Object.freeze({
482
491
  });
483
492
 
484
493
  // A person's view of a list with set columns (Invoices, Reconcile, Price list): the
485
- // shown columns in order (every one when none is saved) and their widths.
486
- export function normaliseListView(input, columns) {
494
+ // shown columns in order (`shown`, else every one, when none is saved) and
495
+ // their widths. With `fields`, a custom field ('field:<id>') is a column too.
496
+ export function normaliseListView(input, columns, { shown = null, fields = false } = {}) {
487
497
  const value = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
488
498
  const ids = Object.keys(columns);
489
499
  return {
490
- columns: cleanColumnIds(value.columns, (id) => ids.includes(id)) ?? ids,
500
+ columns: cleanColumnIds(value.columns, (id) => ids.includes(id) || (fields && FIELD_GROUP.test(id))) ?? [...(shown ?? ids)],
491
501
  widths: cleanWidths(value.widths),
492
502
  };
493
503
  }
package/server/ui/jobs.js CHANGED
@@ -9,24 +9,26 @@
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
- import { columnTable, onRightButton, rowCells, viewSaver } from '/assets/ui/columns.js';
16
+ import { columnTable, dateCell, fieldColumn, onRightButton, personCell, rowCells, viewSaver } from '/assets/ui/columns.js';
17
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
- import { clipLabel, initialsOf } from '/assets/ui/person.js';
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',
@@ -52,18 +54,6 @@ const STAGE_RANK = { 'In work': 0, Drafted: 2, Problem: 2, 'Ready to invoice': 4
52
54
 
53
55
  const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short' }) : '—');
54
56
  const fullDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) : '—');
55
- // A list date, short: Today, Tomorrow or Yesterday, else day/month (and
56
- // year when it is not this year).
57
- const shortDate = (iso) => {
58
- if (!iso) return '—';
59
- const at = new Date(`${iso}T00:00:00`);
60
- const now = new Date();
61
- const days = Math.round((at - new Date(now.getFullYear(), now.getMonth(), now.getDate())) / 86_400_000);
62
- if (days === 0) return 'Today';
63
- if (days === 1) return 'Tomorrow';
64
- if (days === -1) return 'Yesterday';
65
- return at.toLocaleDateString('en-AU', at.getFullYear() === now.getFullYear() ? { day: '2-digit', month: '2-digit' } : { day: '2-digit', month: '2-digit', year: '2-digit' });
66
- };
67
57
  const clientOf = (job) => job.clientName ?? 'No client';
68
58
  // The one date a card shows: when the job is due, else when it was ordered.
69
59
  const dateOf = (job) => (job.dueOn ? `Due ${showDate(job.dueOn)}` : job.orderedOn ? `Ordered ${showDate(job.orderedOn)}` : job.jobDate ? showDate(job.jobDate) : null);
@@ -136,7 +126,7 @@ export async function renderJobs(main, ui) {
136
126
  // In the list, ticked rows get the same floating bar as Invoices.
137
127
  const bulk = bulkBar(ui, { label: 'Ticked jobs' });
138
128
  const frame = listFrame(main, host);
139
- main.append(...listHead('Jobs', { views: views.element, start: controls, end: [find.element, toggle] }).nodes, frame.element, bulk.element);
129
+ main.append(...listHead('Jobs', { views: views.element, controls, end: [find.element, toggle] }).nodes, frame.element, bulk.element);
140
130
 
141
131
  const drawToggle = () => toggle.replaceChildren(...[['board', 'Board'], ['list', 'List']].map(([id, label]) => h('button', {
142
132
  type: 'button', role: 'radio', class: 'fx-seg__item', 'aria-checked': String(state.view.layout === id),
@@ -336,6 +326,7 @@ export async function renderJobs(main, ui) {
336
326
 
337
327
  function draw() {
338
328
  if (!state.jobs.length) {
329
+ boardTabs.hidden = true;
339
330
  main.classList.remove('fx-main--board');
340
331
  host.replaceChildren(h('div', { class: 'fx-panel fx-empty' },
341
332
  h('p', {}, 'No jobs yet. Jobs appear here once the lab system is connected under Integrations.'),
@@ -351,6 +342,7 @@ export async function renderJobs(main, ui) {
351
342
  for (const key of state.ticked) if (!shown.has(key)) state.ticked.delete(key);
352
343
  state.found = found;
353
344
  frame.count(`${found.length} ${found.length === 1 ? 'job' : 'jobs'}`);
345
+ if (state.view.layout !== 'board') boardTabs.hidden = true;
354
346
  host.replaceChildren(state.view.layout === 'list' ? list(groups, found.length) : board(groups));
355
347
  drawBulk();
356
348
  }
@@ -401,7 +393,7 @@ export async function renderJobs(main, ui) {
401
393
  const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
402
394
  const card = h('article', {
403
395
  class: 'fx-job', tabindex: '0', role: 'button', 'aria-label': `${clientOf(job)}, open the job`,
404
- onclick: () => { if (!dragged) openJob(job); },
396
+ onclick: () => { if (dragged) { dragged = false; return; } openJob(job); },
405
397
  onkeydown: (event) => { if (event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); openJob(job); } },
406
398
  },
407
399
  h('div', { class: 'fx-job__client' }, clientOf(job)),
@@ -462,19 +454,108 @@ export async function renderJobs(main, ui) {
462
454
  const totalOf = (jobs) => jobs.reduce((sum, job) => sum + (job.totalCents ?? 0), 0);
463
455
 
464
456
  function board(groups) {
465
- 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
+ }
466
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) },
467
462
  h('header', { class: 'fx-board__head' },
468
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),
469
464
  h('span', { class: 'fx-board__sum' }, jobs.length ? formatCents(totalOf(jobs)) : '')),
470
465
  h('div', { class: 'fx-board__cards' }, jobs.length ? jobs.map(jobCard) : h('p', { class: 'fx-board__none' }, 'None')))));
471
466
  if (groups.some((g) => g.drop)) enableDrag(element, groups);
467
+ phoneLanes(element, groups);
472
468
  // Lanes past the frame's sides, and cards past a lane's foot, fade.
473
469
  edgeFade(element, { target: frame.view, edges: ['left', 'right'] });
474
470
  element.querySelectorAll('.fx-board__cards').forEach((cards) => edgeFade(cards, { target: cards.parentElement, edges: ['top', 'bottom'] }));
475
471
  return element;
476
472
  }
477
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
+
478
559
  // ── Moves ────────────────────────────────────────────────────────────────
479
560
  // Drag with a mouse or pen: press on a card, move 6px, and a copy of the
480
561
  // card follows the pointer. A lane that takes the card lights up. Touch
@@ -498,7 +579,7 @@ export async function renderJobs(main, ui) {
498
579
  drag = null;
499
580
  };
500
581
  element.addEventListener('pointerdown', (event) => {
501
- if (event.button !== 0 || event.pointerType === 'touch') return;
582
+ if (event.button !== 0 || event.pointerType === 'touch' || isPhone()) return;
502
583
  const card = event.target.closest('.fx-job');
503
584
  if (!card || event.target.closest('button, a')) return;
504
585
  const rect = card.getBoundingClientRect();
@@ -799,7 +880,7 @@ export async function renderJobs(main, ui) {
799
880
  // The list's columns, in the order the person chose them. One column holds
800
881
  // one field; each date has its own column.
801
882
  function listColumns() {
802
- const day = (iso) => h('time', { datetime: iso ?? null, title: iso ? fullDate(iso) : null }, shortDate(iso));
883
+ const day = (iso) => dateCell(h, iso);
803
884
  const late = (job) => (isLate(job) ? h('span', { class: 'fx-job__date--late', title: 'Past due' }, day(job.dueOn)) : day(job.dueOn));
804
885
  const BUILT_IN = {
805
886
  case: { kind: 'case', className: 'fx-dim', cell: (job) => caseNumber(job) ?? '—' },
@@ -821,31 +902,12 @@ export async function renderJobs(main, ui) {
821
902
  return [client, ...state.view.columns.map((id) => {
822
903
  if (BUILT_IN[id]) return allowed.has(id) ? { id, label: id === 'assignee' ? assigneeField().name : COLUMNS[id], ...BUILT_IN[id] } : null;
823
904
  const field = customColumns().find((f) => fieldGroup(f.id) === id);
824
- if (!field) return null;
825
- const cell = field.type === 'person' ? (job) => personBadge(job, field)
826
- : field.type === 'date' ? (job) => day(valueOf(job, field.id)?.value ?? null) : (job) => fieldText(job, field);
827
- return { id, label: field.name, kind: field.type === 'date' ? 'date' : field.type === 'person' ? 'person' : field.type === 'number' ? 'total' : 'text', num: field.type === 'number', className: 'fx-dim', cell };
905
+ return field ? fieldColumn(h, field, { id, valueOf: (job) => valueOf(job, field.id)?.value ?? null, people: state.people }) : null;
828
906
  }).filter(Boolean).map((c) => ({ ...c, sort: () => sortButton(c.id, c.label) }))];
829
907
  }
830
908
 
831
909
  // A person custom field's value as a badge (initials), the name on hover.
832
- function personBadge(job, field) {
833
- const value = field ? valueOf(job, field.id)?.value ?? null : null;
834
- if (!value) return h('span', { class: 'fx-dim' }, '—');
835
- const name = state.people.find((p) => p.id === value)?.name ?? 'Former person';
836
- return h('span', { class: 'fx-avatar fx-avatar--sm', role: 'img', 'aria-label': name, title: name }, initialsOf(name));
837
- }
838
-
839
- // A job's custom field value as text.
840
- function fieldText(job, field) {
841
- const value = field ? valueOf(job, field.id)?.value ?? null : null;
842
- if (value == null || value === false) return '—';
843
- if (field.type === 'checkbox') return 'Yes';
844
- if (field.type === 'person') return state.people.find((p) => p.id === value)?.name ?? 'Former person';
845
- if (field.type === 'dropdown') return field.options?.find((o) => o.id === value)?.name ?? '—';
846
- if (field.type === 'date') return showDate(value);
847
- return String(value);
848
- }
910
+ const personBadge = (job, field) => personCell(h, field ? valueOf(job, field.id)?.value ?? null : null, state.people);
849
911
 
850
912
  // The list: one block per group, with the group's label over its rows.
851
913
  // A tick column at the left; a group's head has a tick for every job in
@@ -1122,12 +1184,15 @@ export async function renderJobs(main, ui) {
1122
1184
 
1123
1185
  // The job's page: a large panel over the board, like a task page. A
1124
1186
  // breadcrumb, the client's name, the stage, the job's fields, what to fix
1125
- // and its lines. At the side (below on a phone), the sidecar: Activity,
1126
- // and Fix when the job has a problem (for a changed job: what changed,
1127
- // and Accept change). The sidecar closes, and the main area takes its room.
1128
- // Esc, the X or a click outside closes the panel. `atFix` opens the
1129
- // Fix tab on the first problem (or scrolls to the invoice choice). The job's custom fields can
1130
- // 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
1131
1196
  // work system).
1132
1197
  function openJob(job, { atFix = false } = {}) {
1133
1198
  const o = job.obligation;
@@ -1162,11 +1227,9 @@ export async function renderJobs(main, ui) {
1162
1227
  class: 'fx-task__fixchip', type: 'button', 'aria-pressed': 'false', onclick: () => toggleFix(i),
1163
1228
  }, lucide(h, 'triangle-alert'), f.label));
1164
1229
  function toggleFix(i) {
1165
- if (picked === i && side.tab() === 'fix') { side.close(); return; }
1230
+ if (!isPhone() && picked === i && side.tab() === 'fix') { side.close(); return; }
1166
1231
  picked = i;
1167
1232
  side.open('fix');
1168
- // On a phone the sidecar is below the job: bring it into view.
1169
- if (matchMedia('(max-width: 720px)').matches) side.element.scrollIntoView({ block: 'start' });
1170
1233
  }
1171
1234
  // No invoice, and the match is unclear: one line and two buttons. The
1172
1235
  // possible matches are compared on the Reconcile page, not here.
@@ -1252,6 +1315,7 @@ export async function renderJobs(main, ui) {
1252
1315
  }, lucide(h, 'panel-right'));
1253
1316
  const side = sidecar(h, {
1254
1317
  label: 'Job side panel',
1318
+ phone: 'page',
1255
1319
  tabs: [
1256
1320
  { id: 'activity', label: 'Activity', icon: 'history', render: () => activityBody },
1257
1321
  hasFix ? { id: 'fix', label: 'Fix', icon: 'triangle-alert', mark: true, render: () => { picked ??= 0; return fixBody(); } } : null,
@@ -1264,7 +1328,40 @@ export async function renderJobs(main, ui) {
1264
1328
  },
1265
1329
  });
1266
1330
  side.element.classList.add('fx-task__side');
1267
- 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' },
1268
1365
  h('div', { class: 'fx-task__main' },
1269
1366
  h('header', { class: 'fx-task__head' },
1270
1367
  h('nav', { class: 'fx-task__crumb', 'aria-label': 'Breadcrumb' },
@@ -1307,12 +1404,15 @@ export async function renderJobs(main, ui) {
1307
1404
  : h('p', { class: 'fx-dim' }, 'No lines.')),
1308
1405
  h('div', { class: 'fx-task__actions' }, actions(job, close))),
1309
1406
  side.element));
1407
+ if (menu) element.append(menu[1]);
1310
1408
  element.addEventListener('click', (event) => { if (event.target === element) close(); });
1311
1409
  element.addEventListener('close', () => element.remove());
1312
1410
  document.body.append(element);
1313
1411
  element.showModal();
1314
1412
  const last = readSide();
1315
- 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);
1316
1416
  else if (last !== 'closed') side.open(last === 'fix' && hasFix ? 'fix' : 'activity');
1317
1417
  if (atFix && chooseBox) chooseBox.scrollIntoView({ block: 'start' });
1318
1418
  if (!o) return;