@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.
- package/README.md +1 -0
- package/VISION.md +10 -10
- package/npm-shrinkwrap.json +104 -2
- package/package.json +6 -2
- package/server/api/invoicing.js +56 -16
- package/server/app.js +4 -2
- package/server/auth/people.js +5 -3
- package/server/auth/routes.js +4 -1
- package/server/core/invoice-rules.js +34 -4
- package/server/core/invoices.js +40 -1
- package/server/core/invoicing-rules.js +20 -3
- package/server/core/job-board.js +1 -1
- package/server/core/sending.js +76 -22
- package/server/integrations/call-guard.js +21 -4
- package/server/integrations/sentry/index.js +14 -7
- package/server/integrations/xero/client.js +12 -2
- package/server/integrations/xero/migrations/0010_xero_call_usage.sql +11 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/routes.js +29 -5
- package/server/integrations/xero/runtime.js +5 -0
- package/server/integrations/xero/usage.js +74 -0
- package/server/ui/alerts.js +2 -1
- package/server/ui/app.js +110 -27
- package/server/ui/bulk-bar.js +18 -5
- package/server/ui/columns.js +54 -2
- package/server/ui/custom-fields.js +22 -7
- package/server/ui/flow.css +353 -45
- package/server/ui/invoicing.js +286 -64
- package/server/ui/job-board.js +10 -0
- package/server/ui/job-change.js +4 -3
- package/server/ui/job-view.js +14 -4
- package/server/ui/jobs.js +156 -56
- package/server/ui/kit.js +212 -51
- package/server/ui/list-views.js +50 -10
- package/server/ui/mobile.js +726 -0
- package/server/ui/monitoring.js +51 -0
- package/server/ui/nav-pins.js +41 -0
- package/server/ui/page.js +110 -5
- 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 +19 -18
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +6 -5
- 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/xero-usage-model.js +106 -0
- package/server/ui/xero-usage.js +133 -0
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
|
}
|
|
@@ -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
|
|
486
|
-
|
|
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
|
-
//
|
|
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
|
|
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,
|
|
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 (
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1126
|
-
//
|
|
1127
|
-
//
|
|
1128
|
-
//
|
|
1129
|
-
//
|
|
1130
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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;
|