@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/app.js
CHANGED
|
@@ -18,12 +18,14 @@ import { renderCustomFields } from '/assets/ui/custom-fields.js';
|
|
|
18
18
|
import { renderJobs } from '/assets/ui/jobs.js';
|
|
19
19
|
import { renderReconcile } from '/assets/ui/reconcile.js';
|
|
20
20
|
import { renderXeroImport } from '/assets/ui/xero-import.js';
|
|
21
|
+
import { xeroUsage } from '/assets/ui/xero-usage.js';
|
|
21
22
|
import { startMonitoring } from '/assets/ui/monitoring.js';
|
|
22
23
|
import { isSearchKey, openSearch } from '/assets/ui/search.js';
|
|
23
24
|
import { alertStrip } from '/assets/ui/alerts.js';
|
|
24
25
|
import { initialsOf, personLabel } from '/assets/ui/person.js';
|
|
25
26
|
import { startTips } from '/assets/ui/tip.js';
|
|
26
27
|
import { crumbs, dialog, edgeFade, fieldError, h, holdButton, linksOpenWith, listFrame, listHead, notice, pageHeader, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
|
|
28
|
+
import { NAV_DEFAULT, NAV_LOCKED } from '/assets/ui/nav-pins.js';
|
|
27
29
|
|
|
28
30
|
const $ = (id) => document.getElementById(id);
|
|
29
31
|
|
|
@@ -142,19 +144,20 @@ function roleSelect(value, attrs = {}) {
|
|
|
142
144
|
|
|
143
145
|
let me = null;
|
|
144
146
|
|
|
145
|
-
// Where each view sits
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
// by link
|
|
147
|
+
// Where each view sits, in sidebar order, daily work first. No `place`: a
|
|
148
|
+
// sidebar page, pinned until the person unpins it. `place: 'more'`: a sidebar
|
|
149
|
+
// page that starts in More until they pin it. `place: 'account'`: the account
|
|
150
|
+
// menu. `sidebar: false`: opened by a link or Search only. Jobs and Invoices
|
|
151
|
+
// cannot be unpinned (nav-pins.js).
|
|
149
152
|
const VIEWS = {
|
|
150
153
|
jobs: { label: 'Jobs', icon: 'jobs', render: (main) => renderJobs(main, ui()) },
|
|
151
154
|
invoices: { label: 'Invoices', icon: 'invoices', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
|
|
152
155
|
reconcile: { label: 'Reconcile', icon: 'reconcile', render: (main, id) => renderReconcile(main, ui(), id) },
|
|
153
156
|
'price-list': { label: 'Price list', icon: 'tag', render: (main, id) => (id === 'reconcile' ? renderPriceReconcile(main, ui()) : renderPriceList(main, ui())) },
|
|
154
157
|
'invoicing-settings': { label: 'Rules', icon: 'rules', render: (main, id) => renderInvoicingSettings(main, ui(), id) },
|
|
155
|
-
automations: { label: 'Automations', icon: 'bolt', place: 'more',
|
|
156
|
-
'custom-fields': { label: 'Custom fields', icon: 'fields', place: 'more',
|
|
157
|
-
integrations: { label: 'Integrations', icon: 'plug', place: 'more',
|
|
158
|
+
automations: { label: 'Automations', icon: 'bolt', place: 'more', render: (main) => renderAutomations(main, ui()) },
|
|
159
|
+
'custom-fields': { label: 'Custom fields', icon: 'fields', place: 'more', render: (main) => renderCustomFields(main, ui()) },
|
|
160
|
+
integrations: { label: 'Integrations', icon: 'plug', place: 'more', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
|
|
158
161
|
people: { label: 'People', icon: 'people', place: 'account', ownerOnly: true, render: renderPeople },
|
|
159
162
|
// Not in the sidebar: "Manage account" in the account menu opens the same
|
|
160
163
|
// Clerk profile. The page stays for links and Search.
|
|
@@ -300,11 +303,7 @@ async function route() {
|
|
|
300
303
|
const [view, id] = currentView();
|
|
301
304
|
const path = id ? `/${view}/${id}` : `/${view}`;
|
|
302
305
|
if (location.pathname !== path) history.replaceState(null, '', path);
|
|
303
|
-
|
|
304
|
-
link.setAttribute('aria-current', link.dataset.nav === view ? 'page' : 'false');
|
|
305
|
-
}
|
|
306
|
-
// More shows as current while one of its pages is open.
|
|
307
|
-
document.querySelector('[data-nav="more"]')?.setAttribute('aria-current', VIEWS[view].place === 'more' ? 'page' : 'false');
|
|
306
|
+
markNavCurrent(view);
|
|
308
307
|
main.replaceChildren();
|
|
309
308
|
main.className = 'fx-main';
|
|
310
309
|
document.title = `${VIEWS[view].label} · Flow`;
|
|
@@ -358,26 +357,108 @@ function renderSignedOut() {
|
|
|
358
357
|
});
|
|
359
358
|
}
|
|
360
359
|
|
|
360
|
+
// Sidebar pages this person may open. Account pages stay in the account menu.
|
|
361
|
+
function sidebarViews() {
|
|
362
|
+
return Object.entries(VIEWS).filter(([, view]) => view.sidebar !== false && view.place !== 'account' && (!view.ownerOnly || me?.person.role === 'owner'));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Null until the shell reads the account. Jobs and Invoices are always in it.
|
|
366
|
+
let pinned = null;
|
|
367
|
+
let pinEpoch = 0;
|
|
368
|
+
let pinWrite = Promise.resolve();
|
|
369
|
+
let morePanel = null;
|
|
370
|
+
|
|
371
|
+
function pinSet() {
|
|
372
|
+
if (!pinned) {
|
|
373
|
+
pinned = new Set(me?.views?.nav?.pinned ?? NAV_DEFAULT);
|
|
374
|
+
for (const name of NAV_LOCKED) pinned.add(name);
|
|
375
|
+
}
|
|
376
|
+
return pinned;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function navLink(name, view) {
|
|
380
|
+
return h('a', { href: `/${name}`, 'data-nav': name, 'data-label': view.label, onclick: navigate }, icon(view.icon), h('span', { class: 'fx-nav__label' }, view.label));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// More is the current page while the open page is a sidebar page that is
|
|
384
|
+
// not pinned, so it has no row of its own.
|
|
385
|
+
function markNavCurrent(view = currentView()[0]) {
|
|
386
|
+
for (const link of document.querySelectorAll('a[data-nav]')) {
|
|
387
|
+
link.setAttribute('aria-current', link.dataset.nav === view ? 'page' : 'false');
|
|
388
|
+
}
|
|
389
|
+
const shown = [...document.querySelectorAll('a[data-nav]')].some((link) => link.dataset.nav === view);
|
|
390
|
+
const inSidebar = sidebarViews().some(([name]) => name === view);
|
|
391
|
+
document.querySelector('[data-nav="more"]')?.setAttribute('aria-current', inSidebar && !shown ? 'page' : 'false');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function syncSidebar() {
|
|
395
|
+
const nav = $('nav');
|
|
396
|
+
const moreButton = nav?.querySelector('[data-nav="more"]');
|
|
397
|
+
if (!moreButton) return;
|
|
398
|
+
for (const link of [...nav.querySelectorAll('a[data-nav]')]) link.remove();
|
|
399
|
+
const links = sidebarViews().filter(([name]) => pinSet().has(name)).map(([name, view]) => navLink(name, view));
|
|
400
|
+
if (links.length) moreButton.before(...links);
|
|
401
|
+
morePanel?.refresh?.();
|
|
402
|
+
drawMenu();
|
|
403
|
+
markNavCurrent();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function moreItems() {
|
|
407
|
+
const pins = pinSet();
|
|
408
|
+
return sidebarViews().map(([name, view]) => {
|
|
409
|
+
const on = pins.has(name);
|
|
410
|
+
const locked = NAV_LOCKED.includes(name);
|
|
411
|
+
return {
|
|
412
|
+
icon: icon(view.icon),
|
|
413
|
+
text: view.label,
|
|
414
|
+
run: () => { closeMenu(); go(`/${name}`); },
|
|
415
|
+
trailing: locked
|
|
416
|
+
? { icon: 'pin', label: 'Pinned', pressed: true, locked: true }
|
|
417
|
+
: { icon: 'pin', label: on ? 'Unpin' : 'Pin', pressed: on, run: () => togglePin(name) },
|
|
418
|
+
};
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function togglePin(name) {
|
|
423
|
+
if (NAV_LOCKED.includes(name)) return;
|
|
424
|
+
const next = new Set(pinSet());
|
|
425
|
+
if (next.has(name)) next.delete(name);
|
|
426
|
+
else next.add(name);
|
|
427
|
+
for (const locked of NAV_LOCKED) next.add(locked);
|
|
428
|
+
const epoch = ++pinEpoch;
|
|
429
|
+
pinned = next;
|
|
430
|
+
syncSidebar();
|
|
431
|
+
const body = { pinned: [...next] };
|
|
432
|
+
pinWrite = pinWrite.then(() => savePins(body, epoch), () => savePins(body, epoch));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function savePins(body, epoch) {
|
|
436
|
+
try {
|
|
437
|
+
const saved = await flow('/api/me/views/nav', { method: 'PUT', body: JSON.stringify(body) });
|
|
438
|
+
if (epoch !== pinEpoch) return;
|
|
439
|
+
pinned = new Set(saved.view.pinned);
|
|
440
|
+
me.views = { ...(me.views ?? {}), nav: saved.view };
|
|
441
|
+
syncSidebar();
|
|
442
|
+
} catch (error) {
|
|
443
|
+
if (epoch !== pinEpoch) return;
|
|
444
|
+
pinned = new Set(me?.views?.nav?.pinned ?? NAV_DEFAULT);
|
|
445
|
+
for (const locked of NAV_LOCKED) pinned.add(locked);
|
|
446
|
+
syncSidebar();
|
|
447
|
+
toast(error.message, 'danger');
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
361
451
|
function renderShell() {
|
|
362
452
|
document.body.dataset.state = 'signed-in';
|
|
363
453
|
const nav = $('nav');
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
for (const [name, view] of Object.entries(VIEWS)) {
|
|
367
|
-
if (view.ownerOnly && me?.person.role !== 'owner') continue;
|
|
368
|
-
if (view.sidebar === false || view.place === 'account') continue;
|
|
369
|
-
if (view.place === 'more') more.push({ icon: view.more, text: view.label, run: () => { closeMenu(); go(`/${name}`); } });
|
|
370
|
-
else items.push(h('a', { href: `/${name}`, 'data-nav': name, 'data-label': view.label, onclick: navigate }, icon(view.icon), h('span', { class: 'fx-nav__label' }, view.label)));
|
|
371
|
-
}
|
|
372
|
-
// The rest of the setup pages, behind More (the shared ⋯ menu).
|
|
373
|
-
const [moreButton, morePanel] = rowMenu('More', more, { trigger: [icon('more'), h('span', { class: 'fx-nav__label' }, 'More')], className: 'fx-nav__more', align: 'start' });
|
|
454
|
+
const [moreButton, panel] = rowMenu('More', moreItems, { trigger: [icon('more'), h('span', { class: 'fx-nav__label' }, 'More')], className: 'fx-nav__more', align: 'start', place: 'side' });
|
|
455
|
+
morePanel = panel;
|
|
374
456
|
Object.assign(moreButton.dataset, { nav: 'more', label: 'More' });
|
|
375
|
-
items.push(moreButton, morePanel);
|
|
376
457
|
// "Report a problem" sits at the foot of the menu, above the account chip,
|
|
377
458
|
// while Sentry and its report form are on (monitoring.js).
|
|
378
459
|
const report = h('button', { type: 'button', class: 'fx-nav__report', 'data-nav': 'report', 'data-label': 'Report a problem', hidden: true },
|
|
379
460
|
icon('flag'), h('span', { class: 'fx-nav__label' }, 'Report a problem'));
|
|
380
|
-
nav.replaceChildren(
|
|
461
|
+
nav.replaceChildren(moreButton, panel, report);
|
|
381
462
|
// Folded or narrow, the logo is the sidebar button: it shows the mark and
|
|
382
463
|
// turns into the sidebar icon under the pointer or keyboard focus.
|
|
383
464
|
const mark = h('span', { class: 'fx-toggle__mark', 'aria-hidden': 'true' });
|
|
@@ -386,7 +467,7 @@ function renderShell() {
|
|
|
386
467
|
sidebar.classList.add('fx-toggle__icon');
|
|
387
468
|
$('menu-toggle').replaceChildren(mark, sidebar);
|
|
388
469
|
$('menu-toggle').onclick = toggleMenu;
|
|
389
|
-
|
|
470
|
+
syncSidebar();
|
|
390
471
|
// The sidebar foot shows who is signed in: photo or initial, then the name
|
|
391
472
|
// (the email when the account has no name). It opens the account menu.
|
|
392
473
|
// People (owners) and Appearance live in the account menu.
|
|
@@ -890,10 +971,11 @@ function xeroImportSection(organisations) {
|
|
|
890
971
|
// Once Xero is on: each organisation's connection, the history copy, and
|
|
891
972
|
// Connect, which goes to Xero and comes back.
|
|
892
973
|
function xeroConnection(organisations, canChange) {
|
|
893
|
-
const host = h('div', { class: 'fx-
|
|
974
|
+
const host = h('div', { class: 'fx-xero-orgs' });
|
|
894
975
|
for (const organisation of organisations) {
|
|
976
|
+
const usage = h('div');
|
|
895
977
|
const row = settingRow({ label: organisation.name, hint: 'Checking…' });
|
|
896
|
-
host.append(row);
|
|
978
|
+
host.append(h('div', { class: 'fx-xero-org' }, usage, row));
|
|
897
979
|
(async () => {
|
|
898
980
|
let hint;
|
|
899
981
|
let control = null;
|
|
@@ -901,6 +983,7 @@ function xeroConnection(organisations, canChange) {
|
|
|
901
983
|
let refused = false;
|
|
902
984
|
try {
|
|
903
985
|
const status = await flow(`/api/xero/status?organisationId=${organisation.id}`);
|
|
986
|
+
usage.replaceChildren(xeroUsage(status) ?? '');
|
|
904
987
|
if (status.connected && status.calls?.stopped) {
|
|
905
988
|
// A refused connection stopped calls (the call guard): say so, and
|
|
906
989
|
// make Connect again the main button, which starts them again.
|
package/server/ui/bulk-bar.js
CHANGED
|
@@ -9,16 +9,19 @@
|
|
|
9
9
|
// bar for a screen reader ("Ticked invoices", "Ticked jobs"). state = { count, onClear,
|
|
10
10
|
// groups: [[action]], more: [{ head, actions: [action] }] }; an action is
|
|
11
11
|
// { icon, label, title?, run, primary?, iconOnly?, tone? }. title is the
|
|
12
|
-
// long name, for the hover tip and the More menu.
|
|
13
|
-
//
|
|
12
|
+
// long name, for the hover tip and the More menu. run(working) may call
|
|
13
|
+
// working('Sending') once the step really starts (after its dialog): the
|
|
14
|
+
// pressed button then shows a spinner and that word until run ends. A count
|
|
15
|
+
// of 0 hides the bar; Esc clears the ticks while it shows.
|
|
14
16
|
import { lucide } from '/assets/ui/custom-fields.js';
|
|
17
|
+
import { applyPhoneRule } from './mobile.js';
|
|
15
18
|
|
|
16
19
|
let barCount = 0;
|
|
17
20
|
|
|
18
21
|
export function bulkBar(ui, { label = 'Ticked invoices' } = {}) {
|
|
19
22
|
const { h } = ui;
|
|
20
23
|
const id = `fx-bulk-more-${++barCount}`;
|
|
21
|
-
const element = h('div', { class: 'fx-bulk', role: 'toolbar', 'aria-label': label, hidden: true });
|
|
24
|
+
const element = applyPhoneRule(h('div', { class: 'fx-bulk', role: 'toolbar', 'aria-label': label, hidden: true }), 'bulkBar');
|
|
22
25
|
const panel = h('div', { class: 'fx-pop fx-bulk__more', id, popover: 'auto', role: 'menu' });
|
|
23
26
|
let current = null;
|
|
24
27
|
let busy = false;
|
|
@@ -27,14 +30,24 @@ export function bulkBar(ui, { label = 'Ticked invoices' } = {}) {
|
|
|
27
30
|
if (busy) return;
|
|
28
31
|
busy = true;
|
|
29
32
|
element.setAttribute('aria-busy', 'true');
|
|
30
|
-
|
|
33
|
+
// The step is on its way: its button (on the bar, or More's) spins.
|
|
34
|
+
const working = (word) => {
|
|
35
|
+
const pressed = [...element.querySelectorAll('.fx-bulk__act')].find((b) => b.dataset.action === action.label);
|
|
36
|
+
if (!pressed) return;
|
|
37
|
+
pressed.dataset.busy = '';
|
|
38
|
+
pressed.firstChild?.replaceWith(h('span', { class: 'fx-spinner fx-spinner--sm', 'aria-hidden': 'true' }));
|
|
39
|
+
const text = pressed.querySelector('.fx-bulk__word');
|
|
40
|
+
if (text && word) text.textContent = `${word}…`;
|
|
41
|
+
};
|
|
42
|
+
try { await action.run(working); } finally {
|
|
31
43
|
busy = false;
|
|
32
44
|
element.removeAttribute('aria-busy');
|
|
45
|
+
if (current) draw(current);
|
|
33
46
|
}
|
|
34
47
|
};
|
|
35
48
|
|
|
36
49
|
const button = (action) => h('button', {
|
|
37
|
-
type: 'button',
|
|
50
|
+
type: 'button', 'data-action': action.label,
|
|
38
51
|
class: `fx-bulk__act${action.primary ? ' fx-bulk__act--primary' : ''}${action.iconOnly ? ' fx-bulk__act--icon' : ''}`,
|
|
39
52
|
...(action.tone ? { 'data-tone': action.tone } : {}),
|
|
40
53
|
...(action.iconOnly ? { 'aria-label': action.title ?? action.label, 'data-tip': action.title ?? action.label } : action.title ? { title: action.title } : {}),
|
package/server/ui/columns.js
CHANGED
|
@@ -25,17 +25,69 @@
|
|
|
25
25
|
// column keeps its place: no drag, no Move, no Hide.
|
|
26
26
|
// rows the <tbody> nodes (rowCells builds a row's cells)
|
|
27
27
|
// lead <th> nodes before the columns (the tick column)
|
|
28
|
+
//
|
|
29
|
+
// Cells every list shares: dateCell (a short date), personCell (a person's
|
|
30
|
+
// initials) and fieldColumn (one of the lab's custom fields as a column, on
|
|
31
|
+
// Jobs and on Invoices, where it is the invoice's job's value).
|
|
28
32
|
import { lucide } from '/assets/ui/custom-fields.js';
|
|
29
33
|
import { endWidth, LEAD_WIDTH, MAX_WIDTH, minWidth, moveColumn, TAIL_WIDTH, tableWidth, widthOf } from '/assets/ui/job-view.js';
|
|
34
|
+
import { applyPhoneRule, assignPhoneSlots, phoneSlot } from './mobile.js';
|
|
35
|
+
import { initialsOf } from '/assets/ui/person.js';
|
|
36
|
+
|
|
37
|
+
const fullDate = (iso) => new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
38
|
+
// A list date, short: Today, Tomorrow or Yesterday, else day/month (and
|
|
39
|
+
// year when it is not this year).
|
|
40
|
+
export function shortDate(iso) {
|
|
41
|
+
if (!iso) return '—';
|
|
42
|
+
const at = new Date(`${iso}T00:00:00`);
|
|
43
|
+
const now = new Date();
|
|
44
|
+
const days = Math.round((at - new Date(now.getFullYear(), now.getMonth(), now.getDate())) / 86_400_000);
|
|
45
|
+
if (days === 0) return 'Today';
|
|
46
|
+
if (days === 1) return 'Tomorrow';
|
|
47
|
+
if (days === -1) return 'Yesterday';
|
|
48
|
+
return at.toLocaleDateString('en-AU', at.getFullYear() === now.getFullYear() ? { day: '2-digit', month: '2-digit' } : { day: '2-digit', month: '2-digit', year: '2-digit' });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A date cell: the short date, the whole date on hover.
|
|
52
|
+
export const dateCell = (h, iso) => h('time', { datetime: iso ?? null, title: iso ? fullDate(iso) : null }, shortDate(iso));
|
|
53
|
+
|
|
54
|
+
// A person as a badge (initials), the name on hover; `people` is
|
|
55
|
+
// [{ id, name }] (GET /api/custom-fields).
|
|
56
|
+
export function personCell(h, personId, people) {
|
|
57
|
+
if (!personId) return h('span', { class: 'fx-dim' }, '—');
|
|
58
|
+
const name = people.find((p) => p.id === personId)?.name ?? 'Former person';
|
|
59
|
+
return h('span', { class: 'fx-avatar fx-avatar--sm', role: 'img', 'aria-label': name, title: name }, initialsOf(name));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A custom field's value as text.
|
|
63
|
+
export function fieldText(field, value, people) {
|
|
64
|
+
if (value == null || value === false) return '—';
|
|
65
|
+
if (field.type === 'checkbox') return 'Yes';
|
|
66
|
+
if (field.type === 'person') return people.find((p) => p.id === value)?.name ?? 'Former person';
|
|
67
|
+
if (field.type === 'dropdown') return field.options?.find((o) => o.id === value)?.name ?? '—';
|
|
68
|
+
if (field.type === 'date') return shortDate(value);
|
|
69
|
+
return String(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// One custom field as a list column: `valueOf(row)` is the row's value
|
|
73
|
+
// (null when it has none), `people` names a person field's value.
|
|
74
|
+
export function fieldColumn(h, field, { id, valueOf, people }) {
|
|
75
|
+
const cell = field.type === 'person' ? (row) => personCell(h, valueOf(row), people)
|
|
76
|
+
: field.type === 'date' ? (row) => dateCell(h, valueOf(row)) : (row) => fieldText(field, valueOf(row), people);
|
|
77
|
+
const kind = field.type === 'date' ? 'date' : field.type === 'person' ? 'person' : field.type === 'number' ? 'total' : 'text';
|
|
78
|
+
return { id, label: field.name, kind, num: field.type === 'number', className: 'fx-dim', cell };
|
|
79
|
+
}
|
|
30
80
|
|
|
31
81
|
|
|
32
82
|
// A row's cells for the shown columns: a class per kind, the label for
|
|
33
83
|
// the phone layout, the full text as the cell's name on hover.
|
|
34
84
|
export function rowCells(h, columns, row) {
|
|
35
|
-
return columns.map((c) => {
|
|
85
|
+
return assignPhoneSlots(columns).map((c) => {
|
|
86
|
+
const slot = phoneSlot(c);
|
|
36
87
|
const td = h('td', {
|
|
37
88
|
class: [`fx-c-${c.kind}`, c.num ? 'fx-num' : null, c.className].filter(Boolean).join(' '),
|
|
38
89
|
'data-label': c.label,
|
|
90
|
+
...(slot ? { 'data-phone-slot': slot } : {}),
|
|
39
91
|
}, c.cell(row));
|
|
40
92
|
// A cell stays on one line and ends in "…" when the column is narrow
|
|
41
93
|
// (flow.css, .fx-table--sized); the whole text shows on hover.
|
|
@@ -124,7 +176,7 @@ export function columnTable(h, config) {
|
|
|
124
176
|
h('colgroup', {}, leadCols, cols, endCol),
|
|
125
177
|
h('thead', {}, h('tr', {}, lead, heads, tail)),
|
|
126
178
|
rows);
|
|
127
|
-
const box = h('div', { class: 'fx-table-scroll' }, node);
|
|
179
|
+
const box = applyPhoneRule(h('div', { class: 'fx-table-scroll' }, node), 'columnTable');
|
|
128
180
|
// The width the columns need; past the window the table scrolls
|
|
129
181
|
// sideways. A browser shares spare width among fixed columns, so the
|
|
130
182
|
// end column is sized here, and again when the window changes size.
|
|
@@ -15,6 +15,21 @@
|
|
|
15
15
|
|
|
16
16
|
import { orderStatuses, statusRank } from '/assets/ui/job-board.js';
|
|
17
17
|
import { sidecar } from '/assets/ui/sidecar.js';
|
|
18
|
+
import { assignPhoneSlots, phoneSlot } from './mobile.js';
|
|
19
|
+
|
|
20
|
+
// The manager row uses the same phone slots as a column list.
|
|
21
|
+
const FIELD_SLOTS = assignPhoneSlots([
|
|
22
|
+
{ id: 'name', kind: 'text' },
|
|
23
|
+
{ id: 'type', kind: 'text' },
|
|
24
|
+
{ id: 'by', kind: 'person' },
|
|
25
|
+
{ id: 'date', kind: 'date' },
|
|
26
|
+
{ id: 'where', kind: 'text' },
|
|
27
|
+
{ id: 'menu', kind: 'text', phone: 'end' },
|
|
28
|
+
]);
|
|
29
|
+
const fieldSlot = (id) => {
|
|
30
|
+
const slot = phoneSlot(FIELD_SLOTS.find((column) => column.id === id));
|
|
31
|
+
return slot ? { 'data-phone-slot': slot } : {};
|
|
32
|
+
};
|
|
18
33
|
|
|
19
34
|
const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/CUSTOM_FIELDS.md';
|
|
20
35
|
const TYPE_LABELS = {
|
|
@@ -395,12 +410,12 @@ export async function renderCustomFields(main, ui) {
|
|
|
395
410
|
});
|
|
396
411
|
input.addEventListener('blur', () => { if (view.renaming === field.id) rename(field, input.value); });
|
|
397
412
|
queueMicrotask(() => { input.focus(); input.select(); });
|
|
398
|
-
return h('div', { class: 'fx-cfm__name', role: 'cell' }, input);
|
|
413
|
+
return h('div', { class: 'fx-cfm__name', role: 'cell', ...fieldSlot('name') }, input);
|
|
399
414
|
}
|
|
400
415
|
const editButton = field.archived || disabled || field.source ? null
|
|
401
416
|
: h('button', { class: 'fx-cfm__edit', type: 'button', onclick: () => edit(field) }, 'Edit', lucide(h, 'panel-right'));
|
|
402
417
|
const viewButton = field.source ? h('button', { class: 'fx-cfm__edit', type: 'button', onclick: () => edit(field) }, 'View', lucide(h, 'panel-right')) : null;
|
|
403
|
-
return h('div', { class: 'fx-cfm__name', role: 'cell' },
|
|
418
|
+
return h('div', { class: 'fx-cfm__name', role: 'cell', ...fieldSlot('name') },
|
|
404
419
|
h('div', { class: 'fx-cfm__title' },
|
|
405
420
|
field.source ? lucide(h, 'lock', 'Read only') : null,
|
|
406
421
|
h('span', { class: 'fx-cfm__label', title: field.name }, field.name), editButton, viewButton));
|
|
@@ -418,11 +433,11 @@ export async function renderCustomFields(main, ui) {
|
|
|
418
433
|
});
|
|
419
434
|
return h('div', { class: 'fx-cfm__row', role: 'row', 'data-id': field.id, 'data-open': sideFor === field.id ? 'true' : null, 'data-archived': field.archived ? 'true' : null },
|
|
420
435
|
nameCell(field),
|
|
421
|
-
h('div', { class: 'fx-cfm__type', role: 'cell' }, picker ? [picker.button, picker.panel] : h('span', { class: 'fx-cfm__icon', 'data-color': color }, icon)),
|
|
422
|
-
h('div', { class: 'fx-cfm__by', role: 'cell' }, by ? h('span', { class: 'fx-cfm__avatar', title: by }, field.source ? lucide(h, 'plug') : initials(by)) : h('span', { class: 'fx-dim' }, '—')),
|
|
423
|
-
h('div', { class: 'fx-cfm__date', role: 'cell', title: field.source ? `${field.sourceLabel} connected` : null }, field.createdAt ? createdDay(field.createdAt) : h('span', { class: 'fx-dim' }, '—')),
|
|
424
|
-
h('div', { class: 'fx-cfm__where', role: 'cell' }, field.source ? `${field.sourceLabel} jobs` : 'Jobs'),
|
|
425
|
-
h('div', { class: 'fx-cfm__end', role: 'cell' }, field.source ? null : rowMenu(field, picker)));
|
|
436
|
+
h('div', { class: 'fx-cfm__type', role: 'cell', ...fieldSlot('type') }, picker ? [picker.button, picker.panel] : h('span', { class: 'fx-cfm__icon', 'data-color': color }, icon)),
|
|
437
|
+
h('div', { class: 'fx-cfm__by', role: 'cell', ...fieldSlot('by') }, by ? h('span', { class: 'fx-cfm__avatar', title: by }, field.source ? lucide(h, 'plug') : initials(by)) : h('span', { class: 'fx-dim' }, '—')),
|
|
438
|
+
h('div', { class: 'fx-cfm__date', role: 'cell', ...fieldSlot('date'), title: field.source ? `${field.sourceLabel} connected` : null }, field.createdAt ? createdDay(field.createdAt) : h('span', { class: 'fx-dim' }, '—')),
|
|
439
|
+
h('div', { class: 'fx-cfm__where', role: 'cell', ...fieldSlot('where') }, field.source ? `${field.sourceLabel} jobs` : 'Jobs'),
|
|
440
|
+
h('div', { class: 'fx-cfm__end', role: 'cell', ...fieldSlot('menu') }, field.source ? null : rowMenu(field, picker)));
|
|
426
441
|
}
|
|
427
442
|
|
|
428
443
|
// One group: a header that folds it, its rows, and "Create … field". A
|