@arcforgelabs/flow 2026.10.36 → 2026.10.37

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.
@@ -0,0 +1,99 @@
1
+ // The bulk action bar: while a person has ticked rows in a list, it floats
2
+ // at the foot of the page, like ClickUp's. It shows "N selected ×" (the ×
3
+ // clears the ticks), then the actions in groups split by a thin line, then
4
+ // More. An action has a Lucide icon and one word; an icon-only action gives
5
+ // its name on hover and to a screen reader. More opens every action, with a
6
+ // search box, grouped under small heads.
7
+ //
8
+ // bulkBar(ui) answers { element, draw(state) }. state = { count, onClear,
9
+ // groups: [[action]], more: [{ head, actions: [action] }] }; an action is
10
+ // { icon, label, title?, run, primary?, iconOnly?, tone? }. title is the
11
+ // long name, for the hover tip and the More menu. A count of 0 hides the
12
+ // bar; Esc clears the ticks while it shows.
13
+ import { lucide } from '/assets/ui/custom-fields.js';
14
+
15
+ let barCount = 0;
16
+
17
+ export function bulkBar(ui) {
18
+ const { h } = ui;
19
+ const id = `fx-bulk-more-${++barCount}`;
20
+ const element = h('div', { class: 'fx-bulk', role: 'toolbar', 'aria-label': 'Ticked invoices', hidden: true });
21
+ const panel = h('div', { class: 'fx-pop fx-bulk__more', id, popover: 'auto', role: 'menu' });
22
+ let current = null;
23
+ let busy = false;
24
+
25
+ const run = async (action) => {
26
+ if (busy) return;
27
+ busy = true;
28
+ element.setAttribute('aria-busy', 'true');
29
+ try { await action.run(); } finally {
30
+ busy = false;
31
+ element.removeAttribute('aria-busy');
32
+ }
33
+ };
34
+
35
+ const button = (action) => h('button', {
36
+ type: 'button',
37
+ class: `fx-bulk__act${action.primary ? ' fx-bulk__act--primary' : ''}${action.iconOnly ? ' fx-bulk__act--icon' : ''}`,
38
+ ...(action.tone ? { 'data-tone': action.tone } : {}),
39
+ ...(action.iconOnly ? { 'aria-label': action.title ?? action.label, 'data-tip': action.title ?? action.label } : action.title ? { title: action.title } : {}),
40
+ onclick: () => run(action),
41
+ }, lucide(h, action.icon), action.iconOnly ? null : h('span', { class: 'fx-bulk__word' }, action.label));
42
+
43
+ // More: a search box over every action, under small heads.
44
+ const search = h('input', { class: 'fx-input fx-bulk__search', type: 'search', placeholder: 'Search…', 'aria-label': 'Search actions' });
45
+ const list = h('div', { class: 'fx-bulk__list' });
46
+ const drawMore = () => {
47
+ const q = search.value.trim().toLowerCase();
48
+ const parts = [];
49
+ for (const { head, actions } of current?.more ?? []) {
50
+ const found = actions.filter((a) => !q || `${a.label} ${a.title ?? ''}`.toLowerCase().includes(q));
51
+ if (!found.length) continue;
52
+ parts.push(h('p', { class: 'fx-pop__head' }, head), h('div', { class: 'fx-bulk__grid' }, found.map((action) => h('button', {
53
+ type: 'button', class: 'fx-pop__item', role: 'menuitem', ...(action.tone ? { 'data-tone': action.tone } : {}),
54
+ onclick: () => { panel.hidePopover?.(); run(action); },
55
+ }, h('span', { class: 'fx-pop__tick' }, lucide(h, action.icon)), h('span', {}, action.title ?? action.label)))));
56
+ }
57
+ list.replaceChildren(...(parts.length ? parts : [h('p', { class: 'fx-bulk__none' }, 'No action matches.')]));
58
+ };
59
+ search.addEventListener('input', drawMore);
60
+ const onKey = (event) => {
61
+ if (!element.isConnected) { document.removeEventListener('keydown', onKey); return; }
62
+ if (event.key === 'Escape' && !element.hidden && !panel.matches?.(':popover-open') && !document.querySelector('dialog[open]')) current?.onClear();
63
+ };
64
+ document.addEventListener?.('keydown', onKey);
65
+ panel.append(search, list);
66
+ const more = h('button', {
67
+ type: 'button', class: 'fx-bulk__act', popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false',
68
+ }, lucide(h, 'ellipsis'), h('span', { class: 'fx-bulk__word' }, 'More'));
69
+ // The menu opens above the bar, at its right edge.
70
+ panel.addEventListener('toggle', (event) => {
71
+ more.setAttribute('aria-expanded', String(event.newState === 'open'));
72
+ if (event.newState !== 'open') return;
73
+ search.value = '';
74
+ drawMore();
75
+ const at = more.getBoundingClientRect();
76
+ panel.style.top = `${Math.round(Math.max(8, at.top - panel.offsetHeight - 8))}px`;
77
+ panel.style.left = `${Math.round(Math.max(8, Math.min(at.right - panel.offsetWidth, innerWidth - panel.offsetWidth - 8)))}px`;
78
+ search.focus();
79
+ });
80
+
81
+ function draw(state) {
82
+ current = state;
83
+ element.hidden = !state?.count;
84
+ if (element.hidden) { panel.hidePopover?.(); return; }
85
+ const count = h('span', { class: 'fx-bulk__count' },
86
+ h('span', {}, `${state.count} selected`),
87
+ h('button', { type: 'button', class: 'fx-bulk__clear', 'aria-label': 'Clear the ticks', 'data-tip': 'Clear', onclick: state.onClear }, lucide(h, 'x')));
88
+ const groups = state.groups.filter((g) => g.length);
89
+ const parts = [];
90
+ groups.forEach((group, i) => {
91
+ if (i) parts.push(h('span', { class: 'fx-bulk__sep', 'aria-hidden': 'true' }));
92
+ parts.push(...group.map(button));
93
+ });
94
+ if ((state.more ?? []).some((g) => g.actions.length)) parts.push(h('span', { class: 'fx-bulk__sep', 'aria-hidden': 'true' }), more, panel);
95
+ element.replaceChildren(count, h('span', { class: 'fx-bulk__acts' }, ...parts));
96
+ }
97
+
98
+ return { element, draw };
99
+ }
@@ -155,7 +155,8 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
155
155
  /* Folded (wide screens only): the sidebar keeps its icons, the menu button
156
156
  and the account photo; each page's name is still read out. */
157
157
  @media (min-width: 821px) {
158
- .fx-shell[data-folded] .fx-side { width: 64px; padding-inline: 10px; }
158
+ .fx-shell[data-folded] .fx-side { width: 64px; padding-inline: 10px; cursor: e-resize; }
159
+ .fx-shell[data-folded] .fx-side :is(a, button, #user-button) { cursor: pointer; }
159
160
  .fx-shell[data-folded] .fx-side__head { flex-direction: column; gap: 10px; padding: 0 0 14px; }
160
161
  .fx-shell[data-folded] .fx-side__head .fx-brand { display: none; }
161
162
  .fx-shell[data-folded] .fx-nav__group { height: 1px; margin: 10px 6px; padding: 0; overflow: hidden; background: var(--fx-line); color: transparent; }
@@ -234,6 +235,12 @@ a.fx-link:hover, a.fx-link:focus-visible { border-color: var(--t-strong); }
234
235
  .fx-link__dot[data-tone="success"] { background: var(--t-success); }
235
236
  .fx-link__dot[data-tone="warn"] { background: var(--t-warn); }
236
237
  .fx-link__dot[data-tone="danger"] { background: var(--t-danger); }
238
+ .fx-linked { display: grid; }
239
+ .fx-linked__row { display: flex; align-items: center; gap: 8px; white-space: nowrap; min-height: 44px; padding: 6px 0; border-top: 1px solid var(--fx-line-soft); }
240
+ .fx-linked__row:first-child { border-top: 0; }
241
+ .fx-linked__ref { font-family: var(--t-data); font-variant-numeric: tabular-nums; color: var(--t-strong); }
242
+ .fx-linked__open { margin-left: auto; font-size: 12.5px; color: var(--t-dim); white-space: nowrap; }
243
+ .fx-linked__open:hover { color: var(--t-strong); }
237
244
  .fx-tag {
238
245
  margin-left: 8px; padding: 1px 7px; border: 1px solid var(--fx-line); border-radius: 999px;
239
246
  color: var(--t-dim); font-size: 11.5px; font-weight: 600;
@@ -269,9 +276,6 @@ a.fx-link:hover, a.fx-link:focus-visible { border-color: var(--t-strong); }
269
276
  font-size: 12.5px; font-weight: 700; letter-spacing: .02em;
270
277
  }
271
278
  .fx-empty { padding: 28px 16px; color: var(--t-dim); text-align: center; }
272
- .fx-roles { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; font-size: 13.5px; color: var(--t-dim); }
273
- .fx-roles dt { color: var(--t-strong); font-weight: 600; }
274
- .fx-roles dd { margin: 0; }
275
279
 
276
280
  /* ── Integrations ───────────────────────────────────────────────────────── */
277
281
  .fx-chip { border: 1px solid var(--fx-line); border-radius: 999px; background: var(--t-paper); color: var(--t-text); }
@@ -509,12 +513,13 @@ a.fx-strong:hover { text-decoration: underline; }
509
513
  .fx-table__group-row { display: flex; align-items: center; gap: 12px; }
510
514
  .fx-table__group-row .fx-board__sum { margin-left: 0; }
511
515
  .fx-stage[data-rank="plain"] { --stage: var(--t-dim); }
512
- /* The board never scrolls sideways: its columns (one per work system status)
513
- are about a phone wide, wrap onto more rows when the page is narrow, and
514
- stack on a phone. On a wide, tall screen the board fills the page down to
515
- its bottom edge, and each column scrolls inside itself (jobs.js sets
516
- fx-main--board while the board shows). */
517
- .fx-board { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 256px), 1fr)); gap: 12px; align-items: start; }
516
+ /* The board is one row of lanes (one per group), each about a phone wide.
517
+ Lanes share the width when they fit; more lanes scroll sideways inside
518
+ the board, never as a second row. On a wide, tall screen the board fills
519
+ the page down to its bottom edge, the page itself does not scroll, and
520
+ each lane scrolls inside itself (jobs.js sets fx-main--board while the
521
+ board shows). */
522
+ .fx-board { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(min(85vw, 272px), 1fr); gap: 12px; align-items: start; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin; padding-bottom: 4px; }
518
523
  .fx-board__col { display: flex; flex-direction: column; gap: 8px; min-width: 0; min-height: 0; padding: 10px 6px 6px 8px; border-radius: var(--t-radius-lg); background: var(--t-s1); }
519
524
  .fx-board__head { display: flex; flex: none; align-items: center; gap: 8px; min-width: 0; padding: 2px 4px 4px 2px; }
520
525
  .fx-board__sum { flex: none; margin-left: auto; color: var(--t-dim); font-size: 12px; font-variant-numeric: tabular-nums; }
@@ -522,7 +527,7 @@ a.fx-strong:hover { text-decoration: underline; }
522
527
  @media (min-width: 821px) and (min-height: 560px) {
523
528
  .fx-main--board { height: 100vh; height: 100dvh; padding: 24px 24px 16px; grid-template-rows: auto auto minmax(0, 1fr); align-content: stretch; }
524
529
  .fx-main--board > .fx-jobs { min-height: 0; }
525
- .fx-main--board .fx-board { height: 100%; overflow-y: auto; grid-auto-rows: 100%; align-items: stretch; }
530
+ .fx-main--board .fx-board { height: 100%; overflow-y: hidden; align-items: stretch; }
526
531
  .fx-main--board .fx-board__cards { flex: 1; max-height: none; }
527
532
  }
528
533
  .fx-board__none { padding: 8px 4px; color: var(--t-dim); font-size: 13px; }
@@ -823,6 +828,16 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
823
828
  .fx-table__actions { text-align: left; }
824
829
  .fx-table__actions .fx-row--end { justify-content: flex-start; }
825
830
  .fx-ph__actions, .fx-ph__actions .fx-btn { width: 100%; }
831
+ /* People: the name with its actions (⋯, Approve) on one line, then the
832
+ role select full width, then the status pill when there is one. */
833
+ .fx-people tr { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px 10px; }
834
+ .fx-people td, .fx-people td:nth-child(2), .fx-people td:nth-child(3) { display: block; width: auto; margin: 0; padding: 0; }
835
+ .fx-people td:nth-child(1) { grid-area: 1 / 1; }
836
+ .fx-people td:nth-child(4) { grid-area: 1 / 2; }
837
+ .fx-people td:nth-child(2) { grid-area: 2 / 1 / 3 / -1; }
838
+ .fx-people td:nth-child(2) .fx-select { width: 100%; }
839
+ .fx-people td:nth-child(3) { grid-area: 3 / 1 / 4 / -1; }
840
+ .fx-people td:nth-child(3):empty { display: none; }
826
841
  }
827
842
 
828
843
  @keyframes fx-rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
@@ -921,6 +936,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
921
936
  .fx-cfm > .fx-notice { margin: 12px 16px 0; }
922
937
  .fx-cfm__foot { display: flex; justify-content: space-between; gap: 12px; padding: 12px 16px; border-top: 1px solid var(--fx-line-soft); color: var(--t-dim); font-size: 13px; }
923
938
  .fx-pop__item:disabled { opacity: .5; cursor: default; }
939
+ .fx-pop__item[data-tone="danger"], .fx-pop__item[data-tone="danger"] .fx-pop__tick { color: var(--t-danger); }
924
940
  .fx-pop__item .fx-lucide { width: 15px; height: 15px; }
925
941
  @media (max-width: 720px) {
926
942
  .fx-cfm { grid-template-columns: minmax(0, 1fr) 40px 40px; }
@@ -1019,3 +1035,60 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1019
1035
  .fx-pick__row[hidden] { display: none; }
1020
1036
  .fx-pick__main { display: grid; gap: 2px; min-width: 0; }
1021
1037
  .fx-pick > .fx-dim { padding: 14px; }
1038
+
1039
+ /* The Invoices list head (invoicing.js): tabs, search and the send bar stay
1040
+ at the top while rows scroll up under them; a shadow shows once they do.
1041
+ Narrow, the app bar (60px) sits above it. */
1042
+ .fx-listhead { position: sticky; top: 0; z-index: 5; margin: -12px calc(-1 * clamp(16px, 4vw, 48px)) 0; padding: 12px clamp(16px, 4vw, 48px) 0; background: var(--t-bg); transition: box-shadow .15s; }
1043
+ .fx-listhead[data-stuck] { box-shadow: 0 10px 12px -12px rgba(0, 0, 0, .45); }
1044
+ .fx-listhead .fx-sendbar { margin-bottom: 12px; }
1045
+ .fx-listhead__edge { height: 0; margin-bottom: -20px; }
1046
+ @media (max-width: 820px) { .fx-listhead { top: 60px; } }
1047
+ /* On a phone the head would hide too many rows: it scrolls away. */
1048
+ @media (max-width: 560px) { .fx-listhead { position: static; } }
1049
+
1050
+ /* The bulk action bar (bulk-bar.js): floats at the foot while rows are
1051
+ ticked. "N selected ×", then icon actions in groups, then More. It takes
1052
+ the theme's own surface, line, corners and primary button, like a menu. */
1053
+ .fx-bulk {
1054
+ position: sticky; bottom: 16px; z-index: 6; justify-self: center; display: flex; align-items: center; gap: 2px; max-width: 100%;
1055
+ padding: 5px; border: 1px solid var(--fx-line); border-radius: var(--t-radius-lg); background: var(--t-paper); color: var(--t-text);
1056
+ box-shadow: var(--t-shadow), 0 12px 32px rgba(0, 0, 0, .16);
1057
+ font: 500 13.5px/1 var(--t-body); white-space: nowrap;
1058
+ }
1059
+ .fx-bulk[hidden] { display: none; }
1060
+ .fx-bulk[aria-busy="true"] { opacity: .7; pointer-events: none; }
1061
+ .fx-bulk__acts { display: flex; align-items: center; gap: 2px; min-width: 0; }
1062
+ .fx-bulk__count { display: inline-flex; align-items: center; gap: 6px; flex: none; margin-right: 6px; padding: 3px 4px 3px 10px; border: 1px solid var(--t-accent-brd, var(--fx-line)); border-radius: var(--t-radius); background: var(--t-accent-bg); color: var(--t-accent); font-weight: 600; }
1063
+ .fx-bulk__clear, .fx-bulk__act { position: relative; display: inline-flex; align-items: center; gap: 6px; padding: 7px 9px; border: 1px solid transparent; border-radius: var(--t-radius); background: none; color: inherit; font: inherit; cursor: pointer; }
1064
+ .fx-bulk__clear { padding: 3px; color: inherit; }
1065
+ .fx-bulk__act:hover, .fx-bulk__act:focus-visible, .fx-bulk__act[aria-expanded="true"] { background: var(--fx-hover); color: var(--t-strong); outline: none; }
1066
+ .fx-bulk__clear:hover, .fx-bulk__clear:focus-visible { background: color-mix(in srgb, var(--t-accent) 14%, transparent); outline: none; }
1067
+ .fx-bulk__act--primary, .fx-bulk__act--primary:hover, .fx-bulk__act--primary:focus-visible { background: var(--t-primary); border-color: var(--t-primary); color: var(--t-on-primary); font-weight: 600; }
1068
+ .fx-bulk__act--primary:hover { background: color-mix(in srgb, var(--t-primary) 86%, var(--t-text)); }
1069
+ .fx-bulk__act--icon { padding: 7px; }
1070
+ .fx-bulk__act[data-tone="danger"] { color: var(--t-danger); }
1071
+ .fx-bulk__act[data-tone="danger"]:hover { background: var(--t-danger-bg); }
1072
+ .fx-bulk__sep { flex: none; width: 1px; height: 18px; margin: 0 4px; background: var(--fx-line); }
1073
+ /* An icon-only action says its name on hover, in a tip above it. */
1074
+ .fx-bulk [data-tip]:hover::after, .fx-bulk [data-tip]:focus-visible::after {
1075
+ content: attr(data-tip); position: absolute; bottom: calc(100% + 10px); left: 50%; transform: translateX(-50%);
1076
+ padding: 4px 8px; border: 1px solid var(--fx-line); border-radius: var(--t-radius); background: var(--t-paper); color: var(--t-strong);
1077
+ font: 500 12px/1.2 var(--t-body); pointer-events: none; box-shadow: var(--t-shadow), 0 4px 12px rgba(0, 0, 0, .12);
1078
+ }
1079
+ /* More: a compact list, one row per action, like a dropdown. */
1080
+ .fx-pop.fx-bulk__more { width: 248px; min-width: 0; padding: 4px; }
1081
+ .fx-bulk__search { width: 100%; height: 30px; margin-bottom: 2px; padding: 4px 8px; font-size: 13px; }
1082
+ .fx-bulk__more .fx-pop__head { padding: 6px 8px 2px; font-size: 10.5px; }
1083
+ .fx-bulk__more .fx-pop__item { gap: 8px; padding: 4px 8px; font-size: 13px; line-height: 1.25; }
1084
+ .fx-bulk__more .fx-pop__item .fx-lucide { width: 14px; height: 14px; }
1085
+ .fx-bulk__grid { display: grid; }
1086
+ .fx-bulk__grid .fx-pop__item[data-tone="danger"] { color: var(--t-danger); }
1087
+ .fx-bulk__none { margin: 8px 10px; color: var(--t-dim); font-size: 13.5px; }
1088
+ @media (max-width: 560px) {
1089
+ .fx-bulk { bottom: 10px; padding: 4px; }
1090
+ .fx-bulk__count { margin-right: 2px; padding-left: 8px; }
1091
+ .fx-bulk__act { padding: 7px 6px; }
1092
+ .fx-bulk__sep { margin: 0 2px; }
1093
+ .fx-bulk__act:not(.fx-bulk__act--primary) .fx-bulk__word { display: none; }
1094
+ }
@@ -6,6 +6,7 @@
6
6
  // zero, server/core/money.js). Data comes from /api/invoicing
7
7
  // (server/api/invoicing.js). app.js passes in its shared pieces as `ui`.
8
8
  import { alertStrip } from '/assets/ui/alerts.js';
9
+ import { bulkBar } from '/assets/ui/bulk-bar.js';
9
10
  import { draftNote, sendModeNote } from '/assets/ui/invoice-notes.js';
10
11
  import { jobChangeBanner } from '/assets/ui/job-change.js';
11
12
 
@@ -22,10 +23,10 @@ const VIEW_TONE = { problem: 'danger', ready: 'success', sending: 'info', to_che
22
23
  const EMPTY = {
23
24
  to_check: 'No drafts to check. Press New invoice to start one.',
24
25
  problem: 'No problems.',
25
- ready: 'Nothing is ready. Tick drafts and press Move to Ready.',
26
+ ready: 'Nothing is ready. In Drafts, tick drafts and press Ready.',
26
27
  };
27
- // Tabs whose rows can be ticked, and what the ticked rows can do.
28
- const TICKS = { to_check: true, ready: true };
28
+ // Every tab's rows can be ticked; the bulk bar (bulkActions) says what the
29
+ // ticked rows can do there.
29
30
 
30
31
  const money = new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' });
31
32
  export const formatCents = (cents) => money.format((cents ?? 0) / 100);
@@ -120,18 +121,28 @@ export async function renderInvoiceList(main, ui) {
120
121
  const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a number, client or reference', 'aria-label': 'Find an invoice', value: state.q });
121
122
  const bar = h('div', { class: 'fx-sendbar', hidden: true });
122
123
  const host = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
124
+ const bulk = bulkBar(ui);
123
125
  const context = await flow('/api/invoicing/context').catch(() => ({ canIssue: false }));
124
126
  let body = null;
125
127
  const openTab = (id) => { state.status = id; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); };
126
128
  const alerts = alertStrip(ui, 'invoices', { onTab: openTab });
129
+ // The tabs, search and send bar stay at the top while the rows scroll up
130
+ // under them; the bulk bar floats at the foot (ClickUp's layout).
131
+ const head = h('div', { class: 'fx-listhead' }, h('div', { class: 'fx-toolbar' }, tabs, search), bar);
132
+ const edge = h('div', { class: 'fx-listhead__edge', 'aria-hidden': 'true' });
127
133
  main.append(
128
134
  pageHeader('Invoices', {
129
135
  actions: [h('a', { class: 'fx-btn fx-btn--primary', href: '/invoices/new', onclick: go }, 'New invoice')],
130
136
  }),
131
137
  alerts.element,
132
- h('div', { class: 'fx-toolbar' }, tabs, search),
133
- bar,
134
- host);
138
+ edge,
139
+ head,
140
+ host,
141
+ bulk.element);
142
+ // A shadow under the head shows once rows scroll under it.
143
+ if (typeof IntersectionObserver === 'function') {
144
+ new IntersectionObserver(([entry]) => head.toggleAttribute('data-stuck', !entry.isIntersecting), { rootMargin: `-${head.offsetTop > 0 && window.matchMedia?.('(max-width: 820px)').matches ? 61 : 1}px 0px 0px 0px` }).observe(edge);
145
+ }
135
146
 
136
147
  let timer;
137
148
  search.addEventListener('input', () => {
@@ -167,19 +178,10 @@ export async function renderInvoiceList(main, ui) {
167
178
  const ticked = [...state.ticked];
168
179
  const parts = [];
169
180
  if (state.status === 'to_check') {
170
- parts.push(h('p', { class: 'fx-dim' }, ticked.length ? `${plural(ticked.length, 'draft', 'drafts')} ticked.` : 'Check each draft, tick it, then move it to Ready.'));
171
- if (ticked.length) {
172
- parts.push(action(`Move ${ticked.length} to Ready`, 'mark-ready', () => ({ invoiceIds: ticked }), (res) => (res.refused.length
173
- ? [`${res.marked} ready. ${plural(res.refused.length, 'draft has', 'drafts have')} a problem and moved to Problem.`, 'danger']
174
- : [`${plural(res.marked, 'draft', 'drafts')} ready.`, 'success'])));
175
- }
181
+ parts.push(h('p', { class: 'fx-dim' }, 'Check each draft, tick it, then press Ready.'));
176
182
  } else if (state.status === 'ready') {
177
183
  parts.push(h('p', { class: 'fx-dim' }, sendModeNote(to, body.sendMode)));
178
184
  const buttons = [];
179
- if (ticked.length) {
180
- buttons.push(action(`Move ${ticked.length} to Drafts`, 'mark-not-ready', () => ({ invoiceIds: ticked }),
181
- (res) => [`${plural(res.moved, 'invoice', 'invoices')} back in Drafts.`, 'success'], 'ghost'));
182
- }
183
185
  if (context.canIssue && body.counts.ready > 0) {
184
186
  buttons.push(action(to ? `Send all ${body.counts.ready} ready to ${to}` : `Finalise all ${body.counts.ready} ready`, 'send-ready', () => ({ issueDate: localDate() }),
185
187
  (res) => [`${res.issued} finalised.${to ? ` They are on the way to ${to}.` : ''}${res.remaining ? ` ${res.remaining} more are ready: press again.` : ''}`, 'success']));
@@ -194,6 +196,162 @@ export async function renderInvoiceList(main, ui) {
194
196
  }
195
197
  bar.replaceChildren(...parts);
196
198
  bar.hidden = !parts.length;
199
+ bulk.draw({ count: ticked.length, onClear: () => setTicks([]), ...bulkActions(ticked) });
200
+ }
201
+
202
+ // Ticks changed from the bar: the rows draw again with the new ticks.
203
+ function setTicks(ids) {
204
+ state.ticked = new Set(ids);
205
+ if (body) draw(body);
206
+ }
207
+
208
+ // A bulk step on the server: on success the ticks clear and the list loads
209
+ // again; on a failure the ticks stay, so a person can try again.
210
+ async function post(path, payload, done) {
211
+ try {
212
+ const res = await flow(`/api/invoicing/invoices/${path}`, { method: 'POST', body: JSON.stringify(payload) });
213
+ toast(...done(res));
214
+ state.ticked.clear();
215
+ seen.clear();
216
+ await load();
217
+ alerts.refresh();
218
+ } catch (error) {
219
+ toast(error.message, 'danger');
220
+ }
221
+ }
222
+
223
+ // What the ticked rows can do on this tab. The bar shows the main ones;
224
+ // More shows them all, with Selection steps under them.
225
+ function bulkActions(ticked) {
226
+ const to = body.sendsTo;
227
+ const rows = body.invoices.filter((i) => state.ticked.has(i.id));
228
+ const numbered = ['issued', 'void'].includes(state.status);
229
+ const status = [];
230
+ const apply = [];
231
+ if (state.status === 'ready' && context.canIssue) {
232
+ status.push({
233
+ icon: 'circle-check-big', label: 'Finalise', primary: true,
234
+ title: to ? `Finalise and send to ${to}` : 'Finalise',
235
+ run: async () => {
236
+ const ok = await ui.dialog({
237
+ title: `Finalise ${plural(ticked.length, 'invoice', 'invoices')}?`,
238
+ body: h('p', {}, `Each one gets the next number and is locked.${to ? ` ${sendModeNote(to, body.sendMode, 'each one')}` : ''}`),
239
+ confirm: to ? `Finalise and send ${ticked.length}` : `Finalise ${ticked.length}`,
240
+ });
241
+ if (!ok) return;
242
+ await post('send-ready', { issueDate: localDate(), invoiceIds: ticked },
243
+ (res) => [`${plural(res.issued, 'invoice', 'invoices')} finalised.${to ? ` They are on the way to ${to}.` : ''}${res.remaining ? ` ${res.remaining} more stay in Ready: tick them and press again.` : ''}`, 'success']);
244
+ },
245
+ });
246
+ }
247
+ if (state.status === 'ready') {
248
+ status.push({
249
+ icon: 'undo-2', label: 'Drafts', title: 'Move to Drafts',
250
+ run: () => post('mark-not-ready', { invoiceIds: ticked }, (res) => [`${plural(res.moved, 'invoice', 'invoices')} back in Drafts.`, 'success']),
251
+ });
252
+ }
253
+ if (state.status === 'to_check') {
254
+ status.push({
255
+ icon: 'check-check', label: 'Ready', primary: true, title: 'Move to Ready',
256
+ run: () => post('mark-ready', { invoiceIds: ticked }, (res) => (res.refused.length
257
+ ? [`${res.marked} ready. ${plural(res.refused.length, 'draft has', 'drafts have')} a problem and moved to Problem.`, 'danger']
258
+ : [`${plural(res.marked, 'draft', 'drafts')} ready.`, 'success'])),
259
+ });
260
+ }
261
+ const failed = rows.filter((i) => i.status === 'issued').map((i) => i.id);
262
+ if (state.status === 'problem' && context.canIssue && to && failed.length) {
263
+ status.push({
264
+ icon: 'refresh-cw', label: 'Retry', primary: true, title: `Send again to ${to}`,
265
+ run: () => post('send-again', { invoiceIds: failed }, (res) => [`${res.queued} on the way to ${to} again.`, 'success']),
266
+ });
267
+ }
268
+ apply.push({
269
+ icon: 'copy', label: 'Copy', iconOnly: true, title: numbered ? 'Copy numbers' : 'Copy references',
270
+ run: () => copyLines(rows.map((i) => (numbered ? i.number : i.reference) ?? i.contactName ?? '')),
271
+ });
272
+ apply.push({ icon: 'download', label: 'Export', iconOnly: true, title: 'Export CSV', run: () => exportCsv(rows) });
273
+ if (numbered) apply.push({ icon: 'file-down', label: 'PDFs', iconOnly: true, title: 'Download PDFs', run: () => downloadPdfs(rows) });
274
+ const danger = [];
275
+ const voidable = rows.filter((i) => i.status === 'issued');
276
+ if (state.status === 'issued' && context.canIssue && voidable.length) {
277
+ danger.push({
278
+ icon: 'ban', label: 'Void', iconOnly: true, tone: 'danger', title: 'Void',
279
+ run: () => voidAll(voidable),
280
+ });
281
+ }
282
+ const all = body.invoices.map((i) => i.id);
283
+ const selection = [
284
+ { icon: 'list-checks', label: 'Tick all shown', run: () => setTicks(all) },
285
+ { icon: 'square-check', label: 'Invert ticks', run: () => setTicks(all.filter((id) => !state.ticked.has(id))) },
286
+ { icon: 'square-x', label: 'Clear ticks', run: () => setTicks([]) },
287
+ ];
288
+ return {
289
+ groups: [status, apply, danger],
290
+ more: [
291
+ { head: 'Change status', actions: [...status, ...danger] },
292
+ { head: 'Apply an action', actions: apply },
293
+ { head: 'Selection', actions: selection },
294
+ ],
295
+ };
296
+ }
297
+
298
+ async function copyLines(lines) {
299
+ try {
300
+ await navigator.clipboard.writeText(lines.join('\n'));
301
+ toast(`${plural(lines.length, 'line', 'lines')} copied.`, 'success');
302
+ } catch {
303
+ toast('The browser did not allow a copy.', 'danger');
304
+ }
305
+ }
306
+
307
+ // The ticked rows as a CSV file, with the columns on the screen.
308
+ function exportCsv(rows) {
309
+ const cell = (v) => (/[",\n]/.test(String(v ?? '')) ? `"${String(v).replace(/"/g, '""')}"` : String(v ?? ''));
310
+ const lines = [['Number', 'Client', 'Reference', 'Date', 'Total', 'Status']]
311
+ .concat(rows.map((i) => [i.number ?? '', i.contactName ?? '', i.reference ?? '', i.issueDate ?? i.createdAt?.slice(0, 10) ?? '',
312
+ ((i.totalCents ?? 0) / 100).toFixed(2), i.status === 'void' ? 'Void' : VIEW_LABEL[i.view] ?? STATUS_LABEL[i.status]]));
313
+ save(new Blob([lines.map((l) => l.map(cell).join(',')).join('\r\n')], { type: 'text/csv' }), `invoices-${state.status}-${localDate()}.csv`);
314
+ toast(`${plural(rows.length, 'invoice', 'invoices')} exported.`, 'success');
315
+ }
316
+
317
+ async function downloadPdfs(rows) {
318
+ const token = await window.Clerk?.session?.getToken();
319
+ let saved = 0;
320
+ for (const invoice of rows) {
321
+ try {
322
+ const res = await fetch(`/api/invoicing/invoices/${invoice.id}/pdf`, { headers: { Authorization: `Bearer ${token}` } });
323
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
324
+ save(await res.blob(), `${invoice.number ?? invoice.id}.pdf`);
325
+ saved += 1;
326
+ } catch {
327
+ // The toast below counts the ones that did not save.
328
+ }
329
+ }
330
+ toast(saved === rows.length ? `${plural(saved, 'PDF', 'PDFs')} saved.` : `${saved} of ${rows.length} PDFs saved.`, saved === rows.length ? 'success' : 'danger');
331
+ }
332
+
333
+ // Void, one invoice after the other, as on one invoice's page.
334
+ async function voidAll(rows) {
335
+ const ok = await ui.dialog({
336
+ title: `Void ${plural(rows.length, 'invoice', 'invoices')}?`,
337
+ body: h('div', {}, h('p', {}, 'Their numbers stay used. A void invoice cannot be finalised again.'),
338
+ body.sendsTo ? h('p', { class: 'fx-problem' }, `Flow does not change ${body.sendsTo}: void them in ${body.sendsTo} too.`) : null),
339
+ confirm: `Void ${rows.length}`, tone: 'danger',
340
+ });
341
+ if (!ok) return;
342
+ let done = 0;
343
+ let error = null;
344
+ for (const invoice of rows) {
345
+ try {
346
+ await flow(`/api/invoicing/invoices/${invoice.id}/void`, { method: 'POST', body: '{}' });
347
+ done += 1;
348
+ } catch (e) { error = e; }
349
+ }
350
+ toast(error ? `${done} of ${rows.length} void. ${error.message}` : `${plural(done, 'invoice', 'invoices')} void.`, error ? 'danger' : 'success');
351
+ state.ticked.clear();
352
+ seen.clear();
353
+ await load();
354
+ alerts.refresh();
197
355
  }
198
356
 
199
357
  // A tab switch shows at once: the tab is selected, the last list seen for
@@ -257,7 +415,6 @@ export async function renderInvoiceList(main, ui) {
257
415
  saveNav({ tab: state.status, q: state.q, ids: body.invoices.map((i) => i.id) });
258
416
  const open = (href) => { saveNav({ ...readNav(), scrollY: window.scrollY }); go(href); };
259
417
  const link = (href, label) => h('a', { class: 'fx-strong', href, onclick: (event) => { event.preventDefault(); event.stopPropagation(); open(href); } }, label);
260
- const ticks = TICKS[state.status];
261
418
  const showProblem = state.status === 'problem';
262
419
  const created = ['to_check', 'problem', 'ready'].includes(state.status);
263
420
  // An invoice gets its number when it is sent or issued, so only the
@@ -280,6 +437,7 @@ export async function renderInvoiceList(main, ui) {
280
437
  };
281
438
  const all = h('input', {
282
439
  type: 'checkbox', class: 'fx-tick', 'aria-label': 'Tick every invoice shown',
440
+ ...(state.ticked.size && body.invoices.every((i) => state.ticked.has(i.id)) ? { checked: true } : {}),
283
441
  onchange: () => {
284
442
  for (const invoice of body.invoices) { if (all.checked) state.ticked.add(invoice.id); else state.ticked.delete(invoice.id); }
285
443
  for (const box of boxes) box.checked = all.checked;
@@ -289,7 +447,7 @@ export async function renderInvoiceList(main, ui) {
289
447
  const statusCells = new Map();
290
448
  host.replaceChildren(h('table', { class: 'fx-table fx-table--rows' },
291
449
  h('thead', {}, h('tr', {},
292
- ticks ? h('th', { class: 'fx-tick-cell' }, all) : null,
450
+ h('th', { class: 'fx-tick-cell' }, all),
293
451
  numbered ? h('th', {}, 'Number') : null, h('th', {}, 'Client'), h('th', {}, showProblem ? 'Problem' : 'Reference'),
294
452
  h('th', {}, created ? 'Created' : 'Finalised'), h('th', { class: 'fx-num' }, 'Total'), h('th', {}, 'Status'),
295
453
  linked ? h('th', {}, 'Linked') : null)),
@@ -298,7 +456,7 @@ export async function renderInvoiceList(main, ui) {
298
456
  const first = invoice.problems?.[0];
299
457
  const view = invoice.status === 'void' ? null : invoice.view;
300
458
  return h('tr', { tabindex: '0', onclick: () => open(href), onkeydown: (event) => { if (event.key === 'Enter') open(href); } },
301
- ticks ? h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, tickBox(invoice)) : null,
459
+ h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, tickBox(invoice)),
302
460
  numbered ? h('td', {}, link(href, invoice.number ?? 'Draft')) : null,
303
461
  h('td', {}, numbered ? (invoice.contactName ?? '—') : link(href, invoice.contactName ?? 'No client')),
304
462
  showProblem
@@ -336,6 +494,18 @@ export async function renderInvoiceList(main, ui) {
336
494
  if (returning && back?.scrollY) window.scrollTo(0, back.scrollY);
337
495
  }
338
496
 
497
+ // A file the browser saves, from a blob made on this page.
498
+ function save(blob, name) {
499
+ const url = URL.createObjectURL(blob);
500
+ const a = document.createElement('a');
501
+ a.href = url;
502
+ a.download = name;
503
+ document.body.append(a);
504
+ a.click();
505
+ a.remove();
506
+ setTimeout(() => URL.revokeObjectURL(url), 60_000);
507
+ }
508
+
339
509
  // The list an invoice was opened from: its tab, search, invoice ids in order,
340
510
  // and scroll. The invoice page's bar steps through it and goes back to it.
341
511
  const NAV = 'flow_invoice_nav';
@@ -435,16 +605,46 @@ function totalsBox(ui, { subtotalCents, taxCents, totalCents }) {
435
605
  h('dt', { class: 'fx-totals__grand' }, 'Total'), h('dd', { class: 'fx-totals__grand' }, formatCents(totalCents)));
436
606
  }
437
607
 
438
- // The job an invoice bills. The case id stays on the record, not on screen.
439
- function jobCard(ui, job) {
608
+ // The Linked card: this invoice's link in each connected system (linksTo).
609
+ // A work system row names the job's order and opens it there; the
610
+ // accounting row gives the number and state there (from Flow's copy). A
611
+ // system with no link shows a dashed row. With no system on, the card holds
612
+ // the job's facts only.
613
+ function linkedCard(ui, invoice, accountingStatus = null) {
440
614
  const { h } = ui;
441
- if (!job) return null;
615
+ const job = invoice?.job ?? null;
616
+ const systems = invoice?.linksTo ?? [];
617
+ if (!job && !systems.length) return null;
618
+ const facts = job && (job.practitioner || job.shippedOn) ? h('dl', { class: 'fx-facts' },
619
+ job.practitioner ? [h('dt', {}, 'Practitioner'), h('dd', {}, job.practitioner)] : null,
620
+ job.shippedOn ? [h('dt', {}, 'Shipped'), h('dd', {}, showDate(job.shippedOn))] : null) : null;
621
+ const row = (system, { tone = null, ref = null, state = null, url = null, none = null }) => h('div', { class: 'fx-linked__row' },
622
+ linkIcon(ui, system, { tone, title: none ?? `${system.name}${ref ? ` ${ref}` : ''}` }),
623
+ h('span', { class: 'fx-strong' }, system.name),
624
+ ref ? h('span', { class: 'fx-linked__ref' }, ref) : null,
625
+ state ? h('span', { class: 'fx-status', 'data-tone': tone ?? 'mute' }, state) : null,
626
+ none ? h('span', { class: 'fx-dim' }, none) : null,
627
+ url ? h('a', { class: 'fx-linked__open', href: url, target: '_blank', rel: 'noopener', title: `Open in ${system.name}` }, 'Open ↗') : null);
628
+ const rows = systems.map((system) => {
629
+ if (system.kind === 'work') {
630
+ return job?.sourceSystem === system.id
631
+ ? row(system, { tone: 'success', ref: job.caseRef ?? job.sourceJobRef, url: job.link?.url ?? null })
632
+ : row(system, { none: 'No job linked' });
633
+ }
634
+ const status = accountingStatus;
635
+ if (!status || (status.state === 'not_sent' && !status.finding)) {
636
+ return row(system, { none: invoice?.status === 'draft' ? 'Goes there when sent' : `Not in ${system.name} yet` });
637
+ }
638
+ const tone = status.finding ? 'danger' : LINK_TONE[status.state] ?? 'mute';
639
+ const state = status.finding ? 'Out of sync' : status.label.replace(` in ${system.name}`, '');
640
+ return row(system, { tone, ref: status.xero?.number ?? null, state, url: status.xero?.url ?? null });
641
+ });
642
+ const workRows = systems.some((s) => s.kind === 'work');
442
643
  return h('section', { class: 'fx-card fx-side-card' },
443
- h('h3', { class: 'fx-label' }, 'Job'),
444
- h('dl', { class: 'fx-facts' },
445
- job.practitioner ? [h('dt', {}, 'Practitioner'), h('dd', {}, job.practitioner)] : null,
446
- job.shippedOn ? [h('dt', {}, 'Shipped'), h('dd', {}, showDate(job.shippedOn))] : null,
447
- h('dt', {}, 'From'), h('dd', {}, job.sourceSystem === 'seazona' ? 'Seazona' : job.sourceSystem)));
644
+ h('h3', { class: 'fx-label' }, 'Linked'),
645
+ !workRows && job ? h('p', { class: 'fx-dim' }, `Job from ${job.systemName ?? job.sourceSystem}`) : null,
646
+ h('div', { class: 'fx-linked' }, rows),
647
+ facts);
448
648
  }
449
649
 
450
650
  // A draft, or a new invoice before its first save.
@@ -796,7 +996,7 @@ function draftView(invoice, ui, context, redraw) {
796
996
  h('div', { class: 'fx-lines__foot' },
797
997
  h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', onclick: () => { lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null }); drawLines(); } }, '+ Add line'),
798
998
  totalsHost))),
799
- h('aside', { class: 'fx-invoice__aside' }, statusCard, jobCard(ui, invoice?.job))),
999
+ h('aside', { class: 'fx-invoice__aside' }, statusCard, linkedCard(ui, invoice))),
800
1000
  ].map((part) => {
801
1001
  // Any edit on the page shows Save changes; undoing it hides it again.
802
1002
  for (const kind of ['input', 'change', 'click']) part?.addEventListener?.(kind, () => setTimeout(watchChanges));
@@ -855,12 +1055,14 @@ function issuedView(invoice, ui, context, redraw) {
855
1055
  };
856
1056
  const organisationId = context.organisations?.[0]?.id;
857
1057
  const xeroHost = h('div');
1058
+ const linkedHost = h('div', {}, linkedCard(ui, invoice) ?? '');
858
1059
  let xeroStatus = null;
859
1060
  const send = invoice.status === 'issued' && context.canIssue ? sendToXero(invoice, ui, context) : null;
860
1061
  const loadXero = async () => {
861
1062
  if (!context.xero) return;
862
1063
  xeroStatus = (await xeroStatuses(ui, organisationId, [invoice.id])).get(invoice.id) ?? null;
863
1064
  xeroHost.replaceChildren(xeroCard(invoice, ui, context, xeroStatus, loadXero) ?? '');
1065
+ linkedHost.replaceChildren(linkedCard(ui, invoice, xeroStatus) ?? '');
864
1066
  if (xeroStatus && xeroStatus.state !== 'not_sent') showSent(send);
865
1067
  };
866
1068
  loadXero();
@@ -905,8 +1107,8 @@ function issuedView(invoice, ui, context, redraw) {
905
1107
  h('td', { class: 'fx-num fx-strong' }, formatCents(line.lineAmountCents)))))),
906
1108
  h('div', { class: 'fx-lines__foot' }, h('span'), totalsBox(ui, invoice)))),
907
1109
  h('aside', { class: 'fx-invoice__aside' },
1110
+ linkedHost,
908
1111
  xeroHost,
909
- jobCard(ui, invoice.job),
910
1112
  invoice.versions?.length ? h('section', { class: 'fx-card fx-side-card' },
911
1113
  h('h3', { class: 'fx-label' }, 'History'),
912
1114
  h('ol', { class: 'fx-timeline' }, invoice.versions.map((v) => h('li', {},
@@ -920,7 +1122,9 @@ function issuedView(invoice, ui, context, redraw) {
920
1122
  // never writes to Xero: a person fixes the difference where they choose.
921
1123
  function xeroCard(invoice, ui, context, status, reload) {
922
1124
  const { h, flow, toast } = ui;
923
- if (!status || (status.state === 'not_sent' && !status.finding)) return null;
1125
+ // The Linked card shows Xero's number and state; this card shows only a
1126
+ // difference to resolve.
1127
+ if (!status?.finding) return null;
924
1128
  const rows = status.differences.map((d) => h('tr', {},
925
1129
  h('td', {}, FIELD_LABEL[d.field] ?? d.field),
926
1130
  h('td', { class: 'fx-num' }, showField(d.field, d.flow)),