@arcforgelabs/flow 2026.10.36 → 2026.10.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +1 -0
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +1 -1
  4. package/server/api/custom-fields.js +2 -1
  5. package/server/api/invoicing.js +61 -3
  6. package/server/auth/clerk.js +35 -0
  7. package/server/auth/routes.js +45 -6
  8. package/server/core/custom-fields.js +18 -0
  9. package/server/core/host.js +24 -0
  10. package/server/core/job-board.js +2 -1
  11. package/server/core/job-match.js +241 -0
  12. package/server/core/ledger.js +80 -1
  13. package/server/index.js +9 -3
  14. package/server/integrations/seazona/feed.js +4 -1
  15. package/server/integrations/seazona/index.js +1 -1
  16. package/server/integrations/seazona/settings.js +5 -2
  17. package/server/integrations/sentry/index.js +5 -2
  18. package/server/integrations/xero/fit.js +108 -0
  19. package/server/integrations/xero/import.js +6 -1
  20. package/server/integrations/xero/index.js +4 -4
  21. package/server/integrations/xero/ledger.js +3 -0
  22. package/server/integrations/xero/links.js +293 -0
  23. package/server/integrations/xero/migrations/0008_xero_invoice_links.sql +26 -0
  24. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  25. package/server/integrations/xero/reconcile.js +17 -11
  26. package/server/integrations/xero/routes.js +42 -0
  27. package/server/integrations/xero/settings.js +12 -5
  28. package/server/ui/app-page.js +1 -1
  29. package/server/ui/app.js +142 -23
  30. package/server/ui/bulk-bar.js +100 -0
  31. package/server/ui/flow.css +225 -11
  32. package/server/ui/invoicing.js +234 -30
  33. package/server/ui/job-board.js +60 -34
  34. package/server/ui/job-view.js +30 -7
  35. package/server/ui/jobs.js +469 -38
  36. package/server/ui/reconcile.js +275 -0
  37. package/server/ui/routes.js +1 -1
  38. package/server/ui/xero-import.js +329 -0
package/server/ui/app.js CHANGED
@@ -13,8 +13,10 @@ import { renderInvoice, renderInvoiceList } from '/assets/ui/invoicing.js';
13
13
  import { automationSection } from '/assets/ui/automations.js';
14
14
  import { renderInvoicingSettings, sendModeSection } from '/assets/ui/invoicing-settings.js';
15
15
  import { renderPriceList } from '/assets/ui/price-list.js';
16
- import { renderCustomFields } from '/assets/ui/custom-fields.js';
16
+ import { lucide, renderCustomFields } from '/assets/ui/custom-fields.js';
17
17
  import { renderJobs } from '/assets/ui/jobs.js';
18
+ import { renderReconcile } from '/assets/ui/reconcile.js';
19
+ import { renderXeroImport } from '/assets/ui/xero-import.js';
18
20
  import { startMonitoring } from '/assets/ui/monitoring.js';
19
21
  import { openSearch } from '/assets/ui/search.js';
20
22
  import { alertStrip } from '/assets/ui/alerts.js';
@@ -59,6 +61,7 @@ function mountClerk(kind, element, options = {}) {
59
61
  // The "Manage account" modal does not inherit the button's appearance.
60
62
  if (kind === 'userButton') props.userProfileProps = { ...options.userProfileProps, appearance: clerkAppearance('modal') };
61
63
  if (kind === 'signIn') Clerk.mountSignIn(element, props);
64
+ else if (kind === 'signUp') Clerk.mountSignUp(element, props);
62
65
  else if (kind === 'userButton') Clerk.mountUserButton(element, props);
63
66
  else if (kind === 'profile') Clerk.mountUserProfile(element, props);
64
67
  mounted.set(element, { kind, options });
@@ -69,6 +72,7 @@ function unmountClerk(element) {
69
72
  if (!entry) return;
70
73
  const Clerk = window.Clerk;
71
74
  if (entry.kind === 'signIn') Clerk.unmountSignIn(element);
75
+ else if (entry.kind === 'signUp') Clerk.unmountSignUp(element);
72
76
  else if (entry.kind === 'userButton') Clerk.unmountUserButton(element);
73
77
  else if (entry.kind === 'profile') Clerk.unmountUserProfile(element);
74
78
  mounted.delete(element);
@@ -181,12 +185,13 @@ function statusPill(person) {
181
185
  if (person.status === 'pending') return h('span', { class: 'fx-status', 'data-tone': 'warn' }, 'Waiting for approval');
182
186
  if (person.status === 'disabled') return h('span', { class: 'fx-status', 'data-tone': 'mute' }, 'Turned off');
183
187
  if (person.invited) return h('span', { class: 'fx-status', 'data-tone': 'info' }, 'Invited');
184
- return h('span', { class: 'fx-status', 'data-tone': 'success' }, 'Active');
188
+ // Active is the usual state, so it shows nothing.
189
+ return null;
185
190
  }
186
191
 
187
192
  function roleSelect(value, attrs = {}) {
188
193
  return h('select', { class: 'fx-select', ...attrs },
189
- ['owner', 'admin', 'staff'].map((role) => h('option', { value: role, selected: role === value }, ROLE_LABEL[role])));
194
+ ['owner', 'admin', 'staff'].map((role) => h('option', { value: role, selected: role === value, title: ROLE_HINT[role] }, ROLE_LABEL[role])));
190
195
  }
191
196
 
192
197
  // ── Views ──────────────────────────────────────────────────────────────────
@@ -197,6 +202,7 @@ let me = null;
197
202
  const VIEWS = {
198
203
  jobs: { label: 'Jobs', icon: 'jobs', group: 'Work', render: (main) => renderJobs(main, ui()) },
199
204
  invoices: { label: 'Invoices', icon: 'invoices', group: 'Work', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
205
+ reconcile: { label: 'Reconcile', icon: 'reconcile', group: 'Work', render: (main, id) => renderReconcile(main, ui(), id) },
200
206
  'invoicing-settings': { label: 'Invoice rules', icon: 'invoices', group: 'Settings', render: (main) => renderInvoicingSettings(main, ui()) },
201
207
  'price-list': { label: 'Price list', icon: 'tag', group: 'Settings', render: (main) => renderPriceList(main, ui()) },
202
208
  'custom-fields': { label: 'Custom fields', icon: 'fields', group: 'Settings', render: (main) => renderCustomFields(main, ui()) },
@@ -204,6 +210,8 @@ const VIEWS = {
204
210
  integrations: { label: 'Integrations', icon: 'plug', group: 'Settings', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
205
211
  // Not in the sidebar: "Manage account" in the account menu opens the same
206
212
  // Clerk profile. The page stays for links and Search.
213
+ // Opened from the Xero integration page: Xero invoices against Flow's own.
214
+ 'xero-import': { label: 'Xero import', icon: 'plug', group: 'Settings', sidebar: false, render: (main) => renderXeroImport(main, ui()) },
207
215
  account: { label: 'Account', icon: 'person', group: 'Settings', sidebar: false, render: renderAccount },
208
216
  appearance: { label: 'Appearance', icon: 'palette', group: 'Settings', render: renderAppearance },
209
217
  };
@@ -212,6 +220,7 @@ const VIEWS = {
212
220
  const ICON_PATHS = {
213
221
  jobs: '<rect x="3" y="4" width="5" height="16" rx="1"/><rect x="10" y="4" width="5" height="11" rx="1"/><rect x="17" y="4" width="4" height="14" rx="1"/>',
214
222
  invoices: '<path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5M9 13h7M9 17h5"/>',
223
+ reconcile: '<path d="M4 8h13M13 4l4 4-4 4M20 16H7M11 12l-4 4 4 4"/>',
215
224
  people: '<circle cx="9" cy="8" r="3"/><path d="M3 20c0-3.5 2.7-6 6-6s6 2.5 6 6"/><path d="M16 5a3 3 0 010 6M21 20c0-3-1.6-5-4-5.7"/>',
216
225
  tag: '<path d="M3 12V4h8l10 10-8 8z"/><circle cx="7.5" cy="8.5" r="1.5"/>',
217
226
  fields: '<rect x="3" y="4" width="18" height="5" rx="1.5"/><rect x="3" y="13" width="18" height="5" rx="1.5"/><path d="M7 6.5h4M7 15.5h7"/>',
@@ -284,6 +293,13 @@ document.addEventListener('keydown', (event) => {
284
293
  document.addEventListener('click', (event) => {
285
294
  if (!event.target.closest?.('.fx-side') || event.target.closest?.('[data-nav]')) closeMenu();
286
295
  });
296
+ // Folded, a click on the sidebar's empty space opens it out again, as in
297
+ // Claude. A click on a page, the account photo or Search still does its job.
298
+ document.addEventListener('click', (event) => {
299
+ if (NARROW.matches || !readFolded() || !event.target.closest?.('.fx-side')) return;
300
+ if (event.target.closest('a, button, input, select, textarea, [role="button"], [role="menuitem"], #user-button')) return;
301
+ toggleMenu();
302
+ });
287
303
 
288
304
  // Search, from the sidebar foot or Ctrl+K (⌘K): the pages this person may
289
305
  // open, invoices and jobs (search.js).
@@ -363,6 +379,13 @@ function renderSignedOut() {
363
379
  document.body.dataset.state = 'signed-out';
364
380
  document.title = 'Sign in · Flow';
365
381
  const panel = $('sign-in');
382
+ // The link in an invitation email (POST /api/people) carries a Clerk
383
+ // ticket: the invitee makes their account here, on that address only.
384
+ if (new URLSearchParams(location.search).has('__clerk_ticket')) {
385
+ $('sign-in-note').textContent = 'Finish your invitation to Flow.';
386
+ mountClerk('signUp', panel, { forceRedirectUrl: '/sign-in', signInUrl: '/sign-in' });
387
+ return;
388
+ }
366
389
  $('sign-in-note').textContent = config.publicSignUp
367
390
  ? 'Sign in, or ask for access with your work email.'
368
391
  : 'Sign in with the account you were invited with.';
@@ -442,13 +465,12 @@ function renderNoAccess(error) {
442
465
  // removes. Managing people needs a verified owner sign-in (/api/me says).
443
466
  async function renderPeople(main) {
444
467
  const canManage = Boolean(me.ownerActions?.allowed);
445
- const invite = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled: !canManage, onclick: () => invitePerson() }, 'Invite someone');
468
+ const invite = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled: !canManage, onclick: () => invitePerson() }, 'Invite');
446
469
  const tableHost = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
447
470
  main.append(...[
448
- pageHeader('People', { subtitle: 'Who can sign in to Flow, and what each person can do.', actions: [invite] }),
471
+ pageHeader('People', { actions: [invite] }),
449
472
  canManage ? null : notice('Managing people needs a verified owner sign-in: Google Workspace on the owner domain with no password, or a second factor.', 'warn', 'Read only.'),
450
473
  tableHost,
451
- h('dl', { class: 'fx-roles' }, ['owner', 'admin', 'staff'].flatMap((role) => [h('dt', {}, ROLE_LABEL[role]), h('dd', {}, ROLE_HINT[role])])),
452
474
  ].filter(Boolean));
453
475
  if (!canManage) {
454
476
  tableHost.replaceChildren(h('p', { class: 'fx-empty' }, 'The people list opens after a verified owner sign-in.'));
@@ -469,12 +491,34 @@ async function refreshPeople(host) {
469
491
  const rows = people.map((person) => personRow(person, host));
470
492
  host.replaceChildren(...[
471
493
  waiting ? notice(`${waiting} ${waiting === 1 ? 'person is' : 'people are'} waiting for approval.`, 'info') : null,
472
- h('table', { class: 'fx-table' },
473
- h('thead', {}, h('tr', {}, h('th', {}, 'Person'), h('th', {}, 'Role'), h('th', {}, 'Access'), h('th', { class: 'fx-table__actions' }, h('span', { class: 'fx-sr' }, 'Actions')))),
494
+ h('table', { class: 'fx-table fx-people' },
495
+ h('thead', { class: 'fx-sr' }, h('tr', {}, h('th', {}, 'Person'), h('th', {}, 'Role'), h('th', {}, 'Access'), h('th', {}, 'Actions'))),
474
496
  h('tbody', {}, rows)),
475
497
  ].filter(Boolean));
476
498
  }
477
499
 
500
+ let peopleMenus = 0;
501
+
502
+ // A row's ⋯ menu, as on the Custom fields page.
503
+ function rowMenu(label, items) {
504
+ const id = `fx-people-menu-${++peopleMenus}`;
505
+ const button = h('button', { class: 'fx-cfm__more', type: 'button', 'aria-label': label, popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false' }, lucide(h, 'ellipsis'));
506
+ const panel = h('div', { class: 'fx-pop', id, popover: 'auto', role: 'menu' });
507
+ panel.addEventListener('toggle', (event) => {
508
+ button.setAttribute('aria-expanded', String(event.newState === 'open'));
509
+ if (event.newState !== 'open') return;
510
+ const close = () => panel.hidePopover();
511
+ panel.replaceChildren(...items.map((entry) => (entry === '-'
512
+ ? h('div', { class: 'fx-pop__sep' })
513
+ : h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', 'data-tone': entry.tone, onclick: () => { close(); entry.run(); } },
514
+ h('span', { class: 'fx-pop__tick' }, lucide(h, entry.icon)), h('span', {}, entry.text)))));
515
+ const at = button.getBoundingClientRect();
516
+ panel.style.top = `${Math.round(at.bottom + 6)}px`;
517
+ panel.style.left = `${Math.round(Math.max(8, Math.min(at.right - panel.offsetWidth, innerWidth - panel.offsetWidth - 8)))}px`;
518
+ });
519
+ return [button, panel];
520
+ }
521
+
478
522
  function personRow(person, host) {
479
523
  const self = person.id === me.person.id;
480
524
  const act = async (fn, done) => {
@@ -493,24 +537,28 @@ function personRow(person, host) {
493
537
  h('span', { class: 'fx-person__name' }, person.name || person.email, self ? h('span', { class: 'fx-tag' }, 'You') : null),
494
538
  person.name ? h('span', { class: 'fx-person__email' }, person.email) : null));
495
539
  const role = self
496
- ? h('span', {}, ROLE_LABEL[person.role])
540
+ ? h('span', { title: ROLE_HINT[person.role] }, ROLE_LABEL[person.role])
497
541
  : roleSelect(person.role, {
498
542
  'aria-label': `Role for ${person.email}`,
543
+ title: ROLE_HINT[person.role],
499
544
  onchange: (event) => act(() => patch({ role: event.target.value }), `${person.email} is now ${ROLE_LABEL[event.target.value].toLowerCase()}.`),
500
545
  });
501
546
  const actions = [];
502
547
  if (!self) {
503
548
  if (person.status === 'pending') {
504
549
  actions.push(h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button', onclick: () => act(() => patch({ status: 'active' }), `${person.email} can sign in.`) }, 'Approve'));
505
- } else if (person.status === 'active') {
506
- actions.push(h('button', { class: 'fx-btn fx-btn--sm', type: 'button', onclick: () => act(() => patch({ status: 'disabled' }), `${person.email} is turned off.`) }, 'Turn off'));
507
- } else {
508
- actions.push(h('button', { class: 'fx-btn fx-btn--sm', type: 'button', onclick: () => act(() => patch({ status: 'active' }), `${person.email} is turned on.`) }, 'Turn on'));
509
550
  }
510
- actions.push(h('button', {
511
- class: 'fx-btn fx-btn--ghost fx-btn--danger fx-btn--sm',
512
- type: 'button',
513
- onclick: async () => {
551
+ const items = [];
552
+ if (person.invited && person.status === 'active') {
553
+ items.push({ icon: 'mail', text: 'Email invite again', run: () => act(() => flow(`/api/people/${person.id}/invite`, { method: 'POST' }), `Invite emailed to ${person.email}.`) });
554
+ }
555
+ if (person.status === 'active') items.push({ icon: 'circle-off', text: 'Turn off', run: () => act(() => patch({ status: 'disabled' }), `${person.email} is turned off.`) });
556
+ if (person.status === 'disabled') items.push({ icon: 'circle-check', text: 'Turn on', run: () => act(() => patch({ status: 'active' }), `${person.email} is turned on.`) });
557
+ items.push('-', {
558
+ icon: 'trash-2',
559
+ text: 'Remove',
560
+ tone: 'danger',
561
+ run: async () => {
514
562
  const ok = await dialog({
515
563
  title: `Remove ${person.email}?`,
516
564
  body: h('p', {}, 'They can no longer sign in to Flow. Records they made stay.'),
@@ -519,7 +567,8 @@ function personRow(person, host) {
519
567
  });
520
568
  if (ok) await act(() => flow(`/api/people/${person.id}`, { method: 'DELETE' }), `${person.email} is removed.`);
521
569
  },
522
- }, 'Remove'));
570
+ });
571
+ actions.push(...rowMenu(`More for ${person.email}`, items));
523
572
  }
524
573
  return h('tr', {},
525
574
  h('td', {}, who),
@@ -782,6 +831,7 @@ async function renderIntegration(main, id) {
782
831
  current.id === 'xero' && current.state === 'ready' ? h('section', { class: 'fx-int-section' }, xeroConnection(body.organisations, canChange)) : null,
783
832
  current.id === 'seazona' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'seazona', 'Ready to invoice')) : null,
784
833
  on && current.state === 'ready' ? personSettingsSection(current) : null,
834
+ current.id === 'xero' && current.state === 'ready' ? xeroImportSection(body.organisations) : null,
785
835
  current.id === 'xero' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, sendModeSection(ui())) : null,
786
836
  guide ? h('section', { class: 'fx-int-section' },
787
837
  h('h2', { class: 'fx-int-section__title' }, 'Set up'),
@@ -861,6 +911,53 @@ function connectProgress({ steps, started }) {
861
911
  step.label))));
862
912
  }
863
913
 
914
+ // Xero › Import, in settings rows: how many Xero invoices to check (Review
915
+ // opens them one at a time), the two import settings, and the history. The
916
+ // counts come from Flow's copy of Xero; the pages are server/ui/xero-import.js.
917
+ function xeroImportSection(organisations) {
918
+ const organisationId = organisations?.[0]?.id;
919
+ const canAct = ['owner', 'admin'].includes(me?.person?.role);
920
+ const count = h('span', { class: 'fx-num fx-xi-count' }, '…');
921
+ const copied = h('span', {}, 'Every Xero invoice Flow copied. Search it with no Xero call.');
922
+ const switchFor = (name, label) => {
923
+ const button = h('button', { class: 'fx-switch', type: 'button', role: 'switch', 'aria-label': label, 'aria-checked': 'false', disabled: true });
924
+ button.addEventListener('click', async () => {
925
+ const on = button.getAttribute('aria-checked') !== 'true';
926
+ button.disabled = true;
927
+ try {
928
+ const { settings } = await flow('/api/xero/import/settings', { method: 'PUT', body: JSON.stringify({ organisationId, [name]: on }) });
929
+ button.setAttribute('aria-checked', String(settings[name]));
930
+ toast('Saved.', 'success');
931
+ } catch (error) {
932
+ toast(error.message, 'danger');
933
+ }
934
+ button.disabled = !canAct;
935
+ });
936
+ return button;
937
+ };
938
+ const autoLink = switchFor('autoLink', 'Link sure matches');
939
+ const makeUnmatched = switchFor('makeUnmatched', 'Make Flow invoices for the rest');
940
+ const section = h('section', { class: 'fx-set fx-int-section fx-card', style: { padding: '8px 20px 16px' } },
941
+ h('h2', { class: 'fx-set__title' }, 'Import'),
942
+ settingRow({ label: 'To check', hint: 'Xero invoices that fit a Flow invoice or a Flow job.',
943
+ control: h('span', { class: 'fx-row' }, count, h('a', { class: 'fx-btn fx-btn--sm fx-btn--primary', href: '/xero-import', onclick: navigate }, 'Review')) }),
944
+ settingRow({ label: 'Link sure matches', hint: 'Same client, total and week, and only one fit. Flow links them.', control: autoLink }),
945
+ settingRow({ label: 'Make Flow invoices for the rest', hint: 'Off: they stay in the Xero history.', control: makeUnmatched }),
946
+ settingRow({ label: 'Xero history', hint: copied, control: h('a', { class: 'fx-btn fx-btn--sm', href: '/xero-import?show=all', onclick: navigate }, 'Search') }));
947
+ if (organisationId) {
948
+ flow(`/api/xero/import?organisationId=${organisationId}&show=check&limit=1`)
949
+ .then(({ counts: c, settings }) => {
950
+ count.replaceChildren(String(c.job_fits + c.invoice_fits));
951
+ copied.replaceChildren(`${Object.values(c).reduce((a, b) => a + b, 0)} Xero invoices copied, ${c.linked} in Flow. Search with no Xero call.`);
952
+ autoLink.setAttribute('aria-checked', String(settings.autoLink));
953
+ makeUnmatched.setAttribute('aria-checked', String(settings.makeUnmatched));
954
+ autoLink.disabled = makeUnmatched.disabled = !canAct;
955
+ })
956
+ .catch(() => count.replaceChildren('—'));
957
+ }
958
+ return section;
959
+ }
960
+
864
961
  // Once Xero is on: each organisation's connection, the history copy, and
865
962
  // Connect, which goes to Xero and comes back.
866
963
  function xeroConnection(organisations, canChange) {
@@ -907,7 +1004,27 @@ function xeroConnection(organisations, canChange) {
907
1004
  return h('div', {}, h('h2', { class: 'fx-int-section__title' }, 'Xero organisation'), host);
908
1005
  }
909
1006
 
1007
+ // When Clerk cannot email an invitation, the owner sends the invitee this
1008
+ // message instead. It names the sign-in address and the email to use.
1009
+ function inviteMessage(person) {
1010
+ return [
1011
+ 'You have access to Flow.',
1012
+ `Sign in at ${location.origin}/sign-in with ${person.email}.`,
1013
+ 'Use Continue with Google if that address is a Google account.',
1014
+ ].join('\n');
1015
+ }
1016
+
1017
+ async function copyInvite(person) {
1018
+ try {
1019
+ await navigator.clipboard.writeText(inviteMessage(person));
1020
+ return true;
1021
+ } catch {
1022
+ return false;
1023
+ }
1024
+ }
1025
+
910
1026
  async function invitePerson() {
1027
+ const hint = h('p', { class: 'fx-dim' }, ROLE_HINT.staff);
911
1028
  const data = await dialog({
912
1029
  title: 'Invite someone',
913
1030
  body: h('div', { class: 'fx-fields' },
@@ -916,14 +1033,16 @@ async function invitePerson() {
916
1033
  h('input', { class: 'fx-input', name: 'email', type: 'email', required: true, autocomplete: 'off', placeholder: 'name@example.com' })),
917
1034
  h('label', { class: 'fx-field' },
918
1035
  h('span', { class: 'fx-label' }, 'Role'),
919
- roleSelect('staff', { name: 'role' })),
920
- h('p', { class: 'fx-dim' }, 'They sign in with this email. Flow prepares their sign-in account.')),
921
- confirm: 'Send invitation',
1036
+ roleSelect('staff', { name: 'role', onchange: (event) => { hint.textContent = ROLE_HINT[event.target.value]; } })),
1037
+ hint),
1038
+ confirm: 'Send invite',
922
1039
  });
923
1040
  if (!data) return;
924
1041
  try {
925
- await flow('/api/people', { method: 'POST', body: JSON.stringify({ email: data.get('email'), role: data.get('role') }) });
926
- toast(`${data.get('email')} is invited.`, 'success');
1042
+ const { person, invite } = await flow('/api/people', { method: 'POST', body: JSON.stringify({ email: data.get('email'), role: data.get('role') }) });
1043
+ if (invite?.emailed) toast(`Invite emailed to ${person.email}.`, 'success');
1044
+ else if (await copyInvite(person)) toast(`${person.email} is invited, but the email could not be sent. A sign-in message is copied: paste it into an email to them.`, 'info');
1045
+ else toast(`${person.email} is invited, but the email could not be sent. Send them: ${inviteMessage(person)}`, 'info');
927
1046
  } catch (error) {
928
1047
  toast(error.message, 'danger');
929
1048
  }
@@ -0,0 +1,100 @@
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, { label }) answers { element, draw(state) }. label names the
9
+ // bar for a screen reader ("Ticked invoices", "Ticked jobs"). state = { count, onClear,
10
+ // groups: [[action]], more: [{ head, actions: [action] }] }; an action is
11
+ // { icon, label, title?, run, primary?, iconOnly?, tone? }. title is the
12
+ // long name, for the hover tip and the More menu. A count of 0 hides the
13
+ // bar; Esc clears the ticks while it shows.
14
+ import { lucide } from '/assets/ui/custom-fields.js';
15
+
16
+ let barCount = 0;
17
+
18
+ export function bulkBar(ui, { label = 'Ticked invoices' } = {}) {
19
+ const { h } = ui;
20
+ const id = `fx-bulk-more-${++barCount}`;
21
+ const element = h('div', { class: 'fx-bulk', role: 'toolbar', 'aria-label': label, hidden: true });
22
+ const panel = h('div', { class: 'fx-pop fx-bulk__more', id, popover: 'auto', role: 'menu' });
23
+ let current = null;
24
+ let busy = false;
25
+
26
+ const run = async (action) => {
27
+ if (busy) return;
28
+ busy = true;
29
+ element.setAttribute('aria-busy', 'true');
30
+ try { await action.run(); } finally {
31
+ busy = false;
32
+ element.removeAttribute('aria-busy');
33
+ }
34
+ };
35
+
36
+ const button = (action) => h('button', {
37
+ type: 'button',
38
+ class: `fx-bulk__act${action.primary ? ' fx-bulk__act--primary' : ''}${action.iconOnly ? ' fx-bulk__act--icon' : ''}`,
39
+ ...(action.tone ? { 'data-tone': action.tone } : {}),
40
+ ...(action.iconOnly ? { 'aria-label': action.title ?? action.label, 'data-tip': action.title ?? action.label } : action.title ? { title: action.title } : {}),
41
+ onclick: () => run(action),
42
+ }, lucide(h, action.icon), action.iconOnly ? null : h('span', { class: 'fx-bulk__word' }, action.label));
43
+
44
+ // More: a search box over every action, under small heads.
45
+ const search = h('input', { class: 'fx-input fx-bulk__search', type: 'search', placeholder: 'Search…', 'aria-label': 'Search actions' });
46
+ const list = h('div', { class: 'fx-bulk__list' });
47
+ const drawMore = () => {
48
+ const q = search.value.trim().toLowerCase();
49
+ const parts = [];
50
+ for (const { head, actions } of current?.more ?? []) {
51
+ const found = actions.filter((a) => !q || `${a.label} ${a.title ?? ''}`.toLowerCase().includes(q));
52
+ if (!found.length) continue;
53
+ parts.push(h('p', { class: 'fx-pop__head' }, head), h('div', { class: 'fx-bulk__grid' }, found.map((action) => h('button', {
54
+ type: 'button', class: 'fx-pop__item', role: 'menuitem', ...(action.tone ? { 'data-tone': action.tone } : {}),
55
+ onclick: () => { panel.hidePopover?.(); run(action); },
56
+ }, h('span', { class: 'fx-pop__tick' }, lucide(h, action.icon)), h('span', {}, action.title ?? action.label)))));
57
+ }
58
+ list.replaceChildren(...(parts.length ? parts : [h('p', { class: 'fx-bulk__none' }, 'No action matches.')]));
59
+ };
60
+ search.addEventListener('input', drawMore);
61
+ const onKey = (event) => {
62
+ if (!element.isConnected) { document.removeEventListener('keydown', onKey); return; }
63
+ if (event.key === 'Escape' && !element.hidden && !panel.matches?.(':popover-open') && !document.querySelector('dialog[open]')) current?.onClear();
64
+ };
65
+ document.addEventListener?.('keydown', onKey);
66
+ panel.append(search, list);
67
+ const more = h('button', {
68
+ type: 'button', class: 'fx-bulk__act', popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false',
69
+ }, lucide(h, 'ellipsis'), h('span', { class: 'fx-bulk__word' }, 'More'));
70
+ // The menu opens above the bar, at its right edge.
71
+ panel.addEventListener('toggle', (event) => {
72
+ more.setAttribute('aria-expanded', String(event.newState === 'open'));
73
+ if (event.newState !== 'open') return;
74
+ search.value = '';
75
+ drawMore();
76
+ const at = more.getBoundingClientRect();
77
+ panel.style.top = `${Math.round(Math.max(8, at.top - panel.offsetHeight - 8))}px`;
78
+ panel.style.left = `${Math.round(Math.max(8, Math.min(at.right - panel.offsetWidth, innerWidth - panel.offsetWidth - 8)))}px`;
79
+ search.focus();
80
+ });
81
+
82
+ function draw(state) {
83
+ current = state;
84
+ element.hidden = !state?.count;
85
+ if (element.hidden) { panel.hidePopover?.(); return; }
86
+ const count = h('span', { class: 'fx-bulk__count' },
87
+ h('span', {}, `${state.count} selected`),
88
+ h('button', { type: 'button', class: 'fx-bulk__clear', 'aria-label': 'Clear the ticks', 'data-tip': 'Clear', onclick: state.onClear }, lucide(h, 'x')));
89
+ const groups = state.groups.filter((g) => g.length);
90
+ const parts = [];
91
+ groups.forEach((group, i) => {
92
+ if (i) parts.push(h('span', { class: 'fx-bulk__sep', 'aria-hidden': 'true' }));
93
+ parts.push(...group.map(button));
94
+ });
95
+ if ((state.more ?? []).some((g) => g.actions.length)) parts.push(h('span', { class: 'fx-bulk__sep', 'aria-hidden': 'true' }), more, panel);
96
+ element.replaceChildren(count, h('span', { class: 'fx-bulk__acts' }, ...parts));
97
+ }
98
+
99
+ return { element, draw };
100
+ }