@arcforgelabs/flow 2026.10.47 → 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/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/invoicing.js +35 -8
- package/server/core/contacts.js +160 -0
- package/server/core/directory.js +4 -9
- package/server/core/invoices.js +84 -31
- package/server/core/sending.js +65 -3
- 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/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/app.js +8 -3
- package/server/ui/contacts.js +260 -0
- package/server/ui/flow.css +40 -0
- package/server/ui/invoicing.js +159 -54
- package/server/ui/kit.js +28 -8
- package/server/ui/mobile.js +6 -3
- package/server/ui/nav-pins.js +5 -2
- package/server/ui/routes.js +1 -1
package/server/ui/invoicing.js
CHANGED
|
@@ -250,6 +250,9 @@ export async function renderInvoiceList(main, ui) {
|
|
|
250
250
|
state.values = new Map((values?.values ?? []).map((v) => [valueKey(v, v.fieldId), v.value]));
|
|
251
251
|
let body = null;
|
|
252
252
|
let raw = null;
|
|
253
|
+
// Xero's status from Flow's copy, filled after the list draws. Delete on
|
|
254
|
+
// an issued invoice waits for this: it shows only while the copy says Draft.
|
|
255
|
+
let xeroById = new Map();
|
|
253
256
|
const openTab = (id) => { state.status = id; state.offset = 0; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); };
|
|
254
257
|
const alerts = alertStrip(ui, 'invoices', { onTab: openTab });
|
|
255
258
|
// The tabs and search stay at the top while the rows scroll up under them;
|
|
@@ -355,13 +358,19 @@ export async function renderInvoiceList(main, ui) {
|
|
|
355
358
|
run: () => voidAll(voidable),
|
|
356
359
|
});
|
|
357
360
|
}
|
|
358
|
-
// A draft that was never issued is deleted
|
|
359
|
-
//
|
|
360
|
-
|
|
361
|
-
|
|
361
|
+
// A draft that was never issued is deleted on Pending and Ready. An issued
|
|
362
|
+
// invoice is deleted only while Flow's copy of Xero says Draft. Approved
|
|
363
|
+
// or paid stays hidden. A mix runs every ticked row and reports each one.
|
|
364
|
+
const fresh = rows.filter((i) => i.status === 'draft' && i.issuedVersion === 0);
|
|
365
|
+
const issuedXero = rows.filter((i) => i.status === 'issued' && xeroById.has(i.id));
|
|
366
|
+
const xeroDraft = issuedXero.filter((i) => xeroById.get(i.id)?.state === 'draft');
|
|
367
|
+
const xeroApproved = issuedXero.filter((i) => ['approved', 'part_paid', 'paid'].includes(xeroById.get(i.id)?.state));
|
|
368
|
+
const deletable = [...fresh, ...xeroDraft, ...xeroApproved];
|
|
369
|
+
const onDraftTabs = ['pending', 'ready'].includes(state.status) && fresh.length;
|
|
370
|
+
if (context.canIssue && (onDraftTabs || xeroDraft.length)) {
|
|
362
371
|
danger.push({
|
|
363
372
|
icon: 'trash-2', label: 'Delete', iconOnly: true, tone: 'danger', title: 'Delete',
|
|
364
|
-
run: () => deleteAll(deletable),
|
|
373
|
+
run: () => deleteAll(deletable, { xero: xeroDraft.length + xeroApproved.length > 0 }),
|
|
365
374
|
});
|
|
366
375
|
}
|
|
367
376
|
const all = body.invoices.map((i) => i.id);
|
|
@@ -439,23 +448,46 @@ export async function renderInvoiceList(main, ui) {
|
|
|
439
448
|
alerts.refresh();
|
|
440
449
|
}
|
|
441
450
|
|
|
442
|
-
// Delete, one
|
|
443
|
-
|
|
451
|
+
// Delete, one invoice after another, as on one invoice's page. A Xero
|
|
452
|
+
// draft reports each row: deleted, or refused with the reason.
|
|
453
|
+
async function deleteAll(rows, { xero = false } = {}) {
|
|
454
|
+
const fresh = rows.some((i) => i.status === 'draft' && i.issuedVersion === 0);
|
|
444
455
|
const ok = await ui.dialog({
|
|
445
456
|
title: `Delete ${plural(rows.length, 'invoice', 'invoices')}?`,
|
|
446
|
-
body:
|
|
457
|
+
body: xero
|
|
458
|
+
? h('div', {},
|
|
459
|
+
fresh ? h('p', {}, 'A draft that was never issued is removed. Its job can be invoiced again, and the rules decide.') : null,
|
|
460
|
+
h('p', {}, 'Also deletes the draft in Xero.'))
|
|
461
|
+
: h('p', {}, 'A draft that was never issued is removed. Its job can be invoiced again, and the rules decide.'),
|
|
447
462
|
confirm: `Delete ${rows.length}`, tone: 'danger',
|
|
448
463
|
});
|
|
449
464
|
if (!ok) return;
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
465
|
+
if (xero) {
|
|
466
|
+
const results = [];
|
|
467
|
+
for (const invoice of rows) {
|
|
468
|
+
const name = invoice.number ?? 'Draft';
|
|
469
|
+
try {
|
|
470
|
+
await flow(`/api/invoicing/invoices/${invoice.id}`, { method: 'DELETE' });
|
|
471
|
+
results.push(`${name} deleted.`);
|
|
472
|
+
} catch (error) {
|
|
473
|
+
results.push(`${name} refused: ${error.message}`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const refused = results.some((line) => line.includes(' refused: '));
|
|
477
|
+
// The result stays until it is closed. A success is one short line, and
|
|
478
|
+
// a refusal names each invoice, so neither should vanish on its own.
|
|
479
|
+
toast(results.join(' '), refused ? 'danger' : 'success', { stay: true });
|
|
480
|
+
} else {
|
|
481
|
+
let done = 0;
|
|
482
|
+
let error = null;
|
|
483
|
+
for (const invoice of rows) {
|
|
484
|
+
try {
|
|
485
|
+
await flow(`/api/invoicing/invoices/${invoice.id}`, { method: 'DELETE' });
|
|
486
|
+
done += 1;
|
|
487
|
+
} catch (e) { error = e; }
|
|
488
|
+
}
|
|
489
|
+
toast(error ? `${done} of ${rows.length} deleted. ${error.message}` : `${plural(done, 'invoice', 'invoices')} deleted.`, error ? 'danger' : 'success', { stay: true });
|
|
457
490
|
}
|
|
458
|
-
toast(error ? `${done} of ${rows.length} deleted. ${error.message}` : `${plural(done, 'invoice', 'invoices')} deleted.`, error ? 'danger' : 'success');
|
|
459
491
|
state.ticked.clear();
|
|
460
492
|
seen.clear();
|
|
461
493
|
await load();
|
|
@@ -527,6 +559,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
527
559
|
raw = answer;
|
|
528
560
|
filterMenu.draw();
|
|
529
561
|
body = answer;
|
|
562
|
+
xeroById = new Map();
|
|
530
563
|
// A page past the end (rows went to another tab): show the last one.
|
|
531
564
|
if (!answer.invoices.length && answer.total && answer.offset) {
|
|
532
565
|
state.offset = Math.max(0, Math.floor((answer.total - 1) / state.limit) * state.limit);
|
|
@@ -634,16 +667,22 @@ export async function renderInvoiceList(main, ui) {
|
|
|
634
667
|
rowCells(h, columns, invoice),
|
|
635
668
|
h('td', { class: 'fx-c-end' })))),
|
|
636
669
|
}));
|
|
637
|
-
// Issued
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
|
|
670
|
+
// Issued invoices carry Xero's status on every tab, so Delete can follow
|
|
671
|
+
// Flow's copy. A draft that is out of sync sits on Pending, which has no
|
|
672
|
+
// number column, and is still a draft there. Icons stay on the numbered
|
|
673
|
+
// tabs, where one invoice not in Xero stands out.
|
|
674
|
+
const issuedIds = body.invoices.filter((i) => i.issuedVersion > 0).map((i) => i.id);
|
|
675
|
+
if (numbered || issuedIds.length) {
|
|
641
676
|
const shownBody = body;
|
|
642
|
-
const statuses = await xeroStatuses(ui, context.organisations?.[0]?.id, body.invoices.map((i) => i.id));
|
|
677
|
+
const statuses = await xeroStatuses(ui, context.organisations?.[0]?.id, numbered ? body.invoices.map((i) => i.id) : issuedIds);
|
|
643
678
|
if (shownBody !== body) return;
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
679
|
+
xeroById = statuses;
|
|
680
|
+
if (state.ticked.size) drawBar();
|
|
681
|
+
if (numbered) {
|
|
682
|
+
for (const [invoiceId, status] of statuses) {
|
|
683
|
+
if (linked) { if (accountingSystem) statusCells.get(invoiceId)?.append(accountingIcon(ui, accountingSystem, status)); }
|
|
684
|
+
else if (status.state !== 'not_sent' || status.finding) statusCells.get(invoiceId)?.append(' ', xeroTag(ui, status));
|
|
685
|
+
}
|
|
647
686
|
}
|
|
648
687
|
}
|
|
649
688
|
}
|
|
@@ -1155,29 +1194,10 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1155
1194
|
const fromJob = isNew && context.canIssue
|
|
1156
1195
|
? h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', onclick: () => pickJob(ui) }, 'From a job') : null;
|
|
1157
1196
|
// A draft that was never issued is deleted. An issued invoice, including
|
|
1158
|
-
// one opened for a revision, is voided instead
|
|
1197
|
+
// one opened for a revision, is voided instead, unless its Xero copy is a
|
|
1198
|
+
// draft: that Delete is added on the issued page after the copy loads.
|
|
1159
1199
|
const deleteButton = !isNew && context.canIssue && invoice.status === 'draft' && invoice.issuedVersion === 0
|
|
1160
|
-
?
|
|
1161
|
-
class: 'fx-btn fx-btn--ghost fx-btn--danger', type: 'button',
|
|
1162
|
-
onclick: async () => {
|
|
1163
|
-
const ok = await ui.dialog({
|
|
1164
|
-
title: 'Delete this invoice?',
|
|
1165
|
-
body: h('p', {}, invoice.job
|
|
1166
|
-
? 'It was never issued. The job can be invoiced again, and the rules decide.'
|
|
1167
|
-
: 'It was never issued, so it is removed.'),
|
|
1168
|
-
confirm: 'Delete', tone: 'danger',
|
|
1169
|
-
});
|
|
1170
|
-
if (!ok) return;
|
|
1171
|
-
try {
|
|
1172
|
-
await flow(`/api/invoicing/invoices/${invoice.id}`, { method: 'DELETE' });
|
|
1173
|
-
toast('Invoice deleted.', 'success');
|
|
1174
|
-
sessionStorage.setItem(NAV_BACK, '1');
|
|
1175
|
-
go('/invoices');
|
|
1176
|
-
} catch (error) {
|
|
1177
|
-
toast(error.message, 'danger');
|
|
1178
|
-
}
|
|
1179
|
-
},
|
|
1180
|
-
}, 'Delete') : null;
|
|
1200
|
+
? deleteControl(ui, invoice, { xeroDraft: false }) : null;
|
|
1181
1201
|
|
|
1182
1202
|
// A draft with no problem is in Ready by itself; one with a problem is
|
|
1183
1203
|
// Pending. A changed job is fixed at its source or accepted here.
|
|
@@ -1185,13 +1205,44 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1185
1205
|
// The job changed after the invoice was made: its own banner shows what
|
|
1186
1206
|
// changed. Other problems stay one line.
|
|
1187
1207
|
const otherProblems = (invoice?.problems ?? []).filter((p) => !(jobChange && p.code === 'job_review'));
|
|
1208
|
+
const offer = otherProblems.find((p) => p.code === 'xero_contact_missing' && p.offerCreate);
|
|
1209
|
+
const problemNotice = otherProblems.length
|
|
1210
|
+
? notice([...new Set(otherProblems.map((p) => brief(p.message)))].join(' '), 'danger', 'Problem.')
|
|
1211
|
+
: null;
|
|
1212
|
+
const organisationId = context.organisations[0]?.id;
|
|
1213
|
+
const createClient = offer && context.canIssue && invoice?.contact?.id && organisationId
|
|
1214
|
+
? h('button', {
|
|
1215
|
+
class: 'fx-btn fx-btn--primary', type: 'button', title: 'Create this client in Xero',
|
|
1216
|
+
onclick: async (event) => {
|
|
1217
|
+
const button = event.currentTarget;
|
|
1218
|
+
const name = invoice.contact.name;
|
|
1219
|
+
const confirmed = await ui.dialog({
|
|
1220
|
+
title: `Create ${name} in Xero?`,
|
|
1221
|
+
body: h('p', {}, 'Flow adds this client to Xero from the contact.'),
|
|
1222
|
+
confirm: 'Create',
|
|
1223
|
+
});
|
|
1224
|
+
if (!confirmed) return;
|
|
1225
|
+
button.disabled = true;
|
|
1226
|
+
try {
|
|
1227
|
+
await flow('/api/xero/contacts', { method: 'POST', body: JSON.stringify({ organisationId, contactId: invoice.contact.id }) });
|
|
1228
|
+
toast(`${name} is in Xero.`, 'success');
|
|
1229
|
+
const body = await flow(`/api/invoicing/invoices/${invoice.id}`);
|
|
1230
|
+
redraw(body.invoice);
|
|
1231
|
+
} catch (error) {
|
|
1232
|
+
toast(error.message, error.status === 429 ? 'info' : 'danger');
|
|
1233
|
+
if (error.retryAfterMs) ui.holdButton(button, error.retryAfterMs);
|
|
1234
|
+
else button.disabled = false;
|
|
1235
|
+
}
|
|
1236
|
+
},
|
|
1237
|
+
}, 'Create')
|
|
1238
|
+
: null;
|
|
1188
1239
|
const problems = [
|
|
1189
1240
|
// Accepting changes the draft for the business: owners and admins only.
|
|
1190
1241
|
jobChange ? jobChangeReview(ui, jobChange, { money: formatCents, canAccept: context.canIssue, unsaved: changed, done: redraw }) : null,
|
|
1191
|
-
|
|
1242
|
+
problemNotice ? (createClient ? h('div', { class: 'fx-offer' }, problemNotice, createClient) : problemNotice) : null,
|
|
1192
1243
|
];
|
|
1193
1244
|
// A red field names the banner as its reason (for a screen reader).
|
|
1194
|
-
const banner =
|
|
1245
|
+
const banner = problemNotice;
|
|
1195
1246
|
if (banner) banner.id = 'fx-invoice-problem';
|
|
1196
1247
|
for (const name of failed) {
|
|
1197
1248
|
if (!boxes[name]) continue;
|
|
@@ -1280,6 +1331,35 @@ function previewButton(ui, invoiceId, title = 'Shows the invoice as a PDF.') {
|
|
|
1280
1331
|
}, 'Preview');
|
|
1281
1332
|
}
|
|
1282
1333
|
|
|
1334
|
+
// Delete asks first. A Xero draft says so; a never-issued draft says the job
|
|
1335
|
+
// can be invoiced again. Then the list opens.
|
|
1336
|
+
function deleteControl(ui, invoice, { xeroDraft }) {
|
|
1337
|
+
const { h, flow, toast, go } = ui;
|
|
1338
|
+
return h('button', {
|
|
1339
|
+
class: 'fx-btn fx-btn--ghost fx-btn--danger', type: 'button', 'data-delete': '',
|
|
1340
|
+
onclick: async () => {
|
|
1341
|
+
const ok = await ui.dialog({
|
|
1342
|
+
title: 'Delete this invoice?',
|
|
1343
|
+
body: h('p', {}, xeroDraft
|
|
1344
|
+
? 'Also deletes the draft in Xero.'
|
|
1345
|
+
: invoice.job
|
|
1346
|
+
? 'It was never issued. The job can be invoiced again, and the rules decide.'
|
|
1347
|
+
: 'It was never issued, so it is removed.'),
|
|
1348
|
+
confirm: 'Delete', tone: 'danger',
|
|
1349
|
+
});
|
|
1350
|
+
if (!ok) return;
|
|
1351
|
+
try {
|
|
1352
|
+
await flow(`/api/invoicing/invoices/${invoice.id}`, { method: 'DELETE' });
|
|
1353
|
+
toast('Invoice deleted.', 'success', { stay: true });
|
|
1354
|
+
sessionStorage.setItem(NAV_BACK, '1');
|
|
1355
|
+
go('/invoices');
|
|
1356
|
+
} catch (error) {
|
|
1357
|
+
toast(error.message, 'danger', { stay: true });
|
|
1358
|
+
}
|
|
1359
|
+
},
|
|
1360
|
+
}, 'Delete');
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1283
1363
|
// An issued or void invoice: the document as sent, and what can happen next.
|
|
1284
1364
|
function issuedView(invoice, ui, context, redraw) {
|
|
1285
1365
|
const { h, flow, toast } = ui;
|
|
@@ -1301,14 +1381,28 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
1301
1381
|
const linkedHost = h('div', {}, linkedCard(ui, invoice) ?? '');
|
|
1302
1382
|
let xeroStatus = null;
|
|
1303
1383
|
const send = invoice.status === 'issued' && context.canIssue ? sendToXero(invoice, ui, context) : null;
|
|
1384
|
+
let actionBar = null;
|
|
1385
|
+
let deleteButton = null;
|
|
1386
|
+
let recordSync = () => {};
|
|
1304
1387
|
const loadXero = async () => {
|
|
1305
1388
|
if (!context.xero) return;
|
|
1306
1389
|
xeroStatus = (await xeroStatuses(ui, organisationId, [invoice.id])).get(invoice.id) ?? null;
|
|
1307
1390
|
xeroHost.replaceChildren(xeroCard(invoice, ui, context, xeroStatus, loadXero) ?? '');
|
|
1308
1391
|
linkedHost.replaceChildren(linkedCard(ui, invoice, xeroStatus) ?? '');
|
|
1309
1392
|
if (xeroStatus && xeroStatus.state !== 'not_sent') showSent(send);
|
|
1393
|
+
// Delete only while Flow's copy says Draft. Approved and paid stay on Void.
|
|
1394
|
+
const canDelete = invoice.status === 'issued' && context.canIssue && xeroStatus?.state === 'draft';
|
|
1395
|
+
const remove = actionBar?.querySelector('[data-delete]');
|
|
1396
|
+
if (canDelete && !remove && actionBar) {
|
|
1397
|
+
deleteButton = deleteControl(ui, invoice, { xeroDraft: true });
|
|
1398
|
+
actionBar.prepend(deleteButton);
|
|
1399
|
+
}
|
|
1400
|
+
if (!canDelete && remove) {
|
|
1401
|
+
remove.remove();
|
|
1402
|
+
deleteButton = null;
|
|
1403
|
+
}
|
|
1404
|
+
recordSync();
|
|
1310
1405
|
};
|
|
1311
|
-
loadXero();
|
|
1312
1406
|
// Voiding in Flow never changes Xero: say so while Xero still holds it live.
|
|
1313
1407
|
const voidBody = () => {
|
|
1314
1408
|
const live = xeroStatus?.xero && !['VOIDED', 'DELETED'].includes(xeroStatus.xero.status);
|
|
@@ -1323,16 +1417,29 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
1323
1417
|
const reviseButton = invoice.status === 'issued' && context.canIssue
|
|
1324
1418
|
? h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise') : null;
|
|
1325
1419
|
const preview = previewButton(ui, invoice.id);
|
|
1420
|
+
actionBar = h('div', { class: 'fx-ph__actions' }, [voidButton, reviseButton, preview, send].filter(Boolean));
|
|
1326
1421
|
const header = h('header', { class: 'fx-ph' },
|
|
1327
1422
|
h('div', { class: 'fx-ph__text' },
|
|
1328
1423
|
h('h1', { class: 'fx-title' }, invoice.number),
|
|
1329
1424
|
h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] }, TAB_LABEL[invoice.tab] ?? 'Approved')),
|
|
1330
|
-
|
|
1425
|
+
actionBar);
|
|
1331
1426
|
const chrome = mountRecord(ui, invoice, header, () => send, (skip) => [
|
|
1427
|
+
menuEntry(deleteButton, 'trash-2', skip),
|
|
1332
1428
|
menuEntry(voidButton, 'ban', skip),
|
|
1333
1429
|
menuEntry(reviseButton, 'pencil', skip),
|
|
1334
1430
|
menuEntry(preview, 'eye', skip),
|
|
1335
1431
|
]);
|
|
1432
|
+
recordSync = chrome.sync;
|
|
1433
|
+
loadXero();
|
|
1434
|
+
const history = [
|
|
1435
|
+
...(invoice.versions ?? []).map((v) => h('li', {},
|
|
1436
|
+
h('span', { class: 'fx-strong' }, v.version === 1 ? 'Version 1' : `Revised (version ${v.version})`),
|
|
1437
|
+
h('span', { class: 'fx-dim' }, new Date(v.issuedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })))),
|
|
1438
|
+
// Void, including a delete that removed the Xero draft, sits on the same list.
|
|
1439
|
+
...(invoice.status === 'void' ? [h('li', {},
|
|
1440
|
+
h('span', { class: 'fx-strong' }, 'Void'),
|
|
1441
|
+
h('span', { class: 'fx-dim' }, new Date(invoice.updatedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })))] : []),
|
|
1442
|
+
];
|
|
1336
1443
|
return [
|
|
1337
1444
|
chrome.record,
|
|
1338
1445
|
chrome.pager,
|
|
@@ -1357,11 +1464,9 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
1357
1464
|
h('aside', { class: 'fx-invoice__aside' },
|
|
1358
1465
|
linkedHost,
|
|
1359
1466
|
xeroHost,
|
|
1360
|
-
|
|
1467
|
+
history.length ? h('section', { class: 'fx-card fx-side-card' },
|
|
1361
1468
|
h('h3', { class: 'fx-label' }, 'History'),
|
|
1362
|
-
h('ol', { class: 'fx-timeline' },
|
|
1363
|
-
h('span', { class: 'fx-strong' }, v.version === 1 ? 'Version 1' : `Revised (version ${v.version})`),
|
|
1364
|
-
h('span', { class: 'fx-dim' }, new Date(v.issuedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })))))) : null)),
|
|
1469
|
+
h('ol', { class: 'fx-timeline' }, history)) : null)),
|
|
1365
1470
|
chrome.foot,
|
|
1366
1471
|
chrome.menuPanel,
|
|
1367
1472
|
];
|
package/server/ui/kit.js
CHANGED
|
@@ -381,14 +381,19 @@ function inPlace(event) {
|
|
|
381
381
|
export function tileCard({ href, mark, title, status, blurb, meta, action = 'Manage', row = false }) {
|
|
382
382
|
const logo = typeof mark === 'string' ? h('span', { class: 'fx-logo fx-logo--icon', 'aria-hidden': 'true' }, lucide(h, mark)) : mark;
|
|
383
383
|
const pill = status ? h('span', { class: 'fx-status', 'data-tone': status.tone ?? 'mute' }, status.label) : null;
|
|
384
|
+
// The name is cut with "…" on a narrow row; its title is the full name.
|
|
385
|
+
// The › shows only on a phone, where every card is one row.
|
|
386
|
+
const name = h('h3', { title }, title);
|
|
387
|
+
const go = h('span', { class: 'fx-tile__go', 'aria-hidden': 'true' }, lucide(h, 'chevron-right'));
|
|
384
388
|
if (row) {
|
|
385
389
|
return applyPhoneRule(h('a', { class: 'fx-tile', href, onclick: inPlace },
|
|
386
390
|
logo,
|
|
387
|
-
|
|
388
|
-
pill ?? (meta ? h('span', { class: 'fx-tile__meta' }, meta) : null)
|
|
391
|
+
name,
|
|
392
|
+
pill ?? (meta ? h('span', { class: 'fx-tile__meta' }, meta) : null),
|
|
393
|
+
go), 'tileCard');
|
|
389
394
|
}
|
|
390
395
|
return applyPhoneRule(h('a', { class: `fx-int-card${blurb ? '' : ' fx-int-card--bare'}`, href, onclick: inPlace },
|
|
391
|
-
h('header', { class: 'fx-int-card__head' }, logo,
|
|
396
|
+
h('header', { class: 'fx-int-card__head' }, logo, name, pill, go),
|
|
392
397
|
blurb ? h('p', { class: 'fx-int-card__blurb' }, blurb) : null,
|
|
393
398
|
h('span', { class: 'fx-int-card__btn' }, action)), 'tileCard');
|
|
394
399
|
}
|
|
@@ -406,16 +411,24 @@ export function tileSection(title, cards, { href, more = 'Show all' } = {}) {
|
|
|
406
411
|
// A dialog that fills the screen has no pixel outside it, so an accessibility
|
|
407
412
|
// check cannot tell it is modal and reads the page underneath. While one is
|
|
408
413
|
// open, every other top-level node is inert. The attribute is what the check
|
|
409
|
-
// reads; the browser's own modal behaviour is unchanged.
|
|
410
|
-
|
|
411
|
-
|
|
414
|
+
// reads; the browser's own modal behaviour is unchanged. `ignore` is a dialog
|
|
415
|
+
// that is closing: a browser may still mark it open while it fires close.
|
|
416
|
+
function shieldBehindDialogs(ignore = null) {
|
|
417
|
+
const top = [...document.querySelectorAll('dialog[open]')].filter((el) => el !== ignore).at(-1) ?? null;
|
|
412
418
|
for (const child of document.body.children) {
|
|
413
419
|
if (top && child !== top) child.setAttribute('inert', '');
|
|
414
420
|
else child.removeAttribute('inert');
|
|
415
421
|
}
|
|
416
422
|
}
|
|
417
423
|
// Node tests import this module and have no HTMLDialogElement. The wrap only
|
|
418
|
-
// matters in the browser
|
|
424
|
+
// matters in the browser. showModal is patched because nothing else announces
|
|
425
|
+
// that a dialog opened. close() drops the shield for a dialog closed in
|
|
426
|
+
// script. A form with method="dialog", and Escape, close the dialog inside
|
|
427
|
+
// the browser and never call close(). Chrome then fires toggle, and close,
|
|
428
|
+
// on a later turn. Until one of those runs, the inert attribute would stay
|
|
429
|
+
// and no click would land (an invoice Delete did this, until a reload).
|
|
430
|
+
// close does not bubble, so the listeners are on the capture path. A popover
|
|
431
|
+
// toggle is ignored: only a dialog clears the shield.
|
|
419
432
|
if (typeof HTMLDialogElement !== 'undefined') {
|
|
420
433
|
const openDialog = HTMLDialogElement.prototype.showModal;
|
|
421
434
|
const closeDialog = HTMLDialogElement.prototype.close;
|
|
@@ -426,9 +439,16 @@ if (typeof HTMLDialogElement !== 'undefined') {
|
|
|
426
439
|
};
|
|
427
440
|
HTMLDialogElement.prototype.close = function close(...args) {
|
|
428
441
|
const result = closeDialog.apply(this, args);
|
|
429
|
-
shieldBehindDialogs();
|
|
442
|
+
shieldBehindDialogs(this);
|
|
430
443
|
return result;
|
|
431
444
|
};
|
|
445
|
+
const onDialogClosed = (event) => {
|
|
446
|
+
if (event.target instanceof HTMLDialogElement) shieldBehindDialogs(event.target);
|
|
447
|
+
};
|
|
448
|
+
document.addEventListener('close', onDialogClosed, true);
|
|
449
|
+
document.addEventListener('toggle', (event) => {
|
|
450
|
+
if (event.newState === 'closed') onDialogClosed(event);
|
|
451
|
+
}, true);
|
|
432
452
|
}
|
|
433
453
|
|
|
434
454
|
// The trail above a page one level down: crumbs(['/integrations', 'Integrations'], 'Xero').
|
package/server/ui/mobile.js
CHANGED
|
@@ -564,7 +564,8 @@ export const RULES = {
|
|
|
564
564
|
"component": "tileCard",
|
|
565
565
|
"direction": "one-line rows",
|
|
566
566
|
"phone": {
|
|
567
|
-
"layout": "one-line"
|
|
567
|
+
"layout": "one-line",
|
|
568
|
+
"rowHeight": 56
|
|
568
569
|
},
|
|
569
570
|
"tablet": {
|
|
570
571
|
"layout": "cards"
|
|
@@ -575,7 +576,8 @@ export const RULES = {
|
|
|
575
576
|
"touch": 44,
|
|
576
577
|
"pageOverflow": "none",
|
|
577
578
|
"screens": [
|
|
578
|
-
"rules"
|
|
579
|
+
"rules",
|
|
580
|
+
"integrations"
|
|
579
581
|
],
|
|
580
582
|
"card": "Tiles"
|
|
581
583
|
},
|
|
@@ -583,7 +585,8 @@ export const RULES = {
|
|
|
583
585
|
"component": "tileSection",
|
|
584
586
|
"direction": "one-line rows",
|
|
585
587
|
"phone": {
|
|
586
|
-
"layout": "one-line"
|
|
588
|
+
"layout": "one-line",
|
|
589
|
+
"rowHeight": 56
|
|
587
590
|
},
|
|
588
591
|
"tablet": {
|
|
589
592
|
"layout": "cards"
|
package/server/ui/nav-pins.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
export const NAV_PAGES = Object.freeze([
|
|
7
7
|
'jobs',
|
|
8
8
|
'invoices',
|
|
9
|
+
'contacts',
|
|
9
10
|
'reconcile',
|
|
10
11
|
'price-list',
|
|
11
12
|
'invoicing-settings',
|
|
@@ -16,11 +17,13 @@ export const NAV_PAGES = Object.freeze([
|
|
|
16
17
|
|
|
17
18
|
export const NAV_LOCKED = Object.freeze(['jobs', 'invoices']);
|
|
18
19
|
|
|
19
|
-
// Today's sidebar, before a person has chosen: daily work,
|
|
20
|
-
//
|
|
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.
|
|
21
23
|
export const NAV_DEFAULT = Object.freeze([
|
|
22
24
|
'jobs',
|
|
23
25
|
'invoices',
|
|
26
|
+
'contacts',
|
|
24
27
|
'reconcile',
|
|
25
28
|
'price-list',
|
|
26
29
|
'invoicing-settings',
|
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', 'mobile.js', 'person.js', 'pager.js', 'nav-pins.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';
|