@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.
- package/README.md +1 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/custom-fields.js +2 -1
- package/server/api/invoicing.js +61 -3
- package/server/auth/clerk.js +35 -0
- package/server/auth/routes.js +45 -6
- package/server/core/custom-fields.js +18 -0
- package/server/core/host.js +24 -0
- package/server/core/job-board.js +2 -1
- package/server/core/job-match.js +241 -0
- package/server/core/ledger.js +80 -1
- package/server/index.js +9 -3
- package/server/integrations/seazona/feed.js +4 -1
- package/server/integrations/seazona/index.js +1 -1
- package/server/integrations/seazona/settings.js +5 -2
- package/server/integrations/sentry/index.js +5 -2
- package/server/integrations/xero/fit.js +108 -0
- package/server/integrations/xero/import.js +6 -1
- package/server/integrations/xero/index.js +4 -4
- package/server/integrations/xero/ledger.js +3 -0
- package/server/integrations/xero/links.js +293 -0
- package/server/integrations/xero/migrations/0008_xero_invoice_links.sql +26 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/reconcile.js +17 -11
- package/server/integrations/xero/routes.js +42 -0
- package/server/integrations/xero/settings.js +12 -5
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +142 -23
- package/server/ui/bulk-bar.js +100 -0
- package/server/ui/flow.css +225 -11
- package/server/ui/invoicing.js +234 -30
- package/server/ui/job-board.js +60 -34
- package/server/ui/job-view.js +30 -7
- package/server/ui/jobs.js +469 -38
- package/server/ui/reconcile.js +275 -0
- package/server/ui/routes.js +1 -1
- 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
|
-
|
|
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
|
|
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', {
|
|
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', {
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
921
|
-
confirm: 'Send
|
|
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(
|
|
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
|
+
}
|