@arcforgelabs/flow 2026.10.46 → 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.
- package/README.md +1 -0
- package/VISION.md +10 -10
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/invoicing.js +89 -22
- package/server/auth/people.js +3 -1
- package/server/auth/routes.js +4 -1
- package/server/core/contacts.js +160 -0
- package/server/core/directory.js +4 -9
- package/server/core/invoice-rules.js +34 -4
- package/server/core/invoices.js +93 -1
- package/server/core/invoicing-rules.js +20 -3
- package/server/core/job-board.js +1 -1
- package/server/core/sending.js +141 -25
- package/server/db/doctor.js +1 -1
- package/server/db/index.js +19 -0
- package/server/db/migrations/0021_flow_schema.sql +68 -0
- package/server/db/migrations/0022_contacts.sql +11 -0
- package/server/db/migrations/meta/0021_snapshot.json +4377 -0
- package/server/db/migrations/meta/0022_snapshot.json +4446 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +15 -0
- package/server/integrations/port.js +2 -0
- package/server/integrations/schema.js +3 -2
- package/server/integrations/sentry/index.js +14 -7
- package/server/integrations/setup.js +1 -0
- package/server/integrations/xero/catalogue.js +82 -14
- package/server/integrations/xero/client.js +37 -0
- package/server/integrations/xero/create-contact.js +126 -0
- package/server/integrations/xero/delete-invoice.js +104 -0
- package/server/integrations/xero/drafts.js +19 -8
- package/server/integrations/xero/index.js +9 -0
- package/server/integrations/xero/migrations/0011_xero_invoice_deleted.sql +11 -0
- package/server/integrations/xero/migrations/0012_xero_contact_details.sql +16 -0
- package/server/integrations/xero/migrations/meta/_journal.json +14 -0
- package/server/integrations/xero/routes.js +21 -0
- package/server/integrations/xero/settings.js +3 -0
- package/server/ui/alerts.js +2 -1
- package/server/ui/app.js +113 -28
- package/server/ui/bulk-bar.js +18 -5
- package/server/ui/columns.js +5 -2
- package/server/ui/contacts.js +260 -0
- package/server/ui/custom-fields.js +22 -7
- package/server/ui/flow.css +275 -24
- package/server/ui/invoicing.js +374 -70
- package/server/ui/job-board.js +10 -0
- package/server/ui/job-change.js +4 -3
- package/server/ui/job-view.js +3 -1
- package/server/ui/jobs.js +150 -19
- package/server/ui/kit.js +220 -45
- package/server/ui/list-views.js +50 -10
- package/server/ui/mobile.js +729 -0
- package/server/ui/monitoring.js +51 -0
- package/server/ui/nav-pins.js +44 -0
- package/server/ui/pager.js +57 -10
- package/server/ui/price-list.js +1 -1
- package/server/ui/price-reconcile.js +12 -12
- package/server/ui/reconcile.js +18 -17
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +2 -1
- package/server/ui/sidecar.js +106 -6
- package/server/ui/view-bar.js +22 -11
- package/server/ui/xero-import.js +48 -19
package/server/ui/job-board.js
CHANGED
|
@@ -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
|
+
}
|
package/server/ui/job-change.js
CHANGED
|
@@ -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
|
package/server/ui/job-view.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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 (
|
|
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)
|
|
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
|
|
1095
|
-
//
|
|
1096
|
-
//
|
|
1097
|
-
//
|
|
1098
|
-
//
|
|
1099
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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;
|