@arcforgelabs/flow 2026.10.52 → 2026.10.53-2
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/core/captures.js +34 -4
- package/server/core/sending.js +13 -1
- package/server/db/schema.js +2 -1
- package/server/integrations/seazona/lanes.js +44 -27
- package/server/ui/captures.js +7 -1
- package/server/ui/custom-fields.js +3 -9
- package/server/ui/flow.css +2 -1
- package/server/ui/invoicing.js +16 -0
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.53-2",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@arcforgelabs/flow",
|
|
9
|
-
"version": "2026.10.
|
|
9
|
+
"version": "2026.10.53-2",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
package/package.json
CHANGED
package/server/core/captures.js
CHANGED
|
@@ -3,7 +3,8 @@ import { and, desc, eq, gt, isNotNull, isNull, lt, or, sql } from 'drizzle-orm';
|
|
|
3
3
|
import { CAPTURE_DECISIONS, captures } from '../db/schema.js';
|
|
4
4
|
import { FlowError, notFound } from './errors.js';
|
|
5
5
|
import { matchJobs, proposeChange, readDate } from './capture-match.js';
|
|
6
|
-
import { listFields, setJobFieldValue } from './custom-fields.js';
|
|
6
|
+
import { listFields, listJobFieldValues, setJobFieldValue } from './custom-fields.js';
|
|
7
|
+
import { JOB_FIELDS } from '../ui/capture-fields.js';
|
|
7
8
|
|
|
8
9
|
// Shown when the module is off or the keys are missing. Type it still works.
|
|
9
10
|
const READER_OFF = 'reader turned off';
|
|
@@ -253,7 +254,7 @@ export async function matchCapture(db, storage, organisationId, id, { jobs, fiel
|
|
|
253
254
|
state: 'ready',
|
|
254
255
|
problem: null,
|
|
255
256
|
match,
|
|
256
|
-
...(replace ? { proposal: proposeChange(read, match, jobs) } : {}),
|
|
257
|
+
...(replace ? { proposal: await toCustomFields(db, organisationId, proposeChange(read, match, jobs)) } : {}),
|
|
257
258
|
updatedAt: sql`now()`,
|
|
258
259
|
}).where(and(
|
|
259
260
|
eq(captures.organisationId, organisationId),
|
|
@@ -305,14 +306,43 @@ function fieldValue(field, text) {
|
|
|
305
306
|
}
|
|
306
307
|
}
|
|
307
308
|
|
|
309
|
+
const jobOf = (jobId) => {
|
|
310
|
+
const at = jobId.indexOf(':');
|
|
311
|
+
return { sourceSystem: jobId.slice(0, at), sourceJobRef: jobId.slice(at + 1) };
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// A custom field's stored value as the sheet shows it ("from").
|
|
315
|
+
function shownValue(field, value) {
|
|
316
|
+
if (value == null) return '';
|
|
317
|
+
if (field.type === 'dropdown') return field.options?.find((option) => option.id === value)?.name ?? '';
|
|
318
|
+
if (field.type === 'checkbox') return value ? 'Yes' : 'No';
|
|
319
|
+
return String(value);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// A read field goes to the lab's custom field of the same name ("Shade")
|
|
323
|
+
// when there is one: Flow owns it, so Apply writes it. Any other field stays
|
|
324
|
+
// the work system's; the sheet marks it to change there (maintainer, #357).
|
|
325
|
+
async function toCustomFields(db, organisationId, proposal) {
|
|
326
|
+
if (proposal.action !== 'update') return proposal;
|
|
327
|
+
const byName = new Map((await listFields(db, organisationId)).map((field) => [field.name.trim().toLowerCase(), field]));
|
|
328
|
+
const labels = new Map(JOB_FIELDS.map((field) => [field.key, field.label.toLowerCase()]));
|
|
329
|
+
const values = proposal.jobId
|
|
330
|
+
? new Map((await listJobFieldValues(db, organisationId, { jobs: [jobOf(proposal.jobId)] })).map((row) => [row.fieldId, row.value]))
|
|
331
|
+
: new Map();
|
|
332
|
+
const changes = proposal.changes.map((change) => {
|
|
333
|
+
const field = byName.get(labels.get(change.field));
|
|
334
|
+
return field ? { ...change, field: `custom_${field.id}`, from: shownValue(field, values.get(field.id)) } : change;
|
|
335
|
+
}).filter((change) => change.from !== change.to);
|
|
336
|
+
return { ...proposal, changes };
|
|
337
|
+
}
|
|
338
|
+
|
|
308
339
|
// Apply writes what Flow owns: the job's custom fields, each with its own
|
|
309
340
|
// event (who, when, from and to). A field the work system owns (due date,
|
|
310
341
|
// shade) stays there; the result sheet links to the job in that system.
|
|
311
342
|
// Every value is checked before the first write, so a bad one writes none.
|
|
312
343
|
async function applyToJob(db, organisationId, proposal, personId) {
|
|
313
344
|
if (proposal?.action !== 'update' || !proposal.jobId) return;
|
|
314
|
-
const
|
|
315
|
-
const job = { sourceSystem: proposal.jobId.slice(0, at), sourceJobRef: proposal.jobId.slice(at + 1) };
|
|
345
|
+
const job = jobOf(proposal.jobId);
|
|
316
346
|
const fields = new Map((await listFields(db, organisationId)).map((field) => [`custom_${field.id}`, field]));
|
|
317
347
|
const writes = proposal.changes
|
|
318
348
|
.filter((change) => fields.has(change.field))
|
package/server/core/sending.js
CHANGED
|
@@ -439,7 +439,8 @@ export async function sendQueued(db, organisationId, { accounting, limit = WORKE
|
|
|
439
439
|
summary.waiting += 1;
|
|
440
440
|
} else {
|
|
441
441
|
const error = result.error ?? { code: `send_${result.outcome}`, message: `Sending ended as ${result.outcome}.` };
|
|
442
|
-
|
|
442
|
+
const xero = xeroMatch(error);
|
|
443
|
+
await settle(db, organisationId, result.invoiceId, { sendState: 'failed', sendError: { code: error.code, message: error.message, ...(error.short ? { short: error.short } : {}), ...(xero ? { xero } : {}) } });
|
|
443
444
|
summary.failed += 1;
|
|
444
445
|
summary.refused = [...new Set([...(summary.refused ?? []), error.code])];
|
|
445
446
|
const fact = refusalFact(known.get(result.invoiceId), error);
|
|
@@ -450,6 +451,17 @@ export async function sendQueued(db, organisationId, { accounting, limit = WORKE
|
|
|
450
451
|
return summary;
|
|
451
452
|
}
|
|
452
453
|
|
|
454
|
+
// The Xero invoice a refusal matched, kept so the page can link to it.
|
|
455
|
+
// Only an id, a number and a status. Anything else in the error stays out.
|
|
456
|
+
function xeroMatch(error) {
|
|
457
|
+
const xero = error?.details?.xero;
|
|
458
|
+
if (!xero || typeof xero !== 'object') return null;
|
|
459
|
+
if (!INVOICE_ID.test(xero.invoiceId ?? '')) return null;
|
|
460
|
+
const number = INVOICE_NUMBER.test(xero.number ?? '') ? xero.number : null;
|
|
461
|
+
const status = /^[A-Z0-9_]{1,32}$/.test(xero.status ?? '') ? xero.status : null;
|
|
462
|
+
return { invoiceId: xero.invoiceId, ...(number ? { number } : {}), ...(status ? { status } : {}) };
|
|
463
|
+
}
|
|
464
|
+
|
|
453
465
|
function settle(db, organisationId, invoiceId, fields) {
|
|
454
466
|
return db.update(invoices).set({ ...fields, sendUpdatedAt: sql`now()` })
|
|
455
467
|
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.id, invoiceId), eq(invoices.sendState, 'queued')));
|
package/server/db/schema.js
CHANGED
|
@@ -112,7 +112,8 @@ export const invoices = pgTable('invoices', {
|
|
|
112
112
|
readyAt: timestamp('ready_at', { withTimezone: true }),
|
|
113
113
|
readyBy: uuid('ready_by').references(() => people.id, { onDelete: 'set null' }),
|
|
114
114
|
// Sending to the accounting system (server/core/sending.js): queued by a
|
|
115
|
-
// person, then sent or failed. `sendError` is { code, message
|
|
115
|
+
// person, then sent or failed. `sendError` is { code, message, short?, xero? }
|
|
116
|
+
// while failed. `xero` is the matched invoice { invoiceId, number, status }.
|
|
116
117
|
sendState: text('send_state'),
|
|
117
118
|
sendError: jsonb('send_error'),
|
|
118
119
|
sendRequestedBy: uuid('send_requested_by').references(() => people.id, { onDelete: 'set null' }),
|
|
@@ -430,47 +430,62 @@ const laneRuns = {
|
|
|
430
430
|
|
|
431
431
|
async orders_recent(ctx) {
|
|
432
432
|
const { db, client, sync, at } = ctx;
|
|
433
|
-
const
|
|
434
|
-
const lookbackStart =
|
|
435
|
-
const
|
|
436
|
-
const
|
|
437
|
-
const partialDay = Date.parse(partial?.day ?? '');
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
433
|
+
const today = floorUtcDay(at.getTime());
|
|
434
|
+
const lookbackStart = today - sync.orderLookbackDays * DAY_MS;
|
|
435
|
+
const cursor = ctx.state?.cursor ?? {};
|
|
436
|
+
const doneThrough = Date.parse(cursor.completedThrough ?? '');
|
|
437
|
+
const partialDay = Date.parse(cursor.partial?.day ?? '');
|
|
438
|
+
const todayPartialDay = Date.parse(cursor.todayPartial?.day ?? '');
|
|
439
|
+
let partial = partialDay >= lookbackStart && partialDay < today ? cursor.partial : null;
|
|
440
|
+
// A partial newest day becomes an older day at midnight. Keep its page
|
|
441
|
+
// unless an older partial already owns the rolling walk; that walk will
|
|
442
|
+
// revisit the former newest day on its next cycle too.
|
|
443
|
+
if (!partial && todayPartialDay >= lookbackStart && todayPartialDay < today) partial = cursor.todayPartial;
|
|
444
|
+
let resume = partial ? Date.parse(partial.day) : Number.isFinite(doneThrough) ? Math.max(lookbackStart, floorUtcDay(doneThrough) + DAY_MS) : lookbackStart;
|
|
445
|
+
if (resume >= today) resume = lookbackStart;
|
|
446
|
+
|
|
447
|
+
// Give today and the rolling previous-day walk a share of the existing
|
|
448
|
+
// budgets. With only one day or call available, alternate them instead
|
|
449
|
+
// of permanently starving either. Missing fields migrate naturally.
|
|
450
|
+
const single = sync.listPagesPerRun === 1 || sync.orderDaysPerRun === 1;
|
|
451
|
+
const readToday = !single || !cursor.historyNext;
|
|
447
452
|
const pageSize = Math.min(ORDER_PAGE_MAX, sync.listCap);
|
|
448
453
|
const byId = new Map();
|
|
449
454
|
const days = [];
|
|
450
455
|
let calls = 0;
|
|
451
456
|
let lastDone = null;
|
|
452
|
-
let stoppedAt =
|
|
453
|
-
|
|
457
|
+
let stoppedAt = partial;
|
|
458
|
+
let todayPartial = todayPartialDay === today ? cursor.todayPartial : partialDay === today ? cursor.partial : null;
|
|
459
|
+
async function readDay(day, progress, callLimit) {
|
|
454
460
|
const label = `order list for ${formatOrderedDay(day)}`;
|
|
455
|
-
let page =
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
break;
|
|
461
|
-
}
|
|
461
|
+
let page = Math.max(1, Number(progress?.page) || 1);
|
|
462
|
+
// Keep at most one page of IDs in the existing cursor, so splitting
|
|
463
|
+
// the budget never disables the guard against an API ignoring page.
|
|
464
|
+
let previous = progress?.previousIds ? new Set(progress.previousIds) : null;
|
|
465
|
+
while (calls < callLimit) {
|
|
462
466
|
calls += 1;
|
|
463
467
|
if (!days.includes(day)) days.push(day);
|
|
464
468
|
const list = listOrFail(await client.get('orders/', { ordered: formatOrderedDay(day), page, pageSize }), label);
|
|
465
469
|
const ids = list.map((item) => text(item?.id)).filter(Boolean);
|
|
466
|
-
// A Seazona that does not page answers the same rows again.
|
|
467
470
|
if (previous && ids.length && ids.every((id) => previous.has(id))) throw cannotGoOn(label, sync, 'the next page answers the same orders');
|
|
468
471
|
for (const item of list) if (text(item?.id)) byId.set(text(item.id), item);
|
|
469
|
-
|
|
472
|
+
// The guide's on-or-after lists and single-day lists both work.
|
|
473
|
+
if (list.length < pageSize || placedAfter(list[list.length - 1], day + DAY_MS)) return null;
|
|
470
474
|
previous = new Set(ids);
|
|
471
475
|
page += 1;
|
|
472
476
|
}
|
|
473
|
-
|
|
477
|
+
return { day: new Date(day).toISOString(), page, previousIds: [...previous] };
|
|
478
|
+
}
|
|
479
|
+
if (readToday) {
|
|
480
|
+
const todayCalls = single ? sync.listPagesPerRun : Math.ceil(sync.listPagesPerRun / 2);
|
|
481
|
+
todayPartial = await readDay(today, todayPartial, todayCalls);
|
|
482
|
+
}
|
|
483
|
+
if (!single || !readToday) {
|
|
484
|
+
for (let day = resume; day < today && days.length < sync.orderDaysPerRun && calls < sync.listPagesPerRun; day += DAY_MS) {
|
|
485
|
+
stoppedAt = await readDay(day, day === resume ? partial : null, sync.listPagesPerRun);
|
|
486
|
+
if (stoppedAt) break;
|
|
487
|
+
lastDone = day;
|
|
488
|
+
}
|
|
474
489
|
}
|
|
475
490
|
const known = await readKnown(db, 'orders', [...byId.keys()]);
|
|
476
491
|
const summary = { created: 0, changed: 0, unchanged: 0 };
|
|
@@ -506,7 +521,9 @@ const laneRuns = {
|
|
|
506
521
|
cursorTime: through === null ? ctx.state?.cursorTime ?? null : new Date(through),
|
|
507
522
|
cursor: {
|
|
508
523
|
...(through === null ? {} : { completedThrough: new Date(through).toISOString() }),
|
|
509
|
-
...(stoppedAt ? { partial:
|
|
524
|
+
...(stoppedAt ? { partial: stoppedAt } : {}),
|
|
525
|
+
...(todayPartial ? { todayPartial } : {}),
|
|
526
|
+
...(single ? { historyNext: readToday } : {}),
|
|
510
527
|
},
|
|
511
528
|
};
|
|
512
529
|
},
|
package/server/ui/captures.js
CHANGED
|
@@ -180,8 +180,14 @@ export async function renderCaptures(main, ui) {
|
|
|
180
180
|
? `${job.caseRef ? `Job ${job.caseRef}` : job.jobId} · ${job.clientName ?? ''}`
|
|
181
181
|
: proposal.action === 'create' ? 'New job' : 'Pick a job';
|
|
182
182
|
|
|
183
|
+
// A field the work system owns: Apply leaves it; the person changes it
|
|
184
|
+
// there (until a two-way sync can send it, #357).
|
|
185
|
+
const system = proposal.action === 'update' && proposal.jobId ? proposal.jobId.split(':')[0] : null;
|
|
186
|
+
const systemName = system ? system[0].toUpperCase() + system.slice(1) : null;
|
|
187
|
+
const elsewhere = (change) => systemName && !change.field.startsWith('custom_');
|
|
183
188
|
const changeRow = (change) => h('div', { class: 'fx-capture__change', 'data-unsure': change.sure != null && change.sure < SURE_LINE ? 'true' : 'false' },
|
|
184
|
-
h('span', { class: 'fx-capture__field' }, labels.get(change.field) ?? change.field
|
|
189
|
+
h('span', { class: 'fx-capture__field' }, labels.get(change.field) ?? change.field,
|
|
190
|
+
elsewhere(change) ? h('span', { class: 'fx-capture__where', title: `Change this in ${systemName}` }, systemName) : null),
|
|
185
191
|
h('span', { class: 'fx-capture__from' }, change.from || '–'),
|
|
186
192
|
h('span', { class: 'fx-capture__arrow', 'aria-hidden': 'true' }, '→'),
|
|
187
193
|
h('span', { class: 'fx-capture__to' }, change.to));
|
|
@@ -111,7 +111,7 @@ export async function renderCustomFields(main, ui) {
|
|
|
111
111
|
const { h, flow, toast, notice, dialog, pageHeader, me } = ui;
|
|
112
112
|
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
113
113
|
const host = h('div', { class: 'fx-card fx-cfm__card' }, h('div', { class: 'fx-skeleton' }));
|
|
114
|
-
const addButton = h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button', disabled
|
|
114
|
+
const addButton = h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button', disabled, title: 'New custom field' }, lucide(h, 'plus'), 'New');
|
|
115
115
|
main.append(pageHeader('Custom fields', { help: { href: DOCS, label: 'How custom fields work' } }), host);
|
|
116
116
|
|
|
117
117
|
let data = null;
|
|
@@ -309,7 +309,7 @@ export async function renderCustomFields(main, ui) {
|
|
|
309
309
|
]);
|
|
310
310
|
const filterButton = h('button', { class: 'fx-chip fx-cfm__tool fx-cfm__tool--dashed', type: 'button' });
|
|
311
311
|
const filterMenu = popMenu(filterButton, (close) => [
|
|
312
|
-
item(view.archived ? 'check' : null, '
|
|
312
|
+
item(view.archived ? 'check' : null, 'Archived', () => { view.archived = !view.archived; close(); draw(); }, { role: 'menuitemcheckbox', checked: view.archived }),
|
|
313
313
|
]);
|
|
314
314
|
const toolbar = h('div', { class: 'fx-cfm__bar' }, search, typeButton, typeMenu, filterButton, filterMenu, h('span', { class: 'fx-cfm__gap' }), addButton);
|
|
315
315
|
const table = h('div', { class: 'fx-cfm', role: 'table', 'aria-label': 'Custom fields' });
|
|
@@ -505,15 +505,9 @@ export async function renderCustomFields(main, ui) {
|
|
|
505
505
|
parts.push(...group('archived', { text: 'Archived', icon: 'archive', color: 'gray' }, fields));
|
|
506
506
|
}
|
|
507
507
|
if (!count && (view.q || view.types.size)) parts.push(h('p', { class: 'fx-cfm__empty' }, 'No custom field matches.'));
|
|
508
|
-
if (!
|
|
509
|
-
parts.push(h('p', { class: 'fx-cfm__empty' }, 'No custom fields yet. Create one, for example Priority or Technician, to group the Jobs board by it.'));
|
|
510
|
-
}
|
|
508
|
+
else if (!count) parts.push(h('p', { class: 'fx-cfm__empty' }, 'No custom fields'));
|
|
511
509
|
table.replaceChildren(...parts);
|
|
512
|
-
// The count is what the table shows, the work systems' fields too.
|
|
513
|
-
foot.replaceChildren(h('span', {}, `${count} ${count === 1 ? 'custom field' : 'custom fields'}`));
|
|
514
510
|
}
|
|
515
|
-
const foot = h('div', { class: 'fx-cfm__foot' });
|
|
516
|
-
host.append(foot);
|
|
517
511
|
|
|
518
512
|
async function load() {
|
|
519
513
|
try {
|
package/server/ui/flow.css
CHANGED
|
@@ -1093,6 +1093,7 @@ th:hover > .fx-th-grip::after { background: var(--t-brd-hi); }
|
|
|
1093
1093
|
min-height: 44px; padding: 4px 8px; border-bottom: 1px solid var(--fx-line-soft);
|
|
1094
1094
|
}
|
|
1095
1095
|
.fx-capture__field { color: var(--t-dim); }
|
|
1096
|
+
.fx-capture__where { display: block; font-size: 12px; color: var(--t-dim); }
|
|
1096
1097
|
.fx-capture__from { color: var(--t-dim); text-decoration: line-through; overflow-wrap: anywhere; }
|
|
1097
1098
|
.fx-capture__arrow { color: var(--t-dim); }
|
|
1098
1099
|
.fx-capture__to { color: var(--t-strong); font-weight: 600; overflow-wrap: anywhere; }
|
|
@@ -1682,7 +1683,6 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1682
1683
|
.fx-cfm__create .fx-lucide { width: 14px; height: 14px; }
|
|
1683
1684
|
.fx-cfm__empty { margin: 0; padding: 20px 16px; color: var(--t-dim); }
|
|
1684
1685
|
.fx-cfm > .fx-notice { margin: 12px 16px 0; }
|
|
1685
|
-
.fx-cfm__foot { display: flex; justify-content: space-between; gap: 12px; padding: 12px 16px; border-top: 1px solid var(--fx-line-soft); color: var(--t-dim); font-size: 13px; }
|
|
1686
1686
|
.fx-pop__item:disabled { opacity: .5; cursor: default; }
|
|
1687
1687
|
.fx-pop__item[data-tone="danger"], .fx-pop__item[data-tone="danger"] .fx-pop__tick { color: var(--t-danger); }
|
|
1688
1688
|
.fx-pop__item .fx-lucide { width: 15px; height: 15px; }
|
|
@@ -1759,6 +1759,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1759
1759
|
.fx-cfm__head, .fx-cfm__row { padding-left: 16px; }
|
|
1760
1760
|
.fx-cfm__create { padding-left: 16px; }
|
|
1761
1761
|
.fx-cfm__search { flex: 1 1 100%; width: auto; }
|
|
1762
|
+
.fx-cfm__bar :is(.fx-cfm__search, .fx-cfm__tool, .fx-btn) { height: auto; min-height: 44px; }
|
|
1762
1763
|
.fx-cfm__edit { display: none !important; }
|
|
1763
1764
|
/* Custom fields uses the same phone slots as a column list: name and the
|
|
1764
1765
|
menu on line 1, up to three facts on line 2, 56px. */
|
package/server/ui/invoicing.js
CHANGED
|
@@ -1423,6 +1423,21 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
1423
1423
|
? h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise') : null;
|
|
1424
1424
|
const preview = previewButton(ui, invoice.id);
|
|
1425
1425
|
actionBar = h('div', { class: 'fx-ph__actions' }, [voidButton, reviseButton, preview, send].filter(Boolean));
|
|
1426
|
+
// The same problem notice as a draft. A matched Xero invoice gets the
|
|
1427
|
+
// same Open in Xero link as the Xero card (go.xero.com, reconcile.js).
|
|
1428
|
+
const found = invoice.problems ?? [];
|
|
1429
|
+
const shorts = found.length && found.every((p) => p.short) ? [...new Set(found.map((p) => p.short))].join(', ') : null;
|
|
1430
|
+
const problemNotice = found.length
|
|
1431
|
+
? ui.notice([...new Set(found.map((p) => brief(p.message)))].join(' '), 'danger', 'Problem.', { short: shorts })
|
|
1432
|
+
: null;
|
|
1433
|
+
const matched = found.find((p) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(p.xero?.invoiceId ?? ''));
|
|
1434
|
+
const openXero = matched ? h('a', {
|
|
1435
|
+
class: 'fx-btn fx-btn--sm fx-btn--ghost',
|
|
1436
|
+
href: `https://go.xero.com/AccountsReceivable/View.aspx?InvoiceID=${encodeURIComponent(matched.xero.invoiceId)}`,
|
|
1437
|
+
target: '_blank',
|
|
1438
|
+
rel: 'noopener',
|
|
1439
|
+
}, 'Open in Xero') : null;
|
|
1440
|
+
const problemRow = problemNotice ? (openXero ? h('div', { class: 'fx-offer' }, problemNotice, openXero) : problemNotice) : null;
|
|
1426
1441
|
const header = h('header', { class: 'fx-ph' },
|
|
1427
1442
|
h('div', { class: 'fx-ph__text' },
|
|
1428
1443
|
h('h1', { class: 'fx-title' }, invoice.number),
|
|
@@ -1449,6 +1464,7 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
1449
1464
|
chrome.record,
|
|
1450
1465
|
chrome.pager,
|
|
1451
1466
|
header,
|
|
1467
|
+
problemRow,
|
|
1452
1468
|
h('div', { class: 'fx-invoice__grid' },
|
|
1453
1469
|
h('div', { class: 'fx-invoice__main' },
|
|
1454
1470
|
h('section', { class: 'fx-card fx-doc' },
|