@arcforgelabs/flow 2026.10.42 → 2026.10.43
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/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/integrations/sentry/index.js +9 -1
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +74 -29
- package/server/ui/automations.js +9 -3
- package/server/ui/custom-fields.js +17 -16
- package/server/ui/flow.css +77 -37
- package/server/ui/invoice-notes.js +7 -5
- package/server/ui/invoicing-settings.js +69 -35
- package/server/ui/invoicing.js +16 -50
- package/server/ui/job-board.js +20 -8
- package/server/ui/job-change.js +36 -0
- package/server/ui/jobs.js +88 -32
- package/server/ui/monitoring.js +1 -0
- package/server/ui/price-list.js +2 -3
- package/server/ui/price-reconcile.js +7 -8
- package/server/ui/routes.js +1 -1
- package/server/ui/sidecar.js +72 -0
- package/server/ui/tip.js +99 -0
- package/server/ui/xero-import.js +7 -10
package/server/ui/jobs.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { bulkBar } from '/assets/ui/bulk-bar.js';
|
|
16
16
|
import { lucide } from '/assets/ui/custom-fields.js';
|
|
17
17
|
import { formatCents } from '/assets/ui/invoicing.js';
|
|
18
|
+
import { jobChangeReview } from '/assets/ui/job-change.js';
|
|
18
19
|
import {
|
|
19
20
|
caseNumber, FINAL_RANK, fixes, INVOICE_STAGES, invoiceStage, matches, NO_STATUS, orderStatuses, problemSummary, statusRank,
|
|
20
21
|
} from '/assets/ui/job-board.js';
|
|
@@ -22,10 +23,14 @@ import {
|
|
|
22
23
|
COLUMNS, DEFAULT_VIEW, fieldGroup, fieldIdOf, fieldLanes, FILTER_FIELDS, filterJobs, groupJobs, GROUPS, KIND_OF_TYPE, keptStatuses, laneOf,
|
|
23
24
|
normaliseJobsView, NOT_SET, OPERATORS, sortJobs, SORTS,
|
|
24
25
|
} from '/assets/ui/job-view.js';
|
|
26
|
+
import { sidecar } from '/assets/ui/sidecar.js';
|
|
25
27
|
import { findBox, ICONS, svg, today, viewBar } from '/assets/ui/view-bar.js';
|
|
26
28
|
|
|
27
29
|
const LAYOUT_KEY = 'flow_jobs_layout';
|
|
28
30
|
const FOLDED_KEY = 'flow_jobs_folded';
|
|
31
|
+
// The job panel's sidecar: the tab last open, or closed.
|
|
32
|
+
const SIDE_KEY = 'flow_job_side';
|
|
33
|
+
const SIDE_TABS = ['activity', 'review', 'closed'];
|
|
29
34
|
const SYSTEM_NAME = { seazona: 'Seazona', xero: 'Xero' };
|
|
30
35
|
// A job's events (server/core/ledger.js), in plain words.
|
|
31
36
|
const ACTIVITY = {
|
|
@@ -85,6 +90,17 @@ function saveFolded(folded) {
|
|
|
85
90
|
try { localStorage.setItem(FOLDED_KEY, JSON.stringify(folded)); } catch { /* a convenience only */ }
|
|
86
91
|
}
|
|
87
92
|
|
|
93
|
+
function readSide() {
|
|
94
|
+
try {
|
|
95
|
+
const value = localStorage.getItem(SIDE_KEY);
|
|
96
|
+
return SIDE_TABS.includes(value) ? value : 'activity';
|
|
97
|
+
} catch { return 'activity'; }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function saveSide(tab) {
|
|
101
|
+
try { localStorage.setItem(SIDE_KEY, tab ?? 'closed'); } catch { /* a convenience only */ }
|
|
102
|
+
}
|
|
103
|
+
|
|
88
104
|
|
|
89
105
|
const WARN_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.5 15 14H1z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M8 6v3.6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="8" cy="11.6" r=".9" fill="currentColor"/></svg>';
|
|
90
106
|
const PERSON_ICON = '<svg width="13" height="13" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="5.5" r="3" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M2.5 14c.6-2.8 2.8-4.3 5.5-4.3s4.9 1.5 5.5 4.3" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>';
|
|
@@ -116,9 +132,8 @@ export async function renderJobs(main, ui) {
|
|
|
116
132
|
const controls = h('div', { class: 'fx-viewbar', role: 'group', 'aria-label': 'Group, sort and filter' });
|
|
117
133
|
// In the list, ticked rows get the same floating bar as Invoices.
|
|
118
134
|
const bulk = bulkBar(ui, { label: 'Ticked jobs' });
|
|
119
|
-
main.classList.add('fx-main--wide');
|
|
120
135
|
main.append(
|
|
121
|
-
pageHeader('Jobs'
|
|
136
|
+
pageHeader('Jobs'),
|
|
122
137
|
h('div', { class: 'fx-toolbar fx-toolbar--plain' }, controls, h('div', { class: 'fx-toolbar__end' }, find.element, toggle)),
|
|
123
138
|
host,
|
|
124
139
|
bulk.element);
|
|
@@ -1086,25 +1101,8 @@ export async function renderJobs(main, ui) {
|
|
|
1086
1101
|
if (!o) return [];
|
|
1087
1102
|
if (o.invoiceId) {
|
|
1088
1103
|
const label = o.invoiceStatus === 'draft' ? 'Open draft invoice' : `Open invoice ${o.invoiceNumber ?? ''}`.trim();
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
// puts the job's new lines on Flow's own draft. Nothing goes elsewhere.
|
|
1092
|
-
if (!o.canUseNewLines) return [open];
|
|
1093
|
-
const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
|
|
1094
|
-
return [h('button', {
|
|
1095
|
-
class: 'fx-btn fx-btn--primary', type: 'button',
|
|
1096
|
-
onclick: async (event) => {
|
|
1097
|
-
event.currentTarget.disabled = true;
|
|
1098
|
-
try {
|
|
1099
|
-
await flow(`/api/invoicing/jobs/${o.id}/use-new-lines`, { method: 'POST', body: '{}' });
|
|
1100
|
-
ui.toast(`The draft invoice now matches ${system}.`, 'success');
|
|
1101
|
-
} catch (error) {
|
|
1102
|
-
ui.toast(error.message, 'danger');
|
|
1103
|
-
}
|
|
1104
|
-
close();
|
|
1105
|
-
await load();
|
|
1106
|
-
},
|
|
1107
|
-
}, `Adopt ${system} changes`), open];
|
|
1104
|
+
// A changed job is accepted in the panel's Review sidecar.
|
|
1105
|
+
return [h('a', { class: 'fx-btn', href: `/invoices/${o.invoiceId}`, onclick: (event) => { close(); ui.go(event); } }, label)];
|
|
1108
1106
|
}
|
|
1109
1107
|
// Flow's own stage: a person marks a job ready to invoice, or an
|
|
1110
1108
|
// automation does (Settings, Automations).
|
|
@@ -1147,15 +1145,20 @@ export async function renderJobs(main, ui) {
|
|
|
1147
1145
|
|
|
1148
1146
|
// The job's page: a large panel over the board, like a task page. A
|
|
1149
1147
|
// breadcrumb, the client's name, the stage, the job's fields, what to fix
|
|
1150
|
-
// and its lines
|
|
1151
|
-
//
|
|
1152
|
-
//
|
|
1153
|
-
//
|
|
1148
|
+
// and its lines. At the side (below on a phone), the sidecar: Activity,
|
|
1149
|
+
// and Review when the job changed after its draft (what changed, and
|
|
1150
|
+
// Accept change). The sidecar closes, and the main area takes its room.
|
|
1151
|
+
// Esc, the X or a click outside closes the panel. `atFix` scrolls to what
|
|
1152
|
+
// to fix, and opens Review when there is one. The job's custom fields can
|
|
1153
|
+
// be changed here (in Flow only), and its stage moved (a hand-off to the
|
|
1154
|
+
// work system).
|
|
1154
1155
|
function openJob(job, { atFix = false } = {}) {
|
|
1155
1156
|
const o = job.obligation;
|
|
1156
1157
|
const stage = invoiceStage(o);
|
|
1157
1158
|
const found = problemsOf(job);
|
|
1158
1159
|
const choose = found.find((f) => f.go === 'choose') ?? null;
|
|
1160
|
+
// The job changed after its draft: the sidecar has a Review tab.
|
|
1161
|
+
const reviewable = Boolean(o?.invoiceId) && found.some((f) => f.go === 'review');
|
|
1159
1162
|
const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
|
|
1160
1163
|
const status = job.status ?? NO_STATUS;
|
|
1161
1164
|
const late = isLate(job);
|
|
@@ -1170,9 +1173,14 @@ export async function renderJobs(main, ui) {
|
|
|
1170
1173
|
: choose ? h('span', { class: 'fx-task__none' }, svg(WARN_ICON), 'Needs invoice')
|
|
1171
1174
|
: stage ? INVOICE_STAGES[stage] : o ? 'Not ready to invoice' : 'No invoice yet';
|
|
1172
1175
|
const rest = found.filter((f) => f.go !== 'choose');
|
|
1176
|
+
// One short line a fix: its label, what is wrong, and its link. What to
|
|
1177
|
+
// do is the link's tip.
|
|
1173
1178
|
const fixBox = rest.length ? h('section', { class: 'fx-task__fix', 'aria-label': 'Fix' },
|
|
1174
1179
|
h('h3', {}, 'Fix'),
|
|
1175
|
-
rest.map((f) =>
|
|
1180
|
+
rest.map((f) => {
|
|
1181
|
+
const go = fixLink(f.go, f.todo);
|
|
1182
|
+
return h('div', { class: 'fx-task__fixrow', title: go ? null : f.todo }, h('strong', {}, f.label), h('span', {}, f.text), go);
|
|
1183
|
+
})) : null;
|
|
1176
1184
|
// No invoice, and the match is unclear: one line and two buttons. The
|
|
1177
1185
|
// possible matches are compared on the Reconcile page, not here.
|
|
1178
1186
|
const chooseBox = choose ? h('section', { class: 'fx-task__choose', 'aria-label': 'Invoice' },
|
|
@@ -1200,8 +1208,12 @@ export async function renderJobs(main, ui) {
|
|
|
1200
1208
|
}
|
|
1201
1209
|
// Each fix's link: Flow's own invoice, the job in the work system, or
|
|
1202
1210
|
// the Flow page where the fix is made (job-board.js GO).
|
|
1203
|
-
function fixLink(go) {
|
|
1204
|
-
const inFlow = (href, text) => h('a', { class: 'fx-task__link', href, onclick: (event) => { close(); ui.go(event); } }, text);
|
|
1211
|
+
function fixLink(go, tip = null) {
|
|
1212
|
+
const inFlow = (href, text) => h('a', { class: 'fx-task__link', href, title: tip, onclick: (event) => { close(); ui.go(event); } }, text);
|
|
1213
|
+
if (go === 'review' && reviewable) {
|
|
1214
|
+
return h('button', { class: 'fx-task__link fx-task__linkbtn', type: 'button', title: tip, onclick: () => side.open('review') }, 'Review');
|
|
1215
|
+
}
|
|
1216
|
+
if (go === 'review' && o?.invoiceId) return inFlow(`/invoices/${o.invoiceId}`, 'Open invoice');
|
|
1205
1217
|
if (go === 'invoice' && o?.invoiceId) return inFlow(`/invoices/${o.invoiceId}`, 'Open invoice');
|
|
1206
1218
|
if (go === 'work' && job.link?.url) {
|
|
1207
1219
|
return h('a', { class: 'fx-task__link', href: job.link.url, target: '_blank', rel: 'noopener' }, job.link.exact ? `Open case in ${system} ↗` : `Open ${system} ↗`);
|
|
@@ -1213,12 +1225,55 @@ export async function renderJobs(main, ui) {
|
|
|
1213
1225
|
}
|
|
1214
1226
|
const lines = job.lines ?? [];
|
|
1215
1227
|
const activity = h('ol', {}, h('li', { class: 'fx-dim' }, 'Loading…'));
|
|
1228
|
+
const activityBody = h('div', { class: 'fx-task__activity' },
|
|
1229
|
+
o ? activity : h('p', { class: 'fx-dim' }, `Flow has no invoice record for this job yet. It makes one when ${system} reports the job complete.`));
|
|
1230
|
+
// Review: the job's change as the invoice page shows it (job-change.js),
|
|
1231
|
+
// read from the invoice when the sidecar first needs it.
|
|
1232
|
+
const reviewBody = h('div', { class: 'fx-task__review' }, h('p', { class: 'fx-dim' }, 'Loading…'));
|
|
1233
|
+
let reviewRead = false;
|
|
1234
|
+
const readReview = () => {
|
|
1235
|
+
if (reviewRead) return;
|
|
1236
|
+
reviewRead = true;
|
|
1237
|
+
flow(`/api/invoicing/invoices/${o.invoiceId}`).then(({ invoice }) => drawReview(invoice))
|
|
1238
|
+
.catch(() => reviewBody.replaceChildren(h('p', {}, 'Flow could not read the change.')));
|
|
1239
|
+
};
|
|
1240
|
+
function drawReview(invoice) {
|
|
1241
|
+
const change = invoice?.jobChange ?? null;
|
|
1242
|
+
reviewBody.replaceChildren(change
|
|
1243
|
+
? jobChangeReview(ui, change, { money: formatCents, canAccept: state.canDraft, done: reopen })
|
|
1244
|
+
: h('p', { class: 'fx-dim' }, `The draft matches ${system}.`));
|
|
1245
|
+
}
|
|
1246
|
+
// After Accept change or a recheck: the board reads again, and this
|
|
1247
|
+
// job's panel opens again with its new facts.
|
|
1248
|
+
async function reopen() {
|
|
1249
|
+
await load();
|
|
1250
|
+
const fresh = state.jobs.find((j) => jobKey(j) === jobKey(job));
|
|
1251
|
+
close();
|
|
1252
|
+
if (fresh) openJob(fresh);
|
|
1253
|
+
}
|
|
1254
|
+
const sideButton = h('button', {
|
|
1255
|
+
class: 'fx-btn fx-btn--ghost fx-btn--icon', type: 'button', 'aria-label': 'Side panel', title: 'Side panel', 'aria-pressed': 'false',
|
|
1256
|
+
onclick: () => (side.isOpen() ? side.close() : side.open(readSide() === 'review' && reviewable ? 'review' : 'activity')),
|
|
1257
|
+
}, lucide(h, 'panel-right'));
|
|
1258
|
+
const side = sidecar(h, {
|
|
1259
|
+
label: 'Job side panel',
|
|
1260
|
+
tabs: [
|
|
1261
|
+
{ id: 'activity', label: 'Activity', icon: 'history', render: () => activityBody },
|
|
1262
|
+
reviewable ? { id: 'review', label: 'Review', icon: 'git-compare', mark: true, render: () => { readReview(); return reviewBody; } } : null,
|
|
1263
|
+
].filter(Boolean),
|
|
1264
|
+
onChange: (tab) => {
|
|
1265
|
+
saveSide(tab);
|
|
1266
|
+
sideButton.setAttribute('aria-pressed', String(Boolean(tab)));
|
|
1267
|
+
},
|
|
1268
|
+
});
|
|
1269
|
+
side.element.classList.add('fx-task__side');
|
|
1216
1270
|
element.append(h('div', { class: 'fx-task__grid' },
|
|
1217
1271
|
h('div', { class: 'fx-task__main' },
|
|
1218
1272
|
h('header', { class: 'fx-task__head' },
|
|
1219
1273
|
h('nav', { class: 'fx-task__crumb', 'aria-label': 'Breadcrumb' },
|
|
1220
1274
|
h('span', {}, 'Jobs'), h('span', { class: 'fx-task__slash', 'aria-hidden': 'true' }, '/'), h('span', {}, status)),
|
|
1221
|
-
h('
|
|
1275
|
+
h('div', { class: 'fx-task__tools' }, sideButton,
|
|
1276
|
+
h('button', { class: 'fx-btn fx-btn--ghost fx-btn--icon', type: 'button', 'aria-label': 'Close', title: 'Close', onclick: close }, '✕'))),
|
|
1222
1277
|
h('h2', { class: 'fx-task__title' }, clientOf(job)),
|
|
1223
1278
|
h('div', { class: 'fx-task__stage' }, stagePill(status)),
|
|
1224
1279
|
h('dl', { class: 'fx-task__fields' },
|
|
@@ -1255,13 +1310,14 @@ export async function renderJobs(main, ui) {
|
|
|
1255
1310
|
h('td', { class: 'fx-num' }, job.totalCents == null ? '—' : formatCents(job.totalCents)))))
|
|
1256
1311
|
: h('p', { class: 'fx-dim' }, 'No lines.')),
|
|
1257
1312
|
h('div', { class: 'fx-task__actions' }, actions(job, close))),
|
|
1258
|
-
|
|
1259
|
-
h('h3', {}, 'Activity'),
|
|
1260
|
-
o ? activity : h('p', { class: 'fx-dim' }, `Flow has no invoice record for this job yet. It makes one when ${system} reports the job complete.`))));
|
|
1313
|
+
side.element));
|
|
1261
1314
|
element.addEventListener('click', (event) => { if (event.target === element) close(); });
|
|
1262
1315
|
element.addEventListener('close', () => element.remove());
|
|
1263
1316
|
document.body.append(element);
|
|
1264
1317
|
element.showModal();
|
|
1318
|
+
const last = readSide();
|
|
1319
|
+
if (atFix && reviewable) side.open('review');
|
|
1320
|
+
else if (last !== 'closed') side.open(last === 'review' && reviewable ? 'review' : 'activity');
|
|
1265
1321
|
if (atFix && (chooseBox ?? fixBox)) (chooseBox ?? fixBox).scrollIntoView({ block: 'start' });
|
|
1266
1322
|
if (!o) return;
|
|
1267
1323
|
const when = (iso) => new Date(iso).toLocaleString('en-AU', { day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit' });
|
package/server/ui/monitoring.js
CHANGED
|
@@ -99,6 +99,7 @@ export async function startMonitoring({ flow, person }) {
|
|
|
99
99
|
integrations,
|
|
100
100
|
tracesSampleRate,
|
|
101
101
|
beforeSendSpan: scrubSpan,
|
|
102
|
+
initialScope: { tags: config.tags ?? {} },
|
|
102
103
|
});
|
|
103
104
|
Sentry.getClient()?.on('beforeSendFeedback', tagNotify);
|
|
104
105
|
// Who reported or met the error, so the lab can follow up.
|
package/server/ui/price-list.js
CHANGED
|
@@ -29,7 +29,7 @@ export async function renderPriceList(main, ui) {
|
|
|
29
29
|
const addButton = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled }, 'Add code');
|
|
30
30
|
const reconcileButton = h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', hidden: true, onclick: () => ui.go('/price-list/reconcile') }, 'Reconcile');
|
|
31
31
|
const alerts = alertStrip(ui, 'price-list');
|
|
32
|
-
main.append(pageHeader('Price list', {
|
|
32
|
+
main.append(pageHeader('Price list', { help: { href: DOCS, label: 'How the price list works' }, actions: [reconcileButton, addButton] }), alerts.element, host);
|
|
33
33
|
|
|
34
34
|
let data = null;
|
|
35
35
|
let query = '';
|
|
@@ -176,8 +176,7 @@ export async function renderPriceList(main, ui) {
|
|
|
176
176
|
table,
|
|
177
177
|
];
|
|
178
178
|
const settings = h('section', { class: 'fx-set fx-price__settings' }, h('h2', { class: 'fx-set__title' }, 'Invoice lines'), h('div', { class: 'fx-skeleton' }));
|
|
179
|
-
linePriceRow().then((r) => settings.replaceChildren(settings.firstChild, r
|
|
180
|
-
h('p', { class: 'fx-dim', style: { margin: '8px 0 0' } }, h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How the price list works'))));
|
|
179
|
+
linePriceRow().then((r) => settings.replaceChildren(settings.firstChild, r));
|
|
181
180
|
sections.push(settings);
|
|
182
181
|
host.replaceChildren(...sections.filter(Boolean));
|
|
183
182
|
// Opened from Reconcile's "Edit code": that code's dialog, once.
|
|
@@ -26,17 +26,16 @@ export async function renderPriceReconcile(main, ui) {
|
|
|
26
26
|
const icon = (name, label) => lucide(h, name, label);
|
|
27
27
|
let view = new URLSearchParams(location.search).get('view') === 'list' ? 'list' : 'card';
|
|
28
28
|
const host = h('div', { class: 'fx-xi fx-prc' }, h('p', { class: 'fx-dim' }, 'Loading…'));
|
|
29
|
-
const
|
|
29
|
+
const tally = h('span', {});
|
|
30
30
|
const toggle = (name, label, glyph) => h('button', {
|
|
31
31
|
class: 'fx-xi__view', type: 'button', role: 'tab', 'aria-selected': String(view === name), 'aria-label': label, title: label, 'data-view': name,
|
|
32
32
|
onclick: () => setView(name),
|
|
33
33
|
}, icon(glyph));
|
|
34
34
|
const bulk = bulkBar(ui, { label: 'Ticked records' });
|
|
35
35
|
main.append(
|
|
36
|
-
|
|
37
|
-
h('a', { href: '/price-list', onclick: (event) => ui.go(event) }, 'Price list'), h('span', { 'aria-hidden': 'true' }, '›'), h('span', {}, 'Reconcile')),
|
|
36
|
+
ui.crumbs(['/price-list', 'Price list'], 'Reconcile'),
|
|
38
37
|
ui.pageHeader('Reconcile prices', {
|
|
39
|
-
|
|
38
|
+
count: tally,
|
|
40
39
|
actions: [h('div', { class: 'fx-xi__views', role: 'tablist', 'aria-label': 'View' }, toggle('card', 'One at a time', 'square'), toggle('list', 'List, tick many', 'list-checks'))],
|
|
41
40
|
}),
|
|
42
41
|
host, bulk.element);
|
|
@@ -129,13 +128,13 @@ export async function renderPriceReconcile(main, ui) {
|
|
|
129
128
|
if (!data) return;
|
|
130
129
|
bulk.draw({ count: 0 });
|
|
131
130
|
if (!data.systems.length) {
|
|
132
|
-
|
|
131
|
+
tally.replaceChildren('');
|
|
133
132
|
host.replaceChildren(h('p', { class: 'fx-empty' }, 'No integration with a price list is on. Flow uses its own list alone.'),
|
|
134
133
|
h('a', { href: '/price-list', onclick: (event) => ui.go(event) }, '← Price list'));
|
|
135
134
|
return;
|
|
136
135
|
}
|
|
137
136
|
if (!rows.length) {
|
|
138
|
-
|
|
137
|
+
tally.replaceChildren('');
|
|
139
138
|
host.replaceChildren(h('div', { class: 'fx-xi__done' }, icon('check-check'), h('p', {}, 'Every record has a code, and every price agrees.'),
|
|
140
139
|
h('a', { href: '/price-list', onclick: (event) => ui.go(event) }, 'Price list')));
|
|
141
140
|
return;
|
|
@@ -184,7 +183,7 @@ export async function renderPriceReconcile(main, ui) {
|
|
|
184
183
|
// One record (or one differing code) at a time.
|
|
185
184
|
function drawCard() {
|
|
186
185
|
const row = rows[at];
|
|
187
|
-
|
|
186
|
+
tally.replaceChildren(`${rows.length} to check`);
|
|
188
187
|
const next = () => { at = (at + 1) % rows.length; pick = 0; chosen = null; drawCard(); };
|
|
189
188
|
const record = row.record;
|
|
190
189
|
const item = row.kind === 'differs' ? row.item : chosen ?? row.candidates[pick]?.item ?? null;
|
|
@@ -267,7 +266,7 @@ export async function renderPriceReconcile(main, ui) {
|
|
|
267
266
|
|
|
268
267
|
// Every record and code to check; ticked rows go from the bulk bar.
|
|
269
268
|
function drawList() {
|
|
270
|
-
|
|
269
|
+
tally.replaceChildren(`${rows.length} to check`);
|
|
271
270
|
const ticked = new Set();
|
|
272
271
|
const boxes = new Map();
|
|
273
272
|
const picked = () => rows.filter((r) => ticked.has(r.key));
|
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
|
-
const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.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', 'job-view.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'flow-mark.svg']);
|
|
24
|
+
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', 'job-view.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.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';
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The sidecar, as in ClickUp: a panel at the right of a page or a dialog.
|
|
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, Review). Closed,
|
|
4
|
+
// it takes no room, so the main area gets the full width.
|
|
5
|
+
//
|
|
6
|
+
// sidecar(h, { label, overlay, escape, tabs, onChange }) answers
|
|
7
|
+
// { element, isOpen, open, show, close, tab, setTabs }:
|
|
8
|
+
// open(id) selects tab `id` (or the first) and draws its `render()`
|
|
9
|
+
// show(node) draws one node with no tabs (the overlay's own form)
|
|
10
|
+
// close() hides it and empties it
|
|
11
|
+
// `tabs` is [{ id, label, icon, render }]; each tab is an icon and one word.
|
|
12
|
+
// `escape` closes it on Esc when no dialog or menu is open over the page.
|
|
13
|
+
// `onChange(id)` hears each open (the tab id) and close (null).
|
|
14
|
+
|
|
15
|
+
import { lucide } from '/assets/ui/custom-fields.js';
|
|
16
|
+
|
|
17
|
+
export function sidecar(h, { label, overlay = false, escape = false, tabs = [], onChange = null } = {}) {
|
|
18
|
+
const element = h('aside', { class: `fx-sidecar${overlay ? ' fx-sidecar--over' : ''}`, 'aria-label': label, hidden: true });
|
|
19
|
+
let list = tabs;
|
|
20
|
+
let current = null;
|
|
21
|
+
|
|
22
|
+
const closeButton = () => h('button', { class: 'fx-sidecar__close', type: 'button', 'aria-label': 'Close', title: 'Close', onclick: () => api.close() }, lucide(h, 'x'));
|
|
23
|
+
|
|
24
|
+
function drawTabs() {
|
|
25
|
+
const head = h('header', { class: 'fx-sidecar__head' },
|
|
26
|
+
h('div', { class: 'fx-sidecar__tabs', role: 'tablist', 'aria-label': label },
|
|
27
|
+
list.map((t) => h('button', {
|
|
28
|
+
class: 'fx-sidecar__tab', type: 'button', role: 'tab', 'data-tab': t.id,
|
|
29
|
+
'aria-selected': String(t.id === current), onclick: () => api.open(t.id),
|
|
30
|
+
}, lucide(h, t.icon), h('span', {}, t.label), t.mark ? h('span', { class: 'fx-sidecar__mark', 'aria-hidden': 'true' }) : null))),
|
|
31
|
+
closeButton());
|
|
32
|
+
const tab = list.find((t) => t.id === current);
|
|
33
|
+
element.replaceChildren(head, h('div', { class: 'fx-sidecar__body', role: 'tabpanel' }, tab?.render?.() ?? null));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const api = {
|
|
37
|
+
element,
|
|
38
|
+
isOpen: () => !element.hidden,
|
|
39
|
+
tab: () => (element.hidden ? null : current),
|
|
40
|
+
open(id = null) {
|
|
41
|
+
current = list.find((t) => t.id === id)?.id ?? list[0]?.id ?? null;
|
|
42
|
+
drawTabs();
|
|
43
|
+
element.hidden = false;
|
|
44
|
+
onChange?.(current);
|
|
45
|
+
},
|
|
46
|
+
show(node) {
|
|
47
|
+
current = null;
|
|
48
|
+
element.replaceChildren(node);
|
|
49
|
+
element.hidden = false;
|
|
50
|
+
},
|
|
51
|
+
close() {
|
|
52
|
+
if (element.hidden) return;
|
|
53
|
+
element.hidden = true;
|
|
54
|
+
element.replaceChildren();
|
|
55
|
+
current = null;
|
|
56
|
+
onChange?.(null);
|
|
57
|
+
},
|
|
58
|
+
// New tabs (a tab's data came in): an open tab draws again.
|
|
59
|
+
setTabs(next) {
|
|
60
|
+
list = next;
|
|
61
|
+
if (!element.hidden && current) drawTabs();
|
|
62
|
+
},
|
|
63
|
+
closeButton,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
if (escape) {
|
|
67
|
+
document.addEventListener('keydown', (event) => {
|
|
68
|
+
if (event.key === 'Escape' && element.isConnected && !element.hidden && !document.querySelector('dialog[open], .fx-pop:popover-open')) api.close();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return api;
|
|
72
|
+
}
|
package/server/ui/tip.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Tips: every control that shows only an icon says its name on hover and on
|
|
2
|
+
// keyboard focus, in one small tip beside it. One listener on the document
|
|
3
|
+
// serves the whole app, so a new icon button needs only a name:
|
|
4
|
+
//
|
|
5
|
+
// data-tip="Clear" the tip, when it differs from the spoken name
|
|
6
|
+
// aria-label="Close" an icon-only control's name is its tip
|
|
7
|
+
// title="Search" taken over as the tip (no second, native tip)
|
|
8
|
+
//
|
|
9
|
+
// A control with words on it shows no tip. Clerk's own components keep their
|
|
10
|
+
// own tips. The tip floats on <body>, so a scrolling list cannot cut it off.
|
|
11
|
+
|
|
12
|
+
const INTERACTIVE = 'button, a[href], [role="button"], [role="tab"], [role="radio"], [role="menuitem"], [role="switch"], summary';
|
|
13
|
+
const CANDIDATE = `[data-tip], ${INTERACTIVE.split(', ').map((s) => `${s}[aria-label], ${s}[title]`).join(', ')}, [role="img"][aria-label], [role="img"][title]`;
|
|
14
|
+
|
|
15
|
+
// True when the element shows no word of its own: only icons or a symbol.
|
|
16
|
+
export function iconOnly(text) {
|
|
17
|
+
return !/[\p{L}\p{N}]/u.test(String(text ?? ''));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// The tip for this element, or null. `el` needs getAttribute, textContent
|
|
21
|
+
// and closest (a DOM element, or a test's stand-in).
|
|
22
|
+
export function tipText(el) {
|
|
23
|
+
if (!el || el.closest?.('[class*="cl-"]')) return null;
|
|
24
|
+
if (!iconOnly(el.textContent) && !el.getAttribute('data-tip')) return null;
|
|
25
|
+
return el.getAttribute('title') || el.getAttribute('data-tip') || el.getAttribute('aria-label') || null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Marks a control icon-only: its spoken name and its tip. Returns the node.
|
|
29
|
+
export function named(node, label, tip = label) {
|
|
30
|
+
node.setAttribute('aria-label', label);
|
|
31
|
+
node.setAttribute('data-tip', tip);
|
|
32
|
+
node.removeAttribute('title');
|
|
33
|
+
return node;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let bubble = null;
|
|
37
|
+
let anchor = null;
|
|
38
|
+
|
|
39
|
+
function place() {
|
|
40
|
+
if (!bubble || !anchor) return;
|
|
41
|
+
const box = anchor.getBoundingClientRect();
|
|
42
|
+
const tip = bubble.getBoundingClientRect();
|
|
43
|
+
const gap = 6;
|
|
44
|
+
// A folded sidebar's icons take their tip at the right, as in ClickUp.
|
|
45
|
+
if (anchor.dataset.tipSide === 'right') {
|
|
46
|
+
bubble.style.transform = `translate(${Math.round(box.right + gap)}px, ${Math.round(box.top + box.height / 2 - tip.height / 2)}px)`;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const above = box.top - tip.height - gap;
|
|
50
|
+
const top = above >= 4 ? above : box.bottom + gap;
|
|
51
|
+
const left = Math.min(Math.max(4, box.left + box.width / 2 - tip.width / 2), window.innerWidth - tip.width - 4);
|
|
52
|
+
bubble.style.transform = `translate(${Math.round(left)}px, ${Math.round(top)}px)`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function show(el) {
|
|
56
|
+
const text = tipText(el);
|
|
57
|
+
if (!text) return;
|
|
58
|
+
// A native title would show a second tip after a pause, so the tip takes
|
|
59
|
+
// it over (a title set again later wins).
|
|
60
|
+
if (el.hasAttribute('title')) {
|
|
61
|
+
if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', el.getAttribute('title'));
|
|
62
|
+
el.setAttribute('data-tip', el.getAttribute('title'));
|
|
63
|
+
el.removeAttribute('title');
|
|
64
|
+
return show(el);
|
|
65
|
+
}
|
|
66
|
+
if (!bubble) {
|
|
67
|
+
bubble = document.createElement('div');
|
|
68
|
+
bubble.className = 'fx-tip';
|
|
69
|
+
bubble.setAttribute('role', 'tooltip');
|
|
70
|
+
bubble.setAttribute('aria-hidden', 'true');
|
|
71
|
+
}
|
|
72
|
+
bubble.textContent = text;
|
|
73
|
+
anchor = el;
|
|
74
|
+
(el.closest('dialog[open]') ?? document.body).append(bubble);
|
|
75
|
+
place();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function hide(el) {
|
|
79
|
+
if (el && el !== anchor) return;
|
|
80
|
+
anchor = null;
|
|
81
|
+
bubble?.remove();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function startTips() {
|
|
85
|
+
document.addEventListener('pointerover', (event) => {
|
|
86
|
+
if (event.pointerType === 'touch') return;
|
|
87
|
+
const el = event.target.closest?.(CANDIDATE);
|
|
88
|
+
if (el && el !== anchor) show(el);
|
|
89
|
+
else if (!el) hide();
|
|
90
|
+
});
|
|
91
|
+
document.addEventListener('focusin', (event) => {
|
|
92
|
+
const el = event.target.closest?.(CANDIDATE);
|
|
93
|
+
if (el && el.matches(':focus-visible')) show(el);
|
|
94
|
+
});
|
|
95
|
+
document.addEventListener('focusout', (event) => hide(event.target.closest?.(CANDIDATE)));
|
|
96
|
+
document.addEventListener('pointerdown', () => hide());
|
|
97
|
+
document.addEventListener('keydown', (event) => { if (event.key === 'Escape') hide(); });
|
|
98
|
+
window.addEventListener('scroll', () => hide(), true);
|
|
99
|
+
}
|
package/server/ui/xero-import.js
CHANGED
|
@@ -49,7 +49,7 @@ export async function renderXeroImport(main, ui) {
|
|
|
49
49
|
const history = params.get('show') === 'all';
|
|
50
50
|
const icon = (name, label) => lucide(h, name, label);
|
|
51
51
|
const host = h('div', { class: 'fx-xi' }, h('p', { class: 'fx-dim' }, 'Loading…'));
|
|
52
|
-
const
|
|
52
|
+
const tally = h('span', {});
|
|
53
53
|
const actions = [];
|
|
54
54
|
let view = params.get('view') === 'list' ? 'list' : 'card';
|
|
55
55
|
if (!history) {
|
|
@@ -66,11 +66,8 @@ export async function renderXeroImport(main, ui) {
|
|
|
66
66
|
renderReview();
|
|
67
67
|
}
|
|
68
68
|
main.append(
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
h('a', { href: '/integrations/xero', onclick: (event) => ui.go(event) }, 'Xero'), h('span', { 'aria-hidden': 'true' }, '›'),
|
|
72
|
-
h('span', {}, history ? 'History' : 'Review')),
|
|
73
|
-
ui.pageHeader(history ? 'Xero history' : 'Review', { subtitle, actions }),
|
|
69
|
+
ui.crumbs(['/integrations', 'Integrations'], ['/integrations/xero', 'Xero'], history ? 'History' : 'Review'),
|
|
70
|
+
ui.pageHeader(history ? 'Xero history' : 'Review', { count: tally, actions }),
|
|
74
71
|
host);
|
|
75
72
|
|
|
76
73
|
let organisationId;
|
|
@@ -117,7 +114,7 @@ export async function renderXeroImport(main, ui) {
|
|
|
117
114
|
return;
|
|
118
115
|
}
|
|
119
116
|
if (!rows.length) {
|
|
120
|
-
|
|
117
|
+
tally.replaceChildren('');
|
|
121
118
|
host.replaceChildren(h('div', { class: 'fx-xi__done' }, icon('check-check'), h('p', {}, 'Nothing to check.'), go('/xero-import?show=all', 'Xero history')));
|
|
122
119
|
return;
|
|
123
120
|
}
|
|
@@ -134,7 +131,7 @@ export async function renderXeroImport(main, ui) {
|
|
|
134
131
|
const fit = flowFit(row, pick);
|
|
135
132
|
const bar = h('span', { class: 'fx-xi__progress', role: 'progressbar', 'aria-valuemin': '1', 'aria-valuemax': String(rows.length), 'aria-valuenow': String(at + 1) },
|
|
136
133
|
h('span', { style: { width: `${((at + 1) / rows.length) * 100}%` } }));
|
|
137
|
-
|
|
134
|
+
tally.replaceChildren(h('span', { class: 'fx-xi__count' }, bar, `${at + 1} / ${rows.length}`));
|
|
138
135
|
const days = daysApart(row.issuedOn, fit.date);
|
|
139
136
|
const facts = [
|
|
140
137
|
['hash', 'Number', h('span', { class: 'fx-num' }, row.number ?? '—'), h('span', { class: 'fx-num' }, fit.name), null],
|
|
@@ -195,7 +192,7 @@ export async function renderXeroImport(main, ui) {
|
|
|
195
192
|
|
|
196
193
|
// Every one to check, best fit beside it; ticked rows go from the bulk bar.
|
|
197
194
|
function reviewList(rows) {
|
|
198
|
-
|
|
195
|
+
tally.replaceChildren(`${rows.length} to check`);
|
|
199
196
|
const ticked = new Set();
|
|
200
197
|
const boxes = new Map();
|
|
201
198
|
const drawBar = () => {
|
|
@@ -315,7 +312,7 @@ export async function renderXeroImport(main, ui) {
|
|
|
315
312
|
return;
|
|
316
313
|
}
|
|
317
314
|
const total = Object.values(page.counts).reduce((a, b) => a + b, 0);
|
|
318
|
-
|
|
315
|
+
tally.replaceChildren(h('span', { class: 'fx-xi__count' }, icon('check', 'In Flow'), `${page.counts.linked} / ${total}`));
|
|
319
316
|
shown = offset === 0 ? page.rows : [...shown, ...page.rows];
|
|
320
317
|
const rows = page.rows.map(rowOf);
|
|
321
318
|
if (offset === 0) body.replaceChildren(...(rows.length ? rows : [h('tr', {}, h('td', { colspan: '8', class: 'fx-empty' }, q.trim() ? 'No match.' : 'No Xero invoices copied yet.'))]));
|