@arcforgelabs/flow 2026.10.46 → 2026.10.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/VISION.md +10 -10
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/invoicing.js +89 -22
- package/server/auth/people.js +3 -1
- package/server/auth/routes.js +4 -1
- package/server/core/contacts.js +160 -0
- package/server/core/directory.js +4 -9
- package/server/core/invoice-rules.js +34 -4
- package/server/core/invoices.js +93 -1
- package/server/core/invoicing-rules.js +20 -3
- package/server/core/job-board.js +1 -1
- package/server/core/sending.js +141 -25
- package/server/db/doctor.js +1 -1
- package/server/db/index.js +19 -0
- package/server/db/migrations/0021_flow_schema.sql +68 -0
- package/server/db/migrations/0022_contacts.sql +11 -0
- package/server/db/migrations/meta/0021_snapshot.json +4377 -0
- package/server/db/migrations/meta/0022_snapshot.json +4446 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +15 -0
- package/server/integrations/port.js +2 -0
- package/server/integrations/schema.js +3 -2
- package/server/integrations/sentry/index.js +14 -7
- package/server/integrations/setup.js +1 -0
- package/server/integrations/xero/catalogue.js +82 -14
- package/server/integrations/xero/client.js +37 -0
- package/server/integrations/xero/create-contact.js +126 -0
- package/server/integrations/xero/delete-invoice.js +104 -0
- package/server/integrations/xero/drafts.js +19 -8
- package/server/integrations/xero/index.js +9 -0
- package/server/integrations/xero/migrations/0011_xero_invoice_deleted.sql +11 -0
- package/server/integrations/xero/migrations/0012_xero_contact_details.sql +16 -0
- package/server/integrations/xero/migrations/meta/_journal.json +14 -0
- package/server/integrations/xero/routes.js +21 -0
- package/server/integrations/xero/settings.js +3 -0
- package/server/ui/alerts.js +2 -1
- package/server/ui/app.js +113 -28
- package/server/ui/bulk-bar.js +18 -5
- package/server/ui/columns.js +5 -2
- package/server/ui/contacts.js +260 -0
- package/server/ui/custom-fields.js +22 -7
- package/server/ui/flow.css +275 -24
- package/server/ui/invoicing.js +374 -70
- package/server/ui/job-board.js +10 -0
- package/server/ui/job-change.js +4 -3
- package/server/ui/job-view.js +3 -1
- package/server/ui/jobs.js +150 -19
- package/server/ui/kit.js +220 -45
- package/server/ui/list-views.js +50 -10
- package/server/ui/mobile.js +729 -0
- package/server/ui/monitoring.js +51 -0
- package/server/ui/nav-pins.js +44 -0
- package/server/ui/pager.js +57 -10
- package/server/ui/price-list.js +1 -1
- package/server/ui/price-reconcile.js +12 -12
- package/server/ui/reconcile.js +18 -17
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +2 -1
- package/server/ui/sidecar.js +106 -6
- package/server/ui/view-bar.js +22 -11
- package/server/ui/xero-import.js +48 -19
package/server/ui/monitoring.js
CHANGED
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
// GET /api/monitoring says so and gives the project's public DSN. The SDK
|
|
5
5
|
// comes from Sentry's CDN, pinned and checked against its integrity hash.
|
|
6
6
|
// With the integration off, nothing loads and there is no "Report a problem".
|
|
7
|
+
//
|
|
8
|
+
// Replay (SENTRY_REPLAY, on by default): the SDK keeps the last minute of the
|
|
9
|
+
// page in the browser's memory and sends it only with a report or a browser
|
|
10
|
+
// error, so a report shows the open menu or hover that the click on "Report a
|
|
11
|
+
// problem" closed. Flow shows customers' and patients' names, addresses and
|
|
12
|
+
// prices: a replay keeps the page's shape, the pointer and the clicks, never
|
|
13
|
+
// its words.
|
|
7
14
|
|
|
8
15
|
function loadScript({ src, integrity }) {
|
|
9
16
|
return new Promise((resolve, reject) => {
|
|
@@ -89,6 +96,36 @@ export function scrubSpan(span) {
|
|
|
89
96
|
return span;
|
|
90
97
|
}
|
|
91
98
|
|
|
99
|
+
// Elements a replay never shows: frames, canvases and embedded objects, and
|
|
100
|
+
// links that carry an address, a number or a query.
|
|
101
|
+
const REPLAY_BLOCK = ['iframe', 'canvas', 'embed', 'object', 'a[href^="mailto:" i]', 'a[href^="tel:" i]', 'a[href^="sms:" i]', 'a[href*="?"]'];
|
|
102
|
+
// Attributes that can carry a name; masked like the text.
|
|
103
|
+
const REPLAY_MASKED_ATTRIBUTES = ['title', 'placeholder', 'aria-label', 'aria-description', 'alt', 'download', 'value', 'data-value'];
|
|
104
|
+
// What a replay keeps of a clicked element.
|
|
105
|
+
const KEPT_NODE_ATTRIBUTES = new Set(['id', 'class', 'role']);
|
|
106
|
+
const withoutQuery = (value) => (typeof value === 'string' ? value.replace(/[?#].*$/, '') : value);
|
|
107
|
+
|
|
108
|
+
// One event of a replay's recording, before it is kept. A click names its
|
|
109
|
+
// element by CSS path, and the path can quote an attribute's value: the
|
|
110
|
+
// values go, and of the element only its id, class and role stay. A page's
|
|
111
|
+
// address keeps its path, never its query string.
|
|
112
|
+
export function scrubRecordingEvent(event) {
|
|
113
|
+
const payload = event?.data?.payload;
|
|
114
|
+
if (!payload || typeof payload !== 'object') return event;
|
|
115
|
+
const next = { ...payload };
|
|
116
|
+
if (typeof next.category === 'string' && next.category.startsWith('ui.')) {
|
|
117
|
+
if (typeof next.message === 'string') next.message = next.message.replace(/\[[^\]]*\]/g, '');
|
|
118
|
+
const node = next.data?.node;
|
|
119
|
+
if (node) {
|
|
120
|
+
const attributes = Object.fromEntries(Object.entries(node.attributes ?? {}).filter(([name]) => KEPT_NODE_ATTRIBUTES.has(name)));
|
|
121
|
+
next.data = { ...next.data, node: { ...node, attributes, textContent: '' } };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (next.category === 'navigation' && next.data) next.data = { ...next.data, from: withoutQuery(next.data.from), to: withoutQuery(next.data.to) };
|
|
125
|
+
if (typeof next.description === 'string') next.description = withoutQuery(next.description);
|
|
126
|
+
return { ...event, data: { ...event.data, payload: next } };
|
|
127
|
+
}
|
|
128
|
+
|
|
92
129
|
/** Resolves to a function that opens the "Report a problem" form, or null. */
|
|
93
130
|
export async function startMonitoring({ flow, person }) {
|
|
94
131
|
const config = await flow('/api/monitoring');
|
|
@@ -113,6 +150,17 @@ export async function startMonitoring({ flow, person }) {
|
|
|
113
150
|
submitButtonLabel: 'Send report',
|
|
114
151
|
successMessageText: 'Thank you. Your report is sent.',
|
|
115
152
|
})] : [];
|
|
153
|
+
if (config.replay && Sentry.replayIntegration) {
|
|
154
|
+
integrations.push(Sentry.replayIntegration({
|
|
155
|
+
maskAllText: true,
|
|
156
|
+
maskAllInputs: true,
|
|
157
|
+
blockAllMedia: true,
|
|
158
|
+
maskAttributes: REPLAY_MASKED_ATTRIBUTES,
|
|
159
|
+
block: REPLAY_BLOCK,
|
|
160
|
+
networkDetailAllowUrls: [],
|
|
161
|
+
beforeAddRecordingEvent: scrubRecordingEvent,
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
116
164
|
// Traces: page loads, moves between pages and each click's wait, with the
|
|
117
165
|
// calls to Flow's server joined to the server's own trace. Only when the
|
|
118
166
|
// installation set a share above 0 (SENTRY_TRACES_SAMPLE_RATE).
|
|
@@ -129,6 +177,9 @@ export async function startMonitoring({ flow, person }) {
|
|
|
129
177
|
sendDefaultPii: false,
|
|
130
178
|
integrations,
|
|
131
179
|
tracesSampleRate,
|
|
180
|
+
// Replay: never a whole visit, only the minute before a report or error.
|
|
181
|
+
replaysSessionSampleRate: 0,
|
|
182
|
+
replaysOnErrorSampleRate: config.replay ? 1 : 0,
|
|
132
183
|
beforeSendSpan: scrubSpan,
|
|
133
184
|
initialScope: { tags: config.tags ?? {} },
|
|
134
185
|
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Which sidebar pages a person has pinned. Stored with the account in
|
|
2
|
+
// people.views.nav (PUT /api/me/views/nav). No table of its own.
|
|
3
|
+
// Jobs and Invoices stay pinned: a request that leaves them out is saved
|
|
4
|
+
// with them put back. A page Flow does not know is dropped.
|
|
5
|
+
|
|
6
|
+
export const NAV_PAGES = Object.freeze([
|
|
7
|
+
'jobs',
|
|
8
|
+
'invoices',
|
|
9
|
+
'contacts',
|
|
10
|
+
'reconcile',
|
|
11
|
+
'price-list',
|
|
12
|
+
'invoicing-settings',
|
|
13
|
+
'automations',
|
|
14
|
+
'custom-fields',
|
|
15
|
+
'integrations',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export const NAV_LOCKED = Object.freeze(['jobs', 'invoices']);
|
|
19
|
+
|
|
20
|
+
// Today's sidebar, before a person has chosen: daily work (Jobs, Invoices,
|
|
21
|
+
// Contacts, Reconcile), Price list and Rules. Automations, Custom fields
|
|
22
|
+
// and Integrations start in More.
|
|
23
|
+
export const NAV_DEFAULT = Object.freeze([
|
|
24
|
+
'jobs',
|
|
25
|
+
'invoices',
|
|
26
|
+
'contacts',
|
|
27
|
+
'reconcile',
|
|
28
|
+
'price-list',
|
|
29
|
+
'invoicing-settings',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
export function normaliseNavPins(input) {
|
|
33
|
+
const source = Array.isArray(input?.pinned) ? input.pinned : [];
|
|
34
|
+
const chosen = new Set(NAV_LOCKED);
|
|
35
|
+
for (const name of source) if (NAV_PAGES.includes(name)) chosen.add(name);
|
|
36
|
+
return { pinned: NAV_PAGES.filter((name) => chosen.has(name)) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// No saved choice yet: the default sidebar. A saved choice is checked.
|
|
40
|
+
export function navPinsFromViews(input) {
|
|
41
|
+
const value = input && typeof input === 'object' ? input : {};
|
|
42
|
+
if (!value.nav) return { pinned: [...NAV_DEFAULT] };
|
|
43
|
+
return normaliseNavPins(value.nav);
|
|
44
|
+
}
|
package/server/ui/pager.js
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
|
-
//
|
|
2
|
-
// HubSpot's: rows per page, "1–100 of 1,204", and numbered pages between
|
|
3
|
-
// previous and next. The server cuts the page (GET /api/invoicing/invoices
|
|
4
|
-
// takes offset and limit), so a list works with any number of rows.
|
|
1
|
+
// Two pagers live here:
|
|
5
2
|
//
|
|
6
|
-
// listPager
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
3
|
+
// listPager — the pages of a long list, in the list window's foot bar
|
|
4
|
+
// (listFrame), as HubSpot's: rows per page, "1–100 of 1,204", and numbered
|
|
5
|
+
// pages between previous and next. The server cuts the page
|
|
6
|
+
// (GET /api/invoicing/invoices takes offset and limit), so a list works with
|
|
7
|
+
// any number of rows. listPager(h, { label, sizes, change }) answers
|
|
8
|
+
// { element, draw(page) }. page = { offset, limit, total }.
|
|
9
|
+
// change({ offset, limit }) asks for a page; a new size starts again at the
|
|
10
|
+
// first page. One page or none hides the page numbers; the size stays, so a
|
|
11
|
+
// person can show more per page.
|
|
12
|
+
//
|
|
13
|
+
// pager — ".fx-pager" over one record opened from a list: "← Back", "n of N",
|
|
14
|
+
// previous and next. pager(h, { label, back, … }) answers { element, draw }.
|
|
15
|
+
// draw({ count, prev, next, prevLabel, nextLabel }) fills the count and
|
|
16
|
+
// steps; omit draw (or draw with steps: false) for Back only, as when an
|
|
17
|
+
// invoice is not yet in the list. Invoices, Reconcile and Price list ›
|
|
18
|
+
// Reconcile use it (issue #220).
|
|
19
|
+
|
|
20
|
+
import { applyPhoneRule } from './mobile.js';
|
|
11
21
|
|
|
12
22
|
export const PAGE_SIZES = Object.freeze([25, 50, 100, 200]);
|
|
13
23
|
|
|
@@ -16,6 +26,43 @@ const PREV = chevron('M14.5 6l-6 6 6 6');
|
|
|
16
26
|
const NEXT = chevron('M9.5 6l6 6-6 6');
|
|
17
27
|
const count = new Intl.NumberFormat('en-AU');
|
|
18
28
|
|
|
29
|
+
// The bar over one record: Back, optional "n of N", previous and next.
|
|
30
|
+
export function pager(h, { label = 'Records', back }) {
|
|
31
|
+
const backBtn = h('button', { class: 'fx-pager__back', type: 'button', onclick: back }, '← Back');
|
|
32
|
+
const element = h('nav', { class: 'fx-pager', 'aria-label': label }, backBtn);
|
|
33
|
+
|
|
34
|
+
function draw({
|
|
35
|
+
count: text = '',
|
|
36
|
+
prev = null,
|
|
37
|
+
next = null,
|
|
38
|
+
prevLabel = 'Previous',
|
|
39
|
+
nextLabel = 'Next',
|
|
40
|
+
steps = true,
|
|
41
|
+
} = {}) {
|
|
42
|
+
if (!steps) {
|
|
43
|
+
element.replaceChildren(backBtn);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const step = (go, name, markup) => {
|
|
47
|
+
const button = h('button', {
|
|
48
|
+
class: 'fx-pager__step', type: 'button',
|
|
49
|
+
'aria-label': name, title: name,
|
|
50
|
+
...(!go ? { disabled: true } : { onclick: go }),
|
|
51
|
+
});
|
|
52
|
+
button.innerHTML = markup;
|
|
53
|
+
return button;
|
|
54
|
+
};
|
|
55
|
+
element.replaceChildren(
|
|
56
|
+
backBtn,
|
|
57
|
+
h('span', { class: 'fx-pager__count' }, text),
|
|
58
|
+
h('span', { class: 'fx-pager__steps' },
|
|
59
|
+
step(prev, prevLabel, PREV),
|
|
60
|
+
step(next, nextLabel, NEXT)));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { element, draw };
|
|
64
|
+
}
|
|
65
|
+
|
|
19
66
|
// The page numbers to show: the first, the last, and two each side of the
|
|
20
67
|
// one shown, with '…' for a gap. Pages count from 1.
|
|
21
68
|
export function pageNumbers(at, pages) {
|
|
@@ -34,7 +81,7 @@ export function pageRange({ offset, limit, total }) {
|
|
|
34
81
|
}
|
|
35
82
|
|
|
36
83
|
export function listPager(h, { label = 'Pages', sizes = PAGE_SIZES, change }) {
|
|
37
|
-
const element = h('nav', { class: 'fx-pages', 'aria-label': label });
|
|
84
|
+
const element = applyPhoneRule(h('nav', { class: 'fx-pages', 'aria-label': label }), 'listPager');
|
|
38
85
|
const icon = (markup) => { const span = h('span', { 'aria-hidden': 'true' }); span.innerHTML = markup; return span; };
|
|
39
86
|
|
|
40
87
|
function draw({ offset = 0, limit = sizes[0], total = 0 }) {
|
package/server/ui/price-list.js
CHANGED
|
@@ -22,7 +22,7 @@ const LINE_PRICES = [
|
|
|
22
22
|
// person's account; + shows a hidden one again.
|
|
23
23
|
const COLUMN_DEFS = {
|
|
24
24
|
code: { kind: 'number', className: 'fx-price__code', cell: (item) => item.code },
|
|
25
|
-
name: { kind: 'client', cell: (item) => item.name },
|
|
25
|
+
name: { kind: 'client', phone: 'title', cell: (item) => item.name },
|
|
26
26
|
price: { kind: 'total', num: true, cell: (item) => money(item.unitAmountCents) },
|
|
27
27
|
tax: { kind: 'case', className: 'fx-dim', cell: (item) => TAX_LABELS[item.taxCode] ?? item.taxCode },
|
|
28
28
|
};
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// The price check itself runs on each pipeline pass, so there is no button.
|
|
14
14
|
import { bulkBar } from '/assets/ui/bulk-bar.js';
|
|
15
15
|
import { lucide } from '/assets/ui/custom-fields.js';
|
|
16
|
+
import { pager as recordPager } from '/assets/ui/pager.js';
|
|
16
17
|
import { findCodes, nameScore, reconcileRows, SAME_NAME } from '/assets/ui/price-match.js';
|
|
17
18
|
|
|
18
19
|
const TAX_LABELS = { GST: 'GST', GST_FREE: 'GST free' };
|
|
@@ -166,18 +167,17 @@ export async function renderPriceReconcile(main, ui) {
|
|
|
166
167
|
|
|
167
168
|
// The invoice pager: Back to the price list, "n of N", previous and next.
|
|
168
169
|
function pager() {
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
h('span', { class: 'fx-pager__steps' }, step(at - 1, 'Previous', 'M14.5 6l-6 6 6 6'), step(at + 1, 'Next', 'M9.5 6l6 6-6 6')));
|
|
170
|
+
const go = (to) => () => { at = to; pick = 0; chosen = null; drawCard(); };
|
|
171
|
+
const { element, draw } = recordPager(h, {
|
|
172
|
+
label: 'Records to check',
|
|
173
|
+
back: () => ui.go('/price-list'),
|
|
174
|
+
});
|
|
175
|
+
draw({
|
|
176
|
+
count: `${at + 1} of ${rows.length}`,
|
|
177
|
+
prev: at > 0 ? go(at - 1) : null,
|
|
178
|
+
next: at < rows.length - 1 ? go(at + 1) : null,
|
|
179
|
+
});
|
|
180
|
+
return element;
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
// One record (or one differing code) at a time.
|
package/server/ui/reconcile.js
CHANGED
|
@@ -22,6 +22,7 @@ import { statusRank } from '/assets/ui/job-board.js';
|
|
|
22
22
|
import { filterJobs, KIND_OF_TYPE, normaliseListView, RECONCILE_COLUMNS } from '/assets/ui/job-view.js';
|
|
23
23
|
import { normaliseReconcileView, RECONCILE_FILTER_FIELDS } from '/assets/ui/list-views.js';
|
|
24
24
|
import { savedViews } from '/assets/ui/saved-views.js';
|
|
25
|
+
import { pager } from '/assets/ui/pager.js';
|
|
25
26
|
import { findBox, today, viewBar } from '/assets/ui/view-bar.js';
|
|
26
27
|
import { formatCents } from '/assets/ui/invoicing.js';
|
|
27
28
|
|
|
@@ -95,7 +96,6 @@ function jobCandidateFacts(job) {
|
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
|
|
98
|
-
const CHEVRONS = { prev: 'M14.5 6l-6 6 6 6', next: 'M9.5 6l6 6-6 6' };
|
|
99
99
|
// The list's filter rows (view-bar.js), kept per viewer in this browser.
|
|
100
100
|
// The list a person opened a gap from, for the pager.
|
|
101
101
|
const FILTER_KEY = 'flow_reconcile_filters';
|
|
@@ -306,12 +306,12 @@ async function renderList(main, ui) {
|
|
|
306
306
|
? h('span', { class: 'fx-rec__possible', 'data-state': 'some' }, icon('link-2', 'Possible match'), String(r.possible))
|
|
307
307
|
: h('span', { class: 'fx-rec__possible' }, icon('circle-dashed', 'No possible match')));
|
|
308
308
|
const ALL = {
|
|
309
|
-
kind: { kind: 'icon', icon: 'layers', className: 'fx-rec-list__kind', cell: (r) => icon(...KIND_ICON[r.kind]) },
|
|
310
|
-
client: { kind: 'client', className: 'fx-strong', cell: (r) => r.client ?? h('span', { class: 'fx-dim' }, 'No client') },
|
|
311
|
-
detail: { kind: 'text', cell: (r) => h('span', { class: r.kind === 'invoice' ? 'fx-num' : 'fx-dim' }, r.who ?? '—', r.kind === 'invoice' ? h('span', { class: 'fx-dim' }, ` · ${statusWords(r.status)}`) : null) },
|
|
312
|
-
date: { kind: 'date', className: 'fx-dim', cell: (r) => shortDate(r.date) },
|
|
313
|
-
total: { kind: 'total', num: true, cell: (r) => money(r.totalCents) },
|
|
314
|
-
possible: { kind: 'status', className: 'fx-rec-list__mid', cell: possible },
|
|
309
|
+
kind: { kind: 'icon', phone: 'meta', icon: 'layers', className: 'fx-rec-list__kind', cell: (r) => icon(...KIND_ICON[r.kind]) },
|
|
310
|
+
client: { kind: 'client', phone: 'title', className: 'fx-strong', cell: (r) => r.client ?? h('span', { class: 'fx-dim' }, 'No client') },
|
|
311
|
+
detail: { kind: 'text', phone: 'meta', cell: (r) => h('span', { class: r.kind === 'invoice' ? 'fx-num' : 'fx-dim' }, r.who ?? '—', r.kind === 'invoice' ? h('span', { class: 'fx-dim' }, ` · ${statusWords(r.status)}`) : null) },
|
|
312
|
+
date: { kind: 'date', phone: 'hide', className: 'fx-dim', cell: (r) => shortDate(r.date) },
|
|
313
|
+
total: { kind: 'total', phone: 'end', num: true, cell: (r) => money(r.totalCents) },
|
|
314
|
+
possible: { kind: 'status', phone: 'meta', className: 'fx-rec-list__mid', cell: possible },
|
|
315
315
|
};
|
|
316
316
|
const columns = state.columns.columns.map((id) => ({ id, label: RECONCILE_COLUMNS[id], ...ALL[id] }));
|
|
317
317
|
host.replaceChildren(h('div', { class: 'fx-panel' }, heads.table({
|
|
@@ -351,15 +351,16 @@ async function renderGap(main, ui, id) {
|
|
|
351
351
|
order = lists ? reconcileRows(lists).filter((r) => r.kind === kind).map(({ kind: k, id: i }) => ({ kind: k, id: i })) : [];
|
|
352
352
|
}
|
|
353
353
|
const at = order.findIndex((r) => r.id === id);
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
354
|
+
const { element: pagerBar, draw: drawPager } = pager(h, {
|
|
355
|
+
label: 'Reconcile',
|
|
356
|
+
back: () => ui.go('/reconcile'),
|
|
357
|
+
});
|
|
358
|
+
const goRow = (row) => (row ? () => ui.go(pathOf(row)) : null);
|
|
359
|
+
drawPager({
|
|
360
|
+
count: at >= 0 ? `${at + 1} of ${order.length}` : '',
|
|
361
|
+
prev: goRow(at > 0 ? order[at - 1] : null),
|
|
362
|
+
next: goRow(at >= 0 ? order[at + 1] : null),
|
|
363
|
+
});
|
|
363
364
|
const next = () => {
|
|
364
365
|
const rest = order.filter((r) => r.id !== id);
|
|
365
366
|
writeStored(sessionStorage, NAV_KEY, { ...readStored(sessionStorage, NAV_KEY, {}), rows: rest });
|
|
@@ -380,7 +381,7 @@ async function renderGap(main, ui, id) {
|
|
|
380
381
|
},
|
|
381
382
|
});
|
|
382
383
|
const card = h('section', { class: 'fx-rec fx-rec__gap', 'aria-label': 'Compare' },
|
|
383
|
-
|
|
384
|
+
pagerBar,
|
|
384
385
|
h('header', { class: 'fx-ph' }, h('div', { class: 'fx-ph__text' }, title, sub), actions),
|
|
385
386
|
// The search sits where it does on Jobs and Invoices: the right of the
|
|
386
387
|
// toolbar over the rows it searches.
|
package/server/ui/routes.js
CHANGED
|
@@ -21,7 +21,7 @@ const themeDir = join(here, '..', 'theme');
|
|
|
21
21
|
const logoDir = join(here, 'logos');
|
|
22
22
|
const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
|
|
23
23
|
const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
|
|
24
|
-
export const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'sidecar.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'xero-usage.js', 'xero-usage-model.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'person.js', 'pager.js', 'flow-mark.svg']);
|
|
24
|
+
export const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'sidecar.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'contacts.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'xero-usage.js', 'xero-usage-model.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'mobile.js', 'person.js', 'pager.js', 'nav-pins.js', 'flow-mark.svg']);
|
|
25
25
|
const iconDir = join(dirname(require.resolve('lucide-static/package.json')), 'icons');
|
|
26
26
|
const ICON_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/;
|
|
27
27
|
const MAX_AGE = '1h';
|
package/server/ui/saved-views.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { lucide } from '/assets/ui/custom-fields.js';
|
|
15
15
|
import { rowMenu } from '/assets/ui/kit.js';
|
|
16
16
|
import { MAX_SAVED_VIEWS, normaliseSavedViews, sameView } from '/assets/ui/list-views.js';
|
|
17
|
+
import { applyPhoneRule } from './mobile.js';
|
|
17
18
|
|
|
18
19
|
const ACTIVE_KEY = (page) => `flow_view_tab_${page}`;
|
|
19
20
|
|
|
@@ -33,7 +34,7 @@ export function savedViews(ui, { page, current, apply }) {
|
|
|
33
34
|
const { h, flow, dialog, toast } = ui;
|
|
34
35
|
let saved = normaliseSavedViews(page, ui.me?.views?.saved?.[page]);
|
|
35
36
|
let active = null;
|
|
36
|
-
const element = h('div', { class: 'fx-views', role: 'tablist', 'aria-label': 'Views' });
|
|
37
|
+
const element = applyPhoneRule(h('div', { class: 'fx-views', role: 'tablist', 'aria-label': 'Views' }), 'savedViews');
|
|
37
38
|
const chosen = () => saved.list.find((v) => v.id === active) ?? null;
|
|
38
39
|
|
|
39
40
|
async function store(next) {
|
package/server/ui/sidecar.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// The sidecar, as in ClickUp: a panel at the right of a page or a dialog.
|
|
2
2
|
// The Custom fields manager opens it over its table (`overlay`); a job's
|
|
3
|
-
// panel keeps it as its right column, with tabs (Activity,
|
|
4
|
-
// it takes no room, so the main area gets the full width.
|
|
3
|
+
// panel keeps it as its right column, with tabs (Activity, Fix). Closed,
|
|
4
|
+
// it takes no room, so the main area gets the full width. `phone: 'page'`
|
|
5
|
+
// opens that panel as a full page on a phone: the tabs sit at the top of
|
|
6
|
+
// the record and the record bar (`recordBar`) carries back, the title and
|
|
7
|
+
// the next record. The main action stays in the caller's foot.
|
|
5
8
|
//
|
|
6
|
-
// sidecar(h, { label, overlay, escape, tabs, onChange }) answers
|
|
9
|
+
// sidecar(h, { label, overlay, escape, tabs, onChange, phone }) answers
|
|
7
10
|
// { element, isOpen, open, show, close, tab, setTabs }:
|
|
8
11
|
// open(id) selects tab `id` (or the first) and draws its `render()`
|
|
9
12
|
// show(node) draws one node with no tabs (the overlay's own form)
|
|
@@ -13,14 +16,34 @@
|
|
|
13
16
|
// `onChange(id)` hears each open (the tab id) and close (null).
|
|
14
17
|
|
|
15
18
|
import { lucide } from '/assets/ui/custom-fields.js';
|
|
19
|
+
import { applyPhoneRule, isPhone, WIDTHS } from './mobile.js';
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
const CHEVRON_LEFT = 'M14.5 6l-6 6 6 6';
|
|
22
|
+
const CHEVRON_RIGHT = 'M9.5 6l6 6-6 6';
|
|
23
|
+
|
|
24
|
+
function glyph(button, path) {
|
|
25
|
+
button.innerHTML = `<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="${path}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
26
|
+
return button;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function sidecar(h, { label, overlay = false, escape = false, tabs = [], onChange = null, phone = null } = {}) {
|
|
30
|
+
const element = applyPhoneRule(h('aside', { class: `fx-sidecar${overlay ? ' fx-sidecar--over' : ''}`, 'aria-label': label, hidden: true }), 'sidecar');
|
|
19
31
|
let list = tabs;
|
|
20
32
|
let current = null;
|
|
33
|
+
let drawn = false;
|
|
34
|
+
if (phone === 'page') element.dataset.phone = 'page';
|
|
21
35
|
|
|
22
36
|
const closeButton = () => h('button', { class: 'fx-sidecar__close', type: 'button', 'aria-label': 'Close', title: 'Close', onclick: () => api.close() }, lucide(h, 'x'));
|
|
23
37
|
|
|
38
|
+
// On a phone page the tab strip is the record's top tabs, so it lives on
|
|
39
|
+
// the record (the parent) and stays put while the record scrolls. The
|
|
40
|
+
// body stays in the sidecar, which is what the phone suite measures.
|
|
41
|
+
function parkHead(head) {
|
|
42
|
+
const grid = element.parentElement;
|
|
43
|
+
if (element.classList.contains('fx-sidecar--page') && grid) grid.prepend(head);
|
|
44
|
+
else element.prepend(head);
|
|
45
|
+
}
|
|
46
|
+
|
|
24
47
|
function drawTabs() {
|
|
25
48
|
const head = h('header', { class: 'fx-sidecar__head' },
|
|
26
49
|
h('div', { class: 'fx-sidecar__tabs', role: 'tablist', 'aria-label': label },
|
|
@@ -30,7 +53,10 @@ export function sidecar(h, { label, overlay = false, escape = false, tabs = [],
|
|
|
30
53
|
}, lucide(h, t.icon), h('span', {}, t.label), t.mark ? h('span', { class: 'fx-sidecar__mark', 'aria-hidden': 'true' }) : null))),
|
|
31
54
|
closeButton());
|
|
32
55
|
const tab = list.find((t) => t.id === current);
|
|
33
|
-
|
|
56
|
+
const body = h('div', { class: 'fx-sidecar__body', role: 'tabpanel' }, tab?.render?.() ?? null);
|
|
57
|
+
element.parentElement?.querySelector(':scope > .fx-sidecar__head')?.remove();
|
|
58
|
+
element.replaceChildren(body);
|
|
59
|
+
parkHead(head);
|
|
34
60
|
}
|
|
35
61
|
|
|
36
62
|
const api = {
|
|
@@ -41,18 +67,27 @@ export function sidecar(h, { label, overlay = false, escape = false, tabs = [],
|
|
|
41
67
|
current = list.find((t) => t.id === id)?.id ?? list[0]?.id ?? null;
|
|
42
68
|
drawTabs();
|
|
43
69
|
element.hidden = false;
|
|
70
|
+
// The first open leaves the record where it is. A later tab brings
|
|
71
|
+
// its panel up under the sticky tabs.
|
|
72
|
+
if (drawn && element.classList.contains('fx-sidecar--page')) {
|
|
73
|
+
element.querySelector('.fx-sidecar__body')?.scrollIntoView({ block: 'start' });
|
|
74
|
+
}
|
|
75
|
+
drawn = true;
|
|
44
76
|
onChange?.(current);
|
|
45
77
|
},
|
|
46
78
|
show(node) {
|
|
47
79
|
current = null;
|
|
80
|
+
element.parentElement?.querySelector(':scope > .fx-sidecar__head')?.remove();
|
|
48
81
|
element.replaceChildren(node);
|
|
49
82
|
element.hidden = false;
|
|
50
83
|
},
|
|
51
84
|
close() {
|
|
52
85
|
if (element.hidden) return;
|
|
53
86
|
element.hidden = true;
|
|
87
|
+
element.parentElement?.querySelector(':scope > .fx-sidecar__head')?.remove();
|
|
54
88
|
element.replaceChildren();
|
|
55
89
|
current = null;
|
|
90
|
+
drawn = false;
|
|
56
91
|
onChange?.(null);
|
|
57
92
|
},
|
|
58
93
|
// New tabs (a tab's data came in): an open tab draws again.
|
|
@@ -63,6 +98,15 @@ export function sidecar(h, { label, overlay = false, escape = false, tabs = [],
|
|
|
63
98
|
closeButton,
|
|
64
99
|
};
|
|
65
100
|
|
|
101
|
+
if (phone === 'page') {
|
|
102
|
+
const sync = () => {
|
|
103
|
+
element.classList.toggle('fx-sidecar--page', isPhone());
|
|
104
|
+
if (!element.hidden && current) drawTabs();
|
|
105
|
+
};
|
|
106
|
+
sync();
|
|
107
|
+
if (typeof matchMedia === 'function') matchMedia(WIDTHS.phone).addEventListener('change', sync);
|
|
108
|
+
}
|
|
109
|
+
|
|
66
110
|
if (escape) {
|
|
67
111
|
document.addEventListener('keydown', (event) => {
|
|
68
112
|
if (event.key === 'Escape' && element.isConnected && !element.hidden && !document.querySelector('dialog[open], .fx-pop:popover-open')) api.close();
|
|
@@ -70,3 +114,59 @@ export function sidecar(h, { label, overlay = false, escape = false, tabs = [],
|
|
|
70
114
|
}
|
|
71
115
|
return api;
|
|
72
116
|
}
|
|
117
|
+
|
|
118
|
+
// The bar at the top of a record on a phone: ← back, the title, ‹ › for the
|
|
119
|
+
// previous and next record, and an optional ⋯ menu. Hidden above phone
|
|
120
|
+
// width (flow.css), so a desk record keeps its own bar. `prev` and `next`
|
|
121
|
+
// are { label, disabled, onclick }. `setSteps` and `setMenu` update them
|
|
122
|
+
// after the list arrives.
|
|
123
|
+
export function recordBar(h, { title = '', onBack, prev = null, next = null, menu = null, label = 'Record' } = {}) {
|
|
124
|
+
const back = glyph(h('button', { class: 'fx-record__back', type: 'button', 'aria-label': 'Back', title: 'Back', onclick: onBack }), CHEVRON_LEFT);
|
|
125
|
+
const titleNode = h('h2', { class: 'fx-record__title', title }, title);
|
|
126
|
+
const step = (path) => glyph(h('button', { class: 'fx-record__step', type: 'button' }), path);
|
|
127
|
+
const prevBtn = step(CHEVRON_LEFT);
|
|
128
|
+
const nextBtn = step(CHEVRON_RIGHT);
|
|
129
|
+
const end = h('div', { class: 'fx-record__end' }, prevBtn, nextBtn);
|
|
130
|
+
const apply = (button, spec, fallback) => {
|
|
131
|
+
const name = spec?.label ?? fallback;
|
|
132
|
+
button.setAttribute('aria-label', name);
|
|
133
|
+
button.title = name;
|
|
134
|
+
button.disabled = !spec || Boolean(spec.disabled);
|
|
135
|
+
button.onclick = spec?.onclick ?? null;
|
|
136
|
+
};
|
|
137
|
+
apply(prevBtn, prev, 'Previous');
|
|
138
|
+
apply(nextBtn, next, 'Next');
|
|
139
|
+
const element = h('header', { class: 'fx-record', 'aria-label': label }, back, titleNode, end);
|
|
140
|
+
element.setSteps = (nextPrev, nextNext) => {
|
|
141
|
+
apply(prevBtn, nextPrev, 'Previous');
|
|
142
|
+
apply(nextBtn, nextNext, 'Next');
|
|
143
|
+
};
|
|
144
|
+
element.setMenu = (node) => {
|
|
145
|
+
end.querySelector('.fx-record__menu')?.remove();
|
|
146
|
+
if (node) end.append(node);
|
|
147
|
+
};
|
|
148
|
+
if (menu) element.setMenu(menu);
|
|
149
|
+
return element;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Keeps `pick()` in `foot` while the window is a phone, and back in `home`
|
|
153
|
+
// (the header's actions) otherwise. Returns the function to call again when
|
|
154
|
+
// the primary action changes (Save appears, a send arrives).
|
|
155
|
+
export function watchPhonePrimary(home, foot, pick) {
|
|
156
|
+
let parked = null;
|
|
157
|
+
const apply = () => {
|
|
158
|
+
const next = isPhone() ? (pick() ?? null) : null;
|
|
159
|
+
if (parked && parked !== next && home) home.append(parked);
|
|
160
|
+
parked = next;
|
|
161
|
+
if (parked) {
|
|
162
|
+
foot.hidden = false;
|
|
163
|
+
if (parked.parentElement !== foot) foot.replaceChildren(parked);
|
|
164
|
+
} else {
|
|
165
|
+
foot.replaceChildren();
|
|
166
|
+
foot.hidden = true;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
apply();
|
|
170
|
+
if (typeof matchMedia === 'function') matchMedia(WIDTHS.phone).addEventListener('change', apply);
|
|
171
|
+
return apply;
|
|
172
|
+
}
|
package/server/ui/view-bar.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// first the field a new row starts on
|
|
23
23
|
// empty the words when no filter is on
|
|
24
24
|
import { lucide } from '/assets/ui/custom-fields.js';
|
|
25
|
+
import { applyPhoneRule } from './mobile.js';
|
|
25
26
|
import { DATE_VALUES, dateValueIsOn, filterCount, KIND_ICONS, OPERATORS } from '/assets/ui/job-view.js';
|
|
26
27
|
|
|
27
28
|
const DAY_BETWEEN = /^between:(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/;
|
|
@@ -59,17 +60,22 @@ export function viewBar(h) {
|
|
|
59
60
|
const panel = h('div', { class: `fx-pop${rows ? ' fx-pop--rows' : ''}`, id, popover: 'auto', role: rows ? 'dialog' : 'menu' });
|
|
60
61
|
const button = h('button', { type: 'button', class: 'fx-chip', popovertarget: id, 'aria-haspopup': rows ? 'dialog' : 'menu', 'aria-expanded': 'false' });
|
|
61
62
|
const clear = h('button', { type: 'button', class: 'fx-chip__clear', 'aria-label': clearLabel, title: clearLabel, hidden: true }, lucide(h, 'x'));
|
|
62
|
-
const wrap = h('span', { class: 'fx-chipset' }, button, clear);
|
|
63
|
+
const wrap = applyPhoneRule(h('span', { class: 'fx-chipset' }, button, clear), 'viewBar');
|
|
63
64
|
let onClear = null;
|
|
64
65
|
clear.addEventListener('click', () => onClear?.());
|
|
65
|
-
|
|
66
|
+
// Place again after the rows draw. A filter row is wider than the empty
|
|
67
|
+
// panel, and the panel is measured once it is open; without this a button
|
|
68
|
+
// at the right (Invoices) leaves the value and the bin off the screen.
|
|
69
|
+
const placeMenu = () => { if (panel.matches(':popover-open')) place(button, panel, 6); };
|
|
70
|
+
const fill = () => {
|
|
71
|
+
panel.replaceChildren(...build(fill, () => panel.hidePopover()).filter(Boolean));
|
|
72
|
+
placeMenu();
|
|
73
|
+
requestAnimationFrame(placeMenu);
|
|
74
|
+
};
|
|
66
75
|
panel.addEventListener('toggle', (event) => {
|
|
67
76
|
button.setAttribute('aria-expanded', String(event.newState === 'open'));
|
|
68
77
|
if (event.newState !== 'open') return;
|
|
69
78
|
fill();
|
|
70
|
-
const at = button.getBoundingClientRect();
|
|
71
|
-
panel.style.top = `${Math.round(at.bottom + 6)}px`;
|
|
72
|
-
panel.style.left = `${Math.round(Math.max(8, Math.min(at.left, innerWidth - panel.offsetWidth - 8)))}px`;
|
|
73
79
|
});
|
|
74
80
|
return {
|
|
75
81
|
node: [wrap, panel],
|
|
@@ -144,11 +150,16 @@ export function viewBar(h) {
|
|
|
144
150
|
});
|
|
145
151
|
return [button, pop];
|
|
146
152
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
153
|
+
// Keep a popover on the screen: at the control's left edge, shifted left
|
|
154
|
+
// when it would run off the right. Called again after a filter row draws,
|
|
155
|
+
// because that row is wider than the empty panel.
|
|
156
|
+
function place(anchor, pop, gap = 4) {
|
|
157
|
+
const at = anchor.getBoundingClientRect();
|
|
158
|
+
const width = pop.offsetWidth;
|
|
159
|
+
const left = Math.max(8, Math.min(at.left, innerWidth - width - 8));
|
|
160
|
+
pop.style.top = `${Math.round(at.bottom + gap)}px`;
|
|
161
|
+
pop.style.left = `${Math.round(left)}px`;
|
|
162
|
+
}
|
|
152
163
|
|
|
153
164
|
// ── Filters ──
|
|
154
165
|
// As ClickUp's: groups of rows of field, operator and value, with a bin on
|
|
@@ -328,7 +339,7 @@ export function viewBar(h) {
|
|
|
328
339
|
export function findBox(h, { label, value = '', oninput }) {
|
|
329
340
|
const input = h('input', { class: 'fx-input fx-input--sm fx-find__input', type: 'search', placeholder: label, 'aria-label': label, value });
|
|
330
341
|
const button = h('button', { type: 'button', class: 'fx-chip fx-find__button', 'aria-label': label, title: label }, lucide(h, 'search'));
|
|
331
|
-
const element = h('span', { class: `fx-find${value ? ' fx-find--open' : ''}` }, button, input);
|
|
342
|
+
const element = applyPhoneRule(h('span', { class: `fx-find${value ? ' fx-find--open' : ''}` }, button, input), 'findBox');
|
|
332
343
|
button.addEventListener('click', () => { element.classList.add('fx-find--open'); input.focus(); });
|
|
333
344
|
input.addEventListener('input', () => oninput(input.value));
|
|
334
345
|
input.addEventListener('blur', () => { if (!input.value) element.classList.remove('fx-find--open'); });
|