@arcforgelabs/flow 2026.10.50 → 2026.10.52
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/SECURITY.md +9 -4
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/captures.js +131 -0
- package/server/api/invoicing.js +1 -1
- package/server/app.js +15 -0
- package/server/auth/people.js +3 -3
- package/server/core/ai-bedrock.js +124 -0
- package/server/core/ai.js +145 -0
- package/server/core/capture-match.js +158 -0
- package/server/core/captures.js +501 -0
- package/server/core/invoice-rules.js +16 -5
- package/server/core/reader.js +324 -0
- package/server/core/sending.js +7 -3
- package/server/core/storage.js +491 -0
- package/server/db/migrations/0023_captures.sql +46 -0
- package/server/db/migrations/0024_rule_icon.sql +5 -0
- package/server/db/migrations/meta/0023_snapshot.json +4693 -0
- package/server/db/migrations/meta/0024_snapshot.json +4703 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +50 -0
- package/server/index.js +47 -1
- package/server/integrations/seazona/history/backfill.js +39 -9
- package/server/integrations/seazona/index.js +1 -12
- package/server/integrations/seazona/stage-moves.js +3 -13
- package/server/integrations/sentry/index.js +6 -6
- package/server/integrations/setup.js +1 -0
- package/server/integrations/xero/drafts.js +27 -13
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +101 -8
- package/server/ui/capture-fields.js +17 -0
- package/server/ui/captures.js +283 -0
- package/server/ui/columns.js +68 -3
- package/server/ui/flow.css +92 -17
- package/server/ui/invoice-rules.js +10 -2
- package/server/ui/invoicing-settings.js +2 -8
- package/server/ui/invoicing.js +12 -8
- package/server/ui/job-view.js +6 -3
- package/server/ui/jobs.js +40 -117
- package/server/ui/kit.js +86 -1
- package/server/ui/list-views.js +47 -3
- package/server/ui/mobile.js +45 -5
- package/server/ui/nav-pins.js +4 -2
- package/server/ui/price-list.js +138 -69
- package/server/ui/routes.js +4 -1
- package/server/ui/saved-views.js +2 -0
- package/server/ui/view-bar.js +61 -2
package/server/ui/app.js
CHANGED
|
@@ -10,6 +10,7 @@ import { getAppearance, onAppearanceChange, setAppearance, loadFonts } from '/as
|
|
|
10
10
|
import { flowClerkAppearance } from '/assets/ui/clerk-appearance.js';
|
|
11
11
|
import { ACCENT_PRESETS, normalizeAccent, THEME_FAMILIES, tokensFor } from '/assets/theme/themes.js';
|
|
12
12
|
import { renderInvoice, renderInvoiceList } from '/assets/ui/invoicing.js';
|
|
13
|
+
import { renderCaptures } from '/assets/ui/captures.js';
|
|
13
14
|
import { renderContacts } from '/assets/ui/contacts.js';
|
|
14
15
|
import { automationSection, renderAutomations } from '/assets/ui/automations.js';
|
|
15
16
|
import { renderInvoicingSettings, sendModeSection } from '/assets/ui/invoicing-settings.js';
|
|
@@ -25,7 +26,7 @@ import { isSearchKey, openSearch } from '/assets/ui/search.js';
|
|
|
25
26
|
import { alertStrip } from '/assets/ui/alerts.js';
|
|
26
27
|
import { initialsOf, personLabel } from '/assets/ui/person.js';
|
|
27
28
|
import { startTips } from '/assets/ui/tip.js';
|
|
28
|
-
import { crumbs, dialog, edgeFade, fieldError, h, holdButton, linksOpenWith, listFrame, listHead, notice, pageHeader, pickOne, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
|
|
29
|
+
import { crumbs, dialog, edgeFade, fieldError, h, holdButton, iconPicker, linksOpenWith, listFrame, listHead, notice, pageHeader, pickOne, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
|
|
29
30
|
import { NAV_DEFAULT, NAV_LOCKED } from '/assets/ui/nav-pins.js';
|
|
30
31
|
|
|
31
32
|
const $ = (id) => document.getElementById(id);
|
|
@@ -152,6 +153,7 @@ let me = null;
|
|
|
152
153
|
// cannot be unpinned (nav-pins.js).
|
|
153
154
|
const VIEWS = {
|
|
154
155
|
jobs: { label: 'Jobs', icon: 'jobs', render: (main) => renderJobs(main, ui()) },
|
|
156
|
+
captures: { label: 'Capture', icon: 'camera', render: (main) => renderCaptures(main, ui()) },
|
|
155
157
|
invoices: { label: 'Invoices', icon: 'invoices', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
|
|
156
158
|
contacts: { label: 'Contacts', icon: 'contacts', render: (main, id) => renderContacts(main, ui(), id) },
|
|
157
159
|
reconcile: { label: 'Reconcile', icon: 'reconcile', render: (main, id) => renderReconcile(main, ui(), id) },
|
|
@@ -174,6 +176,7 @@ const ICON_PATHS = {
|
|
|
174
176
|
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"/>',
|
|
175
177
|
invoices: '<path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5M9 13h7M9 17h5"/>',
|
|
176
178
|
contacts: '<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="2"/><path d="M6 16c.4-1.6 1.5-2.4 3-2.4s2.6.8 3 2.4M14 9h5M14 13h4"/>',
|
|
179
|
+
camera: '<path d="M4 8h3l2-3h6l2 3h3v11H4z"/><circle cx="12" cy="13" r="3.5"/>',
|
|
177
180
|
bolt: '<path d="M13 3L5 13h6l-1 8 8-10h-6z"/>',
|
|
178
181
|
rules: '<path d="M3 6l2 2 4-4M3 16l2 2 4-4M13 6h8M13 12h8M13 18h8"/>',
|
|
179
182
|
reconcile: '<path d="M4 8h13M13 4l4 4-4 4M20 16H7M11 12l-4 4 4 4"/>',
|
|
@@ -332,7 +335,7 @@ function navigate(event) {
|
|
|
332
335
|
// The shared pieces a view module (invoicing.js) builds with. Its `go`
|
|
333
336
|
// takes a path, or a link's click event.
|
|
334
337
|
const ui = () => ({
|
|
335
|
-
h, flow, toast, notice, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, settingRow, dialog, pickOne, me, holdButton, tileCard, tileSection, crumbs,
|
|
338
|
+
h, flow, toast, notice, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, settingRow, dialog, pickOne, iconPicker, me, holdButton, tileCard, tileSection, crumbs,
|
|
336
339
|
go: (target, options) => (typeof target === 'string' ? go(target, options) : navigate(target)),
|
|
337
340
|
});
|
|
338
341
|
|
|
@@ -348,7 +351,7 @@ function renderSignedOut() {
|
|
|
348
351
|
return;
|
|
349
352
|
}
|
|
350
353
|
$('sign-in-note').textContent = config.publicSignUp
|
|
351
|
-
? '
|
|
354
|
+
? 'New here? Ask for access with your work email.'
|
|
352
355
|
: 'Sign in with the account you were invited with.';
|
|
353
356
|
// With public sign-up off the widget never creates a Clerk account: no
|
|
354
357
|
// sign-up link, and an OAuth sign-in for an unknown email is not turned
|
|
@@ -741,6 +744,9 @@ async function renderIntegration(main, id) {
|
|
|
741
744
|
// Settings: closed on a phone, open on a wider screen, then as the person
|
|
742
745
|
// leaves it while the page redraws (Connect).
|
|
743
746
|
let settingsOpen = !window.matchMedia?.('(max-width: 639px)').matches;
|
|
747
|
+
// Advanced: the technical settings and the host's values. Open while the
|
|
748
|
+
// integration is off, so Connect shows every setting it needs.
|
|
749
|
+
let advancedOpen = null;
|
|
744
750
|
const alerts = alertStrip(ui(), 'integrations', {
|
|
745
751
|
scope: id,
|
|
746
752
|
onLoad: ({ danger }) => {
|
|
@@ -753,10 +759,11 @@ async function renderIntegration(main, id) {
|
|
|
753
759
|
shown = current;
|
|
754
760
|
const inputs = new Map();
|
|
755
761
|
const rows = [];
|
|
762
|
+
const advanced = [];
|
|
756
763
|
for (const field of current.fields) {
|
|
757
764
|
if (field.source === 'host') {
|
|
758
765
|
// The host sets it: a value to read, not a box to type in.
|
|
759
|
-
|
|
766
|
+
advanced.push(settingRow({
|
|
760
767
|
label: field.label,
|
|
761
768
|
help: 'Set by whoever hosts Flow.',
|
|
762
769
|
control: field.secret ? h('span', { class: 'fx-dim' }, 'Set') : h('code', { class: 'fx-setting__value' }, field.value),
|
|
@@ -778,7 +785,7 @@ async function renderIntegration(main, id) {
|
|
|
778
785
|
}
|
|
779
786
|
inputs.set(field.name, { field, input });
|
|
780
787
|
const suggestion = field.suggest === 'callback' ? `${location.origin}/api/${current.id}/oauth/callback` : null;
|
|
781
|
-
rows.push(settingRow({
|
|
788
|
+
(field.advanced ? advanced : rows).push(settingRow({
|
|
782
789
|
label: field.label,
|
|
783
790
|
hint: field.unreadable ? 'Stored with another key. Enter it again.' : null,
|
|
784
791
|
help: field.help,
|
|
@@ -867,6 +874,7 @@ async function renderIntegration(main, id) {
|
|
|
867
874
|
current.id === 'xero' && current.state === 'ready' ? h('section', { class: 'fx-int-section' }, xeroConnection(body.organisations, canChange)) : null,
|
|
868
875
|
current.id === 'seazona' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'seazona', 'Ready to invoice')) : null,
|
|
869
876
|
on && current.state === 'ready' ? personSettingsSection(current) : null,
|
|
877
|
+
current.id === 'seazona' && current.state === 'ready' ? seazonaHistorySection() : null,
|
|
870
878
|
current.id === 'xero' && current.state === 'ready' ? xeroImportSection(body.organisations) : null,
|
|
871
879
|
current.id === 'xero' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'xero', 'Contacts')) : null,
|
|
872
880
|
current.id === 'xero' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, sendModeSection(ui())) : null,
|
|
@@ -874,9 +882,12 @@ async function renderIntegration(main, id) {
|
|
|
874
882
|
h('summary', { class: 'fx-int-section__title' }, 'Set up'),
|
|
875
883
|
h('ol', { class: 'fx-steps' }, guide.steps.map((step) => h('li', {}, step))),
|
|
876
884
|
guide.link ? h('a', { class: 'fx-btn fx-btn--sm', href: guide.link, target: '_blank', rel: 'noopener' }, `${current.name} site ↗`) : null) : null,
|
|
877
|
-
rows.length ? h('details', { class: 'fx-int-section fx-int-fold', open: settingsOpen, ontoggle: (event) => { settingsOpen = event.currentTarget.open; } },
|
|
885
|
+
rows.length || advanced.length ? h('details', { class: 'fx-int-section fx-int-fold', open: settingsOpen, ontoggle: (event) => { settingsOpen = event.currentTarget.open; } },
|
|
878
886
|
h('summary', { class: 'fx-int-section__title' }, 'Settings'),
|
|
879
|
-
h('div', { class: 'fx-settings' }, rows),
|
|
887
|
+
rows.length ? h('div', { class: 'fx-settings' }, rows) : null,
|
|
888
|
+
advanced.length ? h('details', { class: 'fx-int-fold fx-int-fold--sub', open: advancedOpen ?? !on, ontoggle: (event) => { advancedOpen = event.currentTarget.open === !on ? null : event.currentTarget.open; } },
|
|
889
|
+
h('summary', {}, 'Advanced'),
|
|
890
|
+
h('div', { class: 'fx-settings' }, advanced)) : null,
|
|
880
891
|
canChange && on && inputs.size ? h('div', { class: 'fx-row fx-row--end' }, h('button', { class: 'fx-btn', type: 'button', onclick: () => save(undefined) }, 'Save changes')) : null) : null,
|
|
881
892
|
].filter(Boolean)),
|
|
882
893
|
h('aside', { class: 'fx-int-page__meta' },
|
|
@@ -978,6 +989,87 @@ function xeroImportSection(organisations) {
|
|
|
978
989
|
return section;
|
|
979
990
|
}
|
|
980
991
|
|
|
992
|
+
// Seazona's history read (docs/SEAZONA.md, "The API backfill"): past orders
|
|
993
|
+
// from a start day, a few calls a run inside the rate limit. Start, Resume
|
|
994
|
+
// and Stop call no Seazona; the background lane reads, and skips orders Flow
|
|
995
|
+
// read before. While it runs, the card reads its progress again every 5
|
|
996
|
+
// seconds, from Flow only, and counts down to the lane's next run.
|
|
997
|
+
function seazonaHistorySection() {
|
|
998
|
+
const canAct = ['owner', 'admin'].includes(me?.person?.role);
|
|
999
|
+
const yearAgo = new Date(Date.now() - 365 * 86_400_000).toISOString().slice(0, 10);
|
|
1000
|
+
const from = h('input', { class: 'fx-input fx-history__day', type: 'date', 'aria-label': 'Read from', value: yearAgo, disabled: !canAct });
|
|
1001
|
+
const status = h('span', { class: 'fx-history__status' }, 'Checking…');
|
|
1002
|
+
const control = h('span', { class: 'fx-row' });
|
|
1003
|
+
const bar = h('progress', { class: 'fx-history__bar', max: 1, value: 0, 'aria-label': 'Read so far' });
|
|
1004
|
+
const barRow = settingRow({ label: 'Progress', hint: h('span', {}), control: bar, wide: true });
|
|
1005
|
+
barRow.hidden = true;
|
|
1006
|
+
const day = (value) => new Date(`${value}T00:00:00Z`).toLocaleDateString('en-AU', { dateStyle: 'medium', timeZone: 'UTC' });
|
|
1007
|
+
const section = h('section', { class: 'fx-set fx-int-section fx-card', style: { padding: '8px 20px 16px' } },
|
|
1008
|
+
h('h2', { class: 'fx-set__title' }, 'History'),
|
|
1009
|
+
settingRow({ label: 'Read from', hint: status, help: 'Reads past Seazona orders into Flow, 10 every 2 minutes. Orders Flow read before are skipped. Each job waits on hold and never drafts by itself.', control }),
|
|
1010
|
+
barRow);
|
|
1011
|
+
let timer = null;
|
|
1012
|
+
let tick = null;
|
|
1013
|
+
const press = async (button, path, body) => {
|
|
1014
|
+
button.disabled = true;
|
|
1015
|
+
try {
|
|
1016
|
+
await flow(path, { method: 'POST', ...(body ? { body: JSON.stringify(body) } : {}) });
|
|
1017
|
+
toast(body ? 'History read started.' : 'History read stopped.', 'success');
|
|
1018
|
+
} catch (error) {
|
|
1019
|
+
toast(error.message, 'danger');
|
|
1020
|
+
if (error.retryAfterMs) holdButton(button, error.retryAfterMs);
|
|
1021
|
+
}
|
|
1022
|
+
load();
|
|
1023
|
+
};
|
|
1024
|
+
const start = (label, body, primary = true) => h('button', {
|
|
1025
|
+
class: `fx-btn fx-btn--sm${primary ? ' fx-btn--primary' : ''}`, type: 'button', disabled: !canAct,
|
|
1026
|
+
onclick: (event) => press(event.currentTarget, '/api/seazona/history/backfill', body()),
|
|
1027
|
+
}, label);
|
|
1028
|
+
const load = async () => {
|
|
1029
|
+
clearTimeout(timer);
|
|
1030
|
+
clearInterval(tick);
|
|
1031
|
+
if (!section.isConnected && timer !== null) return;
|
|
1032
|
+
let body;
|
|
1033
|
+
try {
|
|
1034
|
+
body = await flow('/api/seazona/history');
|
|
1035
|
+
} catch (error) {
|
|
1036
|
+
status.replaceChildren(error.message);
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
const run = body.backfill;
|
|
1040
|
+
barRow.hidden = !run;
|
|
1041
|
+
if (run) {
|
|
1042
|
+
const span = Date.parse(run.through) - Date.parse(run.from) + 86_400_000;
|
|
1043
|
+
const at = run.running ? run.readingDay ?? run.through : run.resumeFrom ?? run.through;
|
|
1044
|
+
bar.value = run.running || run.resumeFrom ? Math.min(1, Math.max(0, (Date.parse(at) - Date.parse(run.from)) / span)) : 1;
|
|
1045
|
+
barRow.querySelector('.fx-setting__hint').replaceChildren(`${day(run.from)} to ${day(run.through)}`);
|
|
1046
|
+
}
|
|
1047
|
+
if (run?.running) {
|
|
1048
|
+
const what = { waiting: 'Waiting', failed: 'Failed' }[run.phase] ?? `Reading ${day(run.readingDay ?? run.through)}`;
|
|
1049
|
+
const reading = `${what} · ${run.ordersRead} read${run.waitingToRead ? ` · ${run.waitingToRead} to read` : ''}`;
|
|
1050
|
+
const next = h('span', { class: 'fx-dim' });
|
|
1051
|
+
const count = () => {
|
|
1052
|
+
const left = Math.round((Date.parse(run.nextRunAt) - Date.now()) / 1000);
|
|
1053
|
+
next.replaceChildren(left > 0 ? `· ${run.phase === 'running' ? 'next' : 'retry'} in ${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}` : '· reading now');
|
|
1054
|
+
};
|
|
1055
|
+
if (run.nextRunAt && run.phase !== 'failed') { count(); tick = setInterval(count, 1000); }
|
|
1056
|
+
status.replaceChildren(
|
|
1057
|
+
run.phase === 'running' ? h('span', { class: 'fx-spinner fx-spinner--sm', 'aria-hidden': 'true' }) : '',
|
|
1058
|
+
reading, next, run.cause ? h('span', { class: 'fx-dim' }, `· ${run.cause}`) : '');
|
|
1059
|
+
control.replaceChildren(h('button', { class: 'fx-btn fx-btn--sm', type: 'button', disabled: !canAct, onclick: (event) => press(event.currentTarget, '/api/seazona/history/backfill/stop') }, 'Stop'));
|
|
1060
|
+
} else if (run?.resumeFrom) {
|
|
1061
|
+
status.replaceChildren(`Stopped at ${day(run.resumeFrom)} · ${run.ordersRead} read`);
|
|
1062
|
+
control.replaceChildren(from, start('Start', () => ({ from: from.value }), false), start('Resume', () => ({ from: run.resumeFrom, through: run.through })));
|
|
1063
|
+
} else {
|
|
1064
|
+
status.replaceChildren(run ? `Done · ${run.ordersRead} read` : 'Not started');
|
|
1065
|
+
control.replaceChildren(from, start('Start', () => ({ from: from.value })));
|
|
1066
|
+
}
|
|
1067
|
+
timer = setTimeout(() => { if (section.isConnected && run?.running) load(); }, 5_000);
|
|
1068
|
+
};
|
|
1069
|
+
load();
|
|
1070
|
+
return section;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
981
1073
|
// Once Xero is on: each organisation's connection, the history copy, and
|
|
982
1074
|
// Connect, which goes to Xero and comes back.
|
|
983
1075
|
function xeroConnection(organisations, canChange) {
|
|
@@ -1242,7 +1334,8 @@ drawModeToggles();
|
|
|
1242
1334
|
// the Clerk instance is called.
|
|
1243
1335
|
const LOCALIZATION = {
|
|
1244
1336
|
signIn: {
|
|
1245
|
-
|
|
1337
|
+
// titleCombined is the sign-in-or-sign-up screen (public sign-up on).
|
|
1338
|
+
start: { title: 'Sign in', titleCombined: 'Sign in', subtitle: '' },
|
|
1246
1339
|
password: { subtitle: 'Enter the password for your account.' },
|
|
1247
1340
|
},
|
|
1248
1341
|
signUp: { start: { title: 'Ask for access to Flow', subtitle: 'Use your work email. An owner approves new people.' } },
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The job fields the photo reader asks for (server/core/reader.js) and the
|
|
2
|
+
// capture screens label (server/ui/captures.js). No DOM, so both can use it.
|
|
3
|
+
// `needed` fields are read again by the larger model when they are not sure.
|
|
4
|
+
export const JOB_FIELDS = Object.freeze([
|
|
5
|
+
Object.freeze({ key: 'case_number', label: 'Case number', needed: true }),
|
|
6
|
+
Object.freeze({ key: 'practice', label: 'Practice', needed: true }),
|
|
7
|
+
Object.freeze({ key: 'dentist', label: 'Dentist', needed: false }),
|
|
8
|
+
Object.freeze({ key: 'work', label: 'Work', needed: true }),
|
|
9
|
+
Object.freeze({ key: 'teeth', label: 'Teeth', needed: false }),
|
|
10
|
+
Object.freeze({ key: 'shade', label: 'Shade', needed: false }),
|
|
11
|
+
Object.freeze({ key: 'due_date', label: 'Due date', needed: false }),
|
|
12
|
+
Object.freeze({ key: 'notes', label: 'Notes', needed: false }),
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
// Below this a needed field is read again (FLOW_READER_SURE_LINE can move
|
|
16
|
+
// that line), and the screens mark a value as not sure (amber).
|
|
17
|
+
export const SURE_LINE = 0.8;
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// Capture (/captures): a photo of a handwritten lab form becomes a proposed
|
|
2
|
+
// job change (RFC #357, docs/DATA_MODEL.md "Storage"). One tap opens the
|
|
3
|
+
// phone's own camera. Reading runs on the server, so the person can shoot the
|
|
4
|
+
// next form at once. The tray lists the captures; a row opens the result
|
|
5
|
+
// sheet: the photo, the job, only the fields that change (old, then new;
|
|
6
|
+
// amber when the reader was not sure) and Apply at the foot. On a desk a
|
|
7
|
+
// photo can also be dropped or pasted on the page, and the sheet takes J, K
|
|
8
|
+
// and Enter.
|
|
9
|
+
import { lucide } from '/assets/ui/custom-fields.js';
|
|
10
|
+
import { JOB_FIELDS, SURE_LINE } from '/assets/ui/capture-fields.js';
|
|
11
|
+
import { isPhone } from '/assets/ui/mobile.js';
|
|
12
|
+
import { recordBar } from '/assets/ui/sidecar.js';
|
|
13
|
+
|
|
14
|
+
// The phone sends a smaller copy: about 1,600 px on the long side.
|
|
15
|
+
const LONG_SIDE = 1600;
|
|
16
|
+
const POLL_MS = 3000;
|
|
17
|
+
const POLL_MAX = 40;
|
|
18
|
+
const OFF = 'Capture needs a file store (FLOW_STORAGE).';
|
|
19
|
+
const STATE = { reading: 'Reading', ready: 'Ready', problem: 'Problem', done: 'Done' };
|
|
20
|
+
const TONE = { reading: 'info', ready: 'success', problem: 'warn', done: 'mute' };
|
|
21
|
+
|
|
22
|
+
const valueOf = (capture, key) => capture.read?.fields?.find((f) => f.key === key)?.value || '';
|
|
23
|
+
|
|
24
|
+
// One line for a capture: the practice and the work, else the case number.
|
|
25
|
+
function titleOf(capture) {
|
|
26
|
+
const practice = valueOf(capture, 'practice');
|
|
27
|
+
const work = valueOf(capture, 'work');
|
|
28
|
+
const text = [practice, work].filter(Boolean).join(' · ') || valueOf(capture, 'case_number');
|
|
29
|
+
if (text) return text;
|
|
30
|
+
if (capture.state === 'reading') return 'Reading…';
|
|
31
|
+
const problem = capture.problem ?? '';
|
|
32
|
+
return problem ? problem[0].toUpperCase() + problem.slice(1) : 'Photo';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function shrink(file) {
|
|
36
|
+
try {
|
|
37
|
+
const bitmap = await createImageBitmap(file);
|
|
38
|
+
const scale = Math.min(1, LONG_SIDE / Math.max(bitmap.width, bitmap.height));
|
|
39
|
+
const canvas = document.createElement('canvas');
|
|
40
|
+
canvas.width = Math.round(bitmap.width * scale);
|
|
41
|
+
canvas.height = Math.round(bitmap.height * scale);
|
|
42
|
+
canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
|
43
|
+
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.85));
|
|
44
|
+
return blob ?? file;
|
|
45
|
+
} catch {
|
|
46
|
+
// A type the browser cannot draw (HEIC on some browsers) goes as it is.
|
|
47
|
+
return file;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function upload(file) {
|
|
52
|
+
const body = await shrink(file);
|
|
53
|
+
const token = await window.Clerk.session?.getToken();
|
|
54
|
+
const res = await fetch('/api/captures', {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: {
|
|
57
|
+
Authorization: `Bearer ${token}`,
|
|
58
|
+
'Content-Type': body.type || 'image/jpeg',
|
|
59
|
+
'Idempotency-Key': crypto.randomUUID(),
|
|
60
|
+
'X-Flow-Capture-Source': isPhone() ? 'phone' : 'desk',
|
|
61
|
+
},
|
|
62
|
+
body,
|
|
63
|
+
});
|
|
64
|
+
const answer = await res.json().catch(() => ({}));
|
|
65
|
+
if (!res.ok) throw new Error(res.status === 404 ? OFF : answer.error || `HTTP ${res.status}`);
|
|
66
|
+
return answer.capture;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The Capture button, on Capture and in the Jobs toolbar: one tap opens the
|
|
70
|
+
// phone's own camera; a desk picks files. `send(files)` uploads each image
|
|
71
|
+
// and calls `added(capture)`, then `done()`.
|
|
72
|
+
export function captureButton(ui, { icon = false, added = () => {}, done = () => {} } = {}) {
|
|
73
|
+
const { h, toast } = ui;
|
|
74
|
+
const picker = h('input', { type: 'file', accept: 'image/*', hidden: true });
|
|
75
|
+
if (isPhone()) picker.setAttribute('capture', 'environment');
|
|
76
|
+
else picker.multiple = true;
|
|
77
|
+
const button = icon
|
|
78
|
+
? h('button', { class: 'fx-btn fx-btn--ghost fx-btn--icon fx-capture__shoot', type: 'button', 'aria-label': 'Capture', title: 'Capture', onclick: () => picker.click() }, lucide(h, 'camera'))
|
|
79
|
+
: h('button', { class: 'fx-btn fx-btn--primary fx-capture__shoot', type: 'button', onclick: () => picker.click() }, lucide(h, 'camera'), 'Capture');
|
|
80
|
+
const send = async (files) => {
|
|
81
|
+
for (const file of [...files].filter((f) => f.type.startsWith('image/'))) {
|
|
82
|
+
try {
|
|
83
|
+
added(await upload(file));
|
|
84
|
+
} catch (error) {
|
|
85
|
+
toast(error.message, 'danger', { stay: true });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
done();
|
|
89
|
+
};
|
|
90
|
+
picker.addEventListener('change', () => {
|
|
91
|
+
const files = [...picker.files];
|
|
92
|
+
picker.value = '';
|
|
93
|
+
send(files);
|
|
94
|
+
});
|
|
95
|
+
return { nodes: [button, picker], send };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function renderCaptures(main, ui) {
|
|
99
|
+
const { h, flow, toast, notice, listHead, listFrame } = ui;
|
|
100
|
+
const shoot = captureButton(ui, {
|
|
101
|
+
added: (capture) => { rows = [capture, ...rows]; draw(); },
|
|
102
|
+
done: () => { polls = 0; load(); },
|
|
103
|
+
});
|
|
104
|
+
const head = listHead('Captures', { actions: shoot.nodes });
|
|
105
|
+
const list = h('ul', { class: 'fx-captures', 'aria-label': 'Captures' });
|
|
106
|
+
const frame = listFrame(main, list);
|
|
107
|
+
main.append(...head.nodes, frame.element);
|
|
108
|
+
|
|
109
|
+
let rows = [];
|
|
110
|
+
let labels = new Map(JOB_FIELDS.map((f) => [f.key, f.label]));
|
|
111
|
+
let polls = 0;
|
|
112
|
+
let timer = null;
|
|
113
|
+
|
|
114
|
+
const draw = () => {
|
|
115
|
+
const ready = rows.filter((c) => c.state === 'ready').length;
|
|
116
|
+
frame.count(ready ? `${ready} ready` : '');
|
|
117
|
+
list.replaceChildren(...(rows.length ? rows.map((capture) => h('li', {},
|
|
118
|
+
h('button', { class: 'fx-captures__row', type: 'button', disabled: capture.state === 'reading', onclick: () => openSheet(capture.id) },
|
|
119
|
+
h('span', { class: 'fx-captures__title', title: titleOf(capture) }, titleOf(capture)),
|
|
120
|
+
h('span', { class: 'fx-status', 'data-tone': TONE[capture.state] }, STATE[capture.state])))) : [h('li', { class: 'fx-empty' }, 'No captures.')]));
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
async function load() {
|
|
124
|
+
try {
|
|
125
|
+
({ captures: rows } = await flow('/api/captures'));
|
|
126
|
+
draw();
|
|
127
|
+
} catch (error) {
|
|
128
|
+
const off = error.status === 404;
|
|
129
|
+
list.replaceChildren(h('li', {}, notice(off ? OFF : error.message, off ? 'warn' : 'danger', off ? 'Off.' : null)));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
if (rows.some((c) => c.state === 'reading') && polls < POLL_MAX && frame.element.isConnected) {
|
|
134
|
+
polls += 1;
|
|
135
|
+
timer = setTimeout(load, POLL_MS);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A desk: a photo dropped on the list or pasted on the page is a capture.
|
|
140
|
+
// `main` stays across pages, so the listeners hang on this page's frame.
|
|
141
|
+
frame.element.addEventListener('dragover', (event) => { if (event.dataTransfer?.types.includes('Files')) event.preventDefault(); });
|
|
142
|
+
frame.element.addEventListener('drop', (event) => {
|
|
143
|
+
if (!event.dataTransfer?.files.length) return;
|
|
144
|
+
event.preventDefault();
|
|
145
|
+
shoot.send(event.dataTransfer.files);
|
|
146
|
+
});
|
|
147
|
+
const paste = (event) => {
|
|
148
|
+
if (!frame.element.isConnected) { document.removeEventListener('paste', paste); return; }
|
|
149
|
+
const files = [...(event.clipboardData?.files ?? [])];
|
|
150
|
+
if (files.length && !document.querySelector('dialog[open]')) shoot.send(files);
|
|
151
|
+
};
|
|
152
|
+
document.addEventListener('paste', paste);
|
|
153
|
+
|
|
154
|
+
// The result sheet: a dialog on a desk, the whole screen on a phone (the
|
|
155
|
+
// job panel's layout: record bar, body, the action fixed at the foot).
|
|
156
|
+
async function openSheet(id) {
|
|
157
|
+
let capture;
|
|
158
|
+
try {
|
|
159
|
+
({ capture } = await flow(`/api/captures/${id}`));
|
|
160
|
+
} catch (error) {
|
|
161
|
+
toast(error.message, 'danger');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const element = h('dialog', { class: 'fx-dialog fx-task fx-capture', 'aria-label': 'Capture' });
|
|
165
|
+
const close = () => element.close();
|
|
166
|
+
const at = rows.findIndex((c) => c.id === id);
|
|
167
|
+
// The next capture that has a result; one still reading is skipped.
|
|
168
|
+
const step = (delta) => {
|
|
169
|
+
for (let i = at + delta; i >= 0 && i < rows.length; i += delta) {
|
|
170
|
+
if (rows[i].state === 'reading') continue;
|
|
171
|
+
close();
|
|
172
|
+
openSheet(rows[i].id);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
const proposal = capture.proposal ?? { changes: [] };
|
|
177
|
+
const match = capture.match ?? { outcome: 'none', jobs: [] };
|
|
178
|
+
const job = match.jobs.find((j) => j.jobId === proposal.jobId) ?? null;
|
|
179
|
+
const jobLine = job
|
|
180
|
+
? `${job.caseRef ? `Job ${job.caseRef}` : job.jobId} · ${job.clientName ?? ''}`
|
|
181
|
+
: proposal.action === 'create' ? 'New job' : 'Pick a job';
|
|
182
|
+
|
|
183
|
+
const changeRow = (change) => h('div', { class: 'fx-capture__change', 'data-unsure': change.sure != null && change.sure < SURE_LINE ? 'true' : 'false' },
|
|
184
|
+
h('span', { class: 'fx-capture__field' }, labels.get(change.field) ?? change.field),
|
|
185
|
+
h('span', { class: 'fx-capture__from' }, change.from || '–'),
|
|
186
|
+
h('span', { class: 'fx-capture__arrow', 'aria-hidden': 'true' }, '→'),
|
|
187
|
+
h('span', { class: 'fx-capture__to' }, change.to));
|
|
188
|
+
|
|
189
|
+
const decide = async (decision) => {
|
|
190
|
+
try {
|
|
191
|
+
await flow(`/api/captures/${id}/decision`, { method: 'POST', body: JSON.stringify({ decision }) });
|
|
192
|
+
toast(decision === 'applied' ? 'Applied.' : 'Discarded.', 'success');
|
|
193
|
+
close();
|
|
194
|
+
load();
|
|
195
|
+
} catch (error) {
|
|
196
|
+
toast(error.message, 'danger', { stay: true });
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const pickJob = async () => {
|
|
201
|
+
const chosen = await ui.pickOne({
|
|
202
|
+
title: 'Other job', label: 'Jobs', find: 'Find', empty: 'No jobs.', confirm: 'Use',
|
|
203
|
+
load: async () => {
|
|
204
|
+
const { jobs } = await flow('/api/invoicing/board');
|
|
205
|
+
const near = new Map(match.jobs.map((j, i) => [j.jobId, i]));
|
|
206
|
+
return jobs.map((j) => ({ ...j, jobId: `${j.sourceSystem}:${j.sourceJobRef}` }))
|
|
207
|
+
.sort((a, b) => (near.get(a.jobId) ?? 99) - (near.get(b.jobId) ?? 99));
|
|
208
|
+
},
|
|
209
|
+
item: (j) => ({ value: j.jobId, title: j.clientName ?? j.jobId, meta: j.caseRef ? `Job ${j.caseRef}` : null }),
|
|
210
|
+
});
|
|
211
|
+
const jobId = chosen?.jobId ?? null;
|
|
212
|
+
if (!jobId) return;
|
|
213
|
+
try {
|
|
214
|
+
await flow(`/api/captures/${id}/proposal`, { method: 'POST', body: JSON.stringify({ ...proposal, action: 'update', jobId }) });
|
|
215
|
+
close();
|
|
216
|
+
openSheet(id);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
toast(error.message, 'danger');
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// A capture the reader could not read: type the fields that find the job.
|
|
223
|
+
const typeIt = () => {
|
|
224
|
+
const inputs = ['case_number', 'practice', 'work'].map((key) => h('input', { class: 'fx-input', name: key, 'aria-label': labels.get(key), placeholder: labels.get(key) }));
|
|
225
|
+
const form = h('form', { class: 'fx-fields', onsubmit: async (event) => {
|
|
226
|
+
event.preventDefault();
|
|
227
|
+
const fields = Object.fromEntries(inputs.map((input) => [input.name, input.value.trim()]).filter(([, v]) => v));
|
|
228
|
+
try {
|
|
229
|
+
await flow(`/api/captures/${id}/match`, { method: 'POST', body: JSON.stringify({ fields }) });
|
|
230
|
+
close();
|
|
231
|
+
openSheet(id);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
toast(error.message, 'danger');
|
|
234
|
+
}
|
|
235
|
+
} }, inputs, h('button', { class: 'fx-btn', type: 'submit' }, 'Find job'));
|
|
236
|
+
return form;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const ready = capture.state === 'ready';
|
|
240
|
+
const actions = ready ? [
|
|
241
|
+
h('button', { class: 'fx-btn', type: 'button', onclick: () => decide('discarded') }, 'Discard'),
|
|
242
|
+
match.jobs.length || proposal.action === 'update' ? h('button', { class: 'fx-btn', type: 'button', onclick: pickJob }, 'Other job') : null,
|
|
243
|
+
h('button', { class: 'fx-btn fx-btn--primary', type: 'button', autofocus: true, disabled: proposal.action === 'update' && !proposal.jobId, onclick: () => decide('applied') }, 'Apply'),
|
|
244
|
+
] : capture.state === 'problem' ? [h('button', { class: 'fx-btn', type: 'button', onclick: () => decide('discarded') }, 'Discard')] : [];
|
|
245
|
+
|
|
246
|
+
element.append(
|
|
247
|
+
recordBar(h, {
|
|
248
|
+
title: jobLine, label: 'Capture', onBack: close,
|
|
249
|
+
prev: { label: 'Previous capture', disabled: at <= 0, onclick: () => step(-1) },
|
|
250
|
+
next: { label: 'Next capture', disabled: at < 0 || at >= rows.length - 1, onclick: () => step(1) },
|
|
251
|
+
}),
|
|
252
|
+
h('div', { class: 'fx-task__grid' }, h('div', { class: 'fx-task__main' },
|
|
253
|
+
h('header', { class: 'fx-task__head' }, h('span'), h('div', { class: 'fx-task__tools' },
|
|
254
|
+
h('button', { class: 'fx-btn fx-btn--ghost fx-btn--icon', type: 'button', 'aria-label': 'Close', title: 'Close', onclick: close }, '✕'))),
|
|
255
|
+
capture.link ? h('img', { class: 'fx-capture__photo', src: capture.link.url, alt: 'Form photo' }) : null,
|
|
256
|
+
h('h2', { class: 'fx-task__title' }, jobLine),
|
|
257
|
+
capture.state === 'problem' ? notice(capture.problem, 'warn', null, { short: capture.problem }) : null,
|
|
258
|
+
capture.state === 'problem' ? typeIt() : null,
|
|
259
|
+
ready && proposal.changes.length ? h('div', { class: 'fx-capture__changes' }, proposal.changes.map(changeRow)) : null,
|
|
260
|
+
ready && !proposal.changes.length ? h('p', { class: 'fx-dim' }, 'No change.') : null,
|
|
261
|
+
h('div', { class: 'fx-task__actions' }, actions))));
|
|
262
|
+
element.addEventListener('click', (event) => { if (event.target === element) close(); });
|
|
263
|
+
// Keys: J next, K previous (not while typing). Apply has the focus, so
|
|
264
|
+
// Enter applies.
|
|
265
|
+
element.addEventListener('keydown', (event) => {
|
|
266
|
+
if (event.target.closest('input, textarea, select') || event.metaKey || event.ctrlKey || event.altKey) return;
|
|
267
|
+
if (event.key !== 'j' && event.key !== 'k') return;
|
|
268
|
+
event.preventDefault();
|
|
269
|
+
step(event.key === 'j' ? 1 : -1);
|
|
270
|
+
});
|
|
271
|
+
element.addEventListener('close', () => element.remove());
|
|
272
|
+
document.body.append(element);
|
|
273
|
+
element.showModal();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
const { fields } = await flow('/api/custom-fields');
|
|
278
|
+
labels = new Map([...labels, ...fields.map((f) => [`custom_${f.id}`, f.name])]);
|
|
279
|
+
} catch {
|
|
280
|
+
// The standard labels still show; a custom field shows its key.
|
|
281
|
+
}
|
|
282
|
+
await load();
|
|
283
|
+
}
|
package/server/ui/columns.js
CHANGED
|
@@ -17,15 +17,20 @@
|
|
|
17
17
|
// widths() the saved widths
|
|
18
18
|
// change(patch) saves { columns } or { widths }
|
|
19
19
|
// hidden() [{ id, label }] the + menu lists (no hidden: no +)
|
|
20
|
-
// extra(column, item) menu nodes before Move (
|
|
20
|
+
// extra(column, item) menu nodes before Move (Edit custom field)
|
|
21
|
+
// sorting the heads sort the list (Jobs, Price list), as
|
|
22
|
+
// ClickUp's: { sortOf(columnId) (the sort key, or null),
|
|
23
|
+
// sorts(), change({ sorts }), dirWords(by) }
|
|
21
24
|
// table({ columns, rows, lead, className }), per draw, answers the table:
|
|
22
25
|
// columns the shown columns, in order: { id, label, kind, cell(row),
|
|
23
|
-
// num?, className?,
|
|
26
|
+
// num?, className?, icon?, fixed? }. An icon
|
|
24
27
|
// shows in place of the label (the label is its name). A fixed
|
|
25
28
|
// column keeps its place: no drag, no Move, no Hide.
|
|
26
29
|
// rows the <tbody> nodes (rowCells builds a row's cells)
|
|
27
30
|
// lead <th> nodes before the columns (the tick column)
|
|
28
31
|
//
|
|
32
|
+
// groupHead(h, config) is a grouped list's head row (below).
|
|
33
|
+
//
|
|
29
34
|
// Cells every list shares: dateCell (a short date), personCell (a person's
|
|
30
35
|
// initials) and fieldColumn (one of the lab's custom fields as a column, on
|
|
31
36
|
// Jobs and on Invoices, where it is the invoice's job's value).
|
|
@@ -33,6 +38,7 @@ import { lucide } from '/assets/ui/custom-fields.js';
|
|
|
33
38
|
import { endWidth, LEAD_WIDTH, MAX_WIDTH, minWidth, moveColumn, TAIL_WIDTH, tableWidth, widthOf } from '/assets/ui/job-view.js';
|
|
34
39
|
import { applyPhoneRule, assignPhoneSlots, phoneSlot } from './mobile.js';
|
|
35
40
|
import { initialsOf } from '/assets/ui/person.js';
|
|
41
|
+
import { ICONS, svg } from '/assets/ui/view-bar.js';
|
|
36
42
|
|
|
37
43
|
const fullDate = (iso) => new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
38
44
|
// A list date, short: Today, Tomorrow or Yesterday, else day/month (and
|
|
@@ -132,14 +138,73 @@ export function onRightButton(event, open) {
|
|
|
132
138
|
else open();
|
|
133
139
|
}
|
|
134
140
|
|
|
141
|
+
// A group's head row in a grouped list (Jobs, Price list): a fold button
|
|
142
|
+
// with the group's `badge` (its name and count), then `extra` (a sum). A
|
|
143
|
+
// click on the row folds the group or opens it. `lead` is the cells before
|
|
144
|
+
// the columns (a tick); `span` is how many cells the head covers after them.
|
|
145
|
+
const CHEVRON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="m6 3.5 4.5 4.5L6 12.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
146
|
+
export function groupHead(h, { label, badge, open, onFold, lead = [], span, extra = null }) {
|
|
147
|
+
const chevron = h('span', {});
|
|
148
|
+
chevron.innerHTML = CHEVRON;
|
|
149
|
+
const toggle = h('button', {
|
|
150
|
+
type: 'button', class: 'fx-table__fold', 'aria-expanded': String(open), 'aria-label': `${label}. ${open ? 'Fold' : 'Open'} the group`,
|
|
151
|
+
onclick: (event) => { event.stopPropagation(); onFold(); },
|
|
152
|
+
}, chevron.firstChild, badge);
|
|
153
|
+
return h('tr', { class: `fx-table__group${open ? '' : ' fx-table__group--folded'}`, onclick: onFold },
|
|
154
|
+
lead, h('td', { colspan: String(span) }, h('span', { class: 'fx-table__group-row' }, toggle, extra)));
|
|
155
|
+
}
|
|
156
|
+
|
|
135
157
|
export function columnTable(h, config) {
|
|
136
158
|
const menu = columnPopover(h);
|
|
137
159
|
const ids = () => config.order?.() ?? [];
|
|
138
160
|
const widths = () => config.widths?.() ?? {};
|
|
139
161
|
|
|
162
|
+
// A column head's sort button: it shows on hover, or always while it
|
|
163
|
+
// sorts. A click sorts one way, again the other way, and a third time
|
|
164
|
+
// takes the sort off. A right-click on a sorting head opens a small menu:
|
|
165
|
+
// change the direction, or clear the sort.
|
|
166
|
+
const sorting = config.sorting ?? null;
|
|
167
|
+
function sortButton(column) {
|
|
168
|
+
const by = sorting?.sortOf(column.id);
|
|
169
|
+
if (!by) return null;
|
|
170
|
+
const sorts = sorting.sorts();
|
|
171
|
+
const change = (next) => sorting.change({ sorts: next });
|
|
172
|
+
const at = sorts.findIndex((s) => s.by === by);
|
|
173
|
+
const on = at >= 0 ? sorts[at] : null;
|
|
174
|
+
const [up, down] = sorting.dirWords(by);
|
|
175
|
+
const next = () => {
|
|
176
|
+
if (!on) change([{ by, dir: 'asc' }]);
|
|
177
|
+
else if (on.dir === 'asc') change(sorts.map((s, n) => (n === at ? { ...s, dir: 'desc' } : s)));
|
|
178
|
+
else change(sorts.filter((_, n) => n !== at));
|
|
179
|
+
};
|
|
180
|
+
const flip = () => change(sorts.map((s, n) => (n === at ? { ...s, dir: s.dir === 'asc' ? 'desc' : 'asc' } : s)));
|
|
181
|
+
const words = on ? `Sorted ${on.dir === 'asc' ? up : down}. Click to ${on.dir === 'asc' ? `sort ${down}` : 'clear the sort'}` : `Sort by ${column.label}`;
|
|
182
|
+
return h('button', {
|
|
183
|
+
type: 'button', class: `fx-th-sort${on ? ' fx-th-sort--on' : ''}`, 'aria-label': words, title: words,
|
|
184
|
+
onclick: (event) => { event.stopPropagation(); next(); },
|
|
185
|
+
oncontextmenu: (event) => {
|
|
186
|
+
if (!on) return;
|
|
187
|
+
event.preventDefault();
|
|
188
|
+
event.stopPropagation();
|
|
189
|
+
onRightButton(event, () => menu.show([
|
|
190
|
+
h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitemcheckbox', 'aria-checked': 'true', title: 'Change the direction', onclick: () => { menu.close(); flip(); } },
|
|
191
|
+
h('span', { class: 'fx-pop__tick' }, lucide(h, on.dir === 'asc' ? 'arrow-up' : 'arrow-down')), h('span', {}, `Sort by ${column.label}`), h('span', { class: 'fx-pop__end' }, svg(ICONS.tick))),
|
|
192
|
+
menu.item('ban', 'Clear sort', () => change(sorts.filter((_, n) => n !== at))),
|
|
193
|
+
], event));
|
|
194
|
+
},
|
|
195
|
+
}, lucide(h, on ? (on.dir === 'asc' ? 'arrow-up' : 'arrow-down') : 'chevrons-up-down'),
|
|
196
|
+
on && sorts.length > 1 ? h('span', { class: 'fx-th-sort__n' }, String(at + 1)) : null);
|
|
197
|
+
}
|
|
198
|
+
|
|
140
199
|
function openMenu(column, event) {
|
|
141
200
|
const parts = [];
|
|
142
201
|
if (column) {
|
|
202
|
+
const by = sorting?.sortOf(column.id);
|
|
203
|
+
if (by) {
|
|
204
|
+
const [up, down] = sorting.dirWords(by);
|
|
205
|
+
parts.push(menu.item('arrow-up-narrow-wide', `Sort: ${up}`, () => sorting.change({ sorts: [{ by, dir: 'asc' }] })),
|
|
206
|
+
menu.item('arrow-down-wide-narrow', `Sort: ${down}`, () => sorting.change({ sorts: [{ by, dir: 'desc' }] })), menu.sep());
|
|
207
|
+
}
|
|
143
208
|
parts.push(...(config.extra?.(column, menu.item) ?? []));
|
|
144
209
|
if (!column.fixed) {
|
|
145
210
|
const order = ids();
|
|
@@ -212,7 +277,7 @@ export function columnTable(h, config) {
|
|
|
212
277
|
class: [c.num ? 'fx-num' : null, c.fixed ? null : 'fx-th--drag'].filter(Boolean).join(' ') || null,
|
|
213
278
|
'data-col': c.id,
|
|
214
279
|
oncontextmenu: (event) => { event.preventDefault(); onRightButton(event, () => openMenu(c, event)); },
|
|
215
|
-
}, h('span', { class: 'fx-th-cell' }, button, c
|
|
280
|
+
}, h('span', { class: 'fx-th-cell' }, button, sortButton(c)), grip);
|
|
216
281
|
resizer(grip, c, n);
|
|
217
282
|
if (!c.fixed) dragger(button, th, c);
|
|
218
283
|
return th;
|