@arcforgelabs/flow 2026.11.2 → 2026.11.4

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.
Files changed (49) hide show
  1. package/SECURITY.md +12 -0
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +2 -1
  4. package/server/agents/gate.js +25 -5
  5. package/server/api/invoicing.js +119 -3
  6. package/server/app.js +3 -2
  7. package/server/auth/routes.js +20 -0
  8. package/server/auth/session.js +44 -1
  9. package/server/auth/settings.js +27 -0
  10. package/server/core/alerts.js +38 -3
  11. package/server/core/automations.js +8 -3
  12. package/server/core/invoice-rules.js +158 -8
  13. package/server/core/invoices.js +9 -1
  14. package/server/core/invoicing-rules.js +31 -6
  15. package/server/core/job-board.js +16 -1
  16. package/server/core/job-match.js +6 -4
  17. package/server/core/ledger.js +67 -8
  18. package/server/core/pipeline.js +6 -2
  19. package/server/core/reconcile.js +11 -3
  20. package/server/core/sending.js +30 -3
  21. package/server/db/migrations/0027_high_doctor_strange.sql +24 -0
  22. package/server/db/migrations/0028_sharp_power_pack.sql +2 -0
  23. package/server/db/migrations/meta/0027_snapshot.json +5050 -0
  24. package/server/db/migrations/meta/0028_snapshot.json +5062 -0
  25. package/server/db/migrations/meta/_journal.json +14 -0
  26. package/server/db/schema.js +21 -0
  27. package/server/integrations/seazona/feed.js +10 -3
  28. package/server/integrations/seazona/index.js +6 -1
  29. package/server/integrations/seazona/routes.js +65 -2
  30. package/server/integrations/seazona/settings.js +3 -0
  31. package/server/integrations/xero/drafts.js +1 -1
  32. package/server/integrations/xero/index.js +1 -1
  33. package/server/integrations/xero/ledger.js +17 -8
  34. package/server/integrations/xero/links.js +39 -1
  35. package/server/integrations/xero/lookup.js +3 -1
  36. package/server/integrations/xero/routes.js +18 -1
  37. package/server/ui/app-page.js +1 -1
  38. package/server/ui/app.js +7 -4
  39. package/server/ui/flow.css +69 -1
  40. package/server/ui/home.js +120 -0
  41. package/server/ui/invoice-rules.js +11 -3
  42. package/server/ui/invoicing.js +81 -6
  43. package/server/ui/job-board.js +8 -3
  44. package/server/ui/jobs.js +14 -2
  45. package/server/ui/nav-pins.js +5 -3
  46. package/server/ui/reconcile.js +4 -2
  47. package/server/ui/routes.js +1 -1
  48. package/skills/flow/SKILL.md +306 -0
  49. package/skills/flow/flow.sh +121 -0
@@ -0,0 +1,120 @@
1
+ // Home: the page a person lands on. One job first: send the invoices that
2
+ // are ready. The big number is the Ready tab's count, and Send sends them
3
+ // all with the same step as the Invoices page (POST /invoices/send-ready).
4
+ // Beside it, at most three things that need a person most days: invoices
5
+ // that need a look, jobs and invoices to match (Reconcile), and form photos
6
+ // to check (Capture). Each opens its page. No calls of its own to other
7
+ // systems: every number is its page's own.
8
+ import { formatCents, localDate } from '/assets/ui/invoicing.js';
9
+ import { sendModeNote } from '/assets/ui/invoice-notes.js';
10
+ import { lucide } from '/assets/ui/custom-fields.js';
11
+
12
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
13
+
14
+ function greeting(person, now = new Date()) {
15
+ const hour = now.getHours();
16
+ const part = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
17
+ const first = String(person?.name ?? '').trim().split(/\s+/)[0];
18
+ return first ? `${part}, ${first}` : part;
19
+ }
20
+
21
+ export async function renderHome(main, ui) {
22
+ const { h, flow, toast, notice, pageHeader, dialog, go, me } = ui;
23
+ const host = h('div', { class: 'fx-home' });
24
+ main.append(pageHeader(greeting(me?.person)), host);
25
+ const tabQuery = (tab) => `/api/invoicing/invoices?${new URLSearchParams({ status: tab, limit: '1' })}`;
26
+
27
+ async function load() {
28
+ let context;
29
+ let body;
30
+ let pending;
31
+ let toMatch;
32
+ let forms;
33
+ try {
34
+ [context, body, pending, toMatch, forms] = await Promise.all([
35
+ flow('/api/invoicing/context'),
36
+ flow(tabQuery('ready')),
37
+ flow(tabQuery('pending')),
38
+ // The two below are extras: Home still shows without them.
39
+ flow('/api/invoicing/reconcile').catch(() => null),
40
+ flow('/api/captures').catch(() => null),
41
+ ]);
42
+ } catch (error) {
43
+ host.replaceChildren(notice(error.message, 'danger', 'Flow could not load your invoices.'));
44
+ return;
45
+ }
46
+ if (!host.isConnected) return;
47
+ draw(context, body, [
48
+ { href: '/invoices?tab=pending', icon: 'circle-alert', label: 'Need a look', tone: 'danger', count: pending.total, sum: pending.totalCents },
49
+ toMatch && { href: '/reconcile', icon: 'link', label: 'To match', tone: 'warn', count: (toMatch.jobs?.length ?? 0) + (toMatch.invoices?.length ?? 0) },
50
+ forms && { href: '/captures', icon: 'camera', label: 'Forms to check', tone: 'info', count: forms.captures.filter((c) => c.state === 'ready').length },
51
+ ].filter(Boolean));
52
+ }
53
+
54
+ function draw(context, body, items) {
55
+ const to = body.sendsTo;
56
+ const counts = body.counts ?? {};
57
+ const ready = counts.ready ?? 0;
58
+ const blocked = to && body.sendMode && (!body.sendMode.connected || !body.sendMode.writesAllowed);
59
+ const canSend = context.canIssue && !blocked;
60
+
61
+ const hero = ready
62
+ ? h('section', { class: 'fx-home__send', 'aria-labelledby': 'home-ready' },
63
+ h('p', { class: 'fx-home__count', id: 'home-ready' }, String(ready),
64
+ h('span', {}, ready === 1 ? 'invoice ready' : 'invoices ready')),
65
+ h('p', { class: 'fx-home__sum' }, formatCents(body.totalCents)),
66
+ h('button', {
67
+ class: 'fx-btn fx-btn--primary fx-home__btn', type: 'button', disabled: !canSend,
68
+ onclick: (event) => send(to, ready, body.sendMode, event.currentTarget),
69
+ }, lucide(h, 'send'), h('span', {}, 'Send')),
70
+ context.canIssue ? null : h('p', { class: 'fx-home__note' }, 'An owner or admin sends.'),
71
+ blocked ? h('p', { class: 'fx-home__note' }, sendModeNote(to, body.sendMode)) : null)
72
+ : h('section', { class: 'fx-home__send', 'data-done': '', 'aria-labelledby': 'home-ready' },
73
+ h('span', { class: 'fx-home__done', 'aria-hidden': 'true' }, lucide(h, 'check')),
74
+ h('p', { class: 'fx-home__count', id: 'home-ready' }, h('span', {}, 'All caught up')),
75
+ h('p', { class: 'fx-home__sum' }, 'Nothing waits to be sent.'));
76
+
77
+ const needs = h('section', { class: 'fx-home__needs', 'aria-labelledby': 'home-needs' },
78
+ h('h2', { class: 'fx-label', id: 'home-needs' }, 'Needs you'),
79
+ h('ul', {}, items.map((item) => h('li', {},
80
+ h('a', { href: item.href, onclick: open, 'data-tone': item.count ? item.tone : 'clear' },
81
+ h('span', { class: 'fx-home__dot', 'aria-hidden': 'true' }, lucide(h, item.count ? item.icon : 'check')),
82
+ h('span', { class: 'fx-home__what' }, item.label,
83
+ item.count && item.sum ? h('small', {}, formatCents(item.sum)) : null),
84
+ h('span', { class: 'fx-home__n' }, String(item.count)))))));
85
+
86
+ host.replaceChildren(hero, needs);
87
+ }
88
+
89
+ function open(event) {
90
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
91
+ event.preventDefault();
92
+ go(event.currentTarget.getAttribute('href'));
93
+ }
94
+
95
+ async function send(to, ready, sendMode, button) {
96
+ const ok = await dialog({
97
+ title: `Send ${plural(ready, 'invoice', 'invoices')}?`,
98
+ body: h('p', {}, to ? sendModeNote(to, sendMode, 'each one') : 'Each one gets the next number and is locked.'),
99
+ confirm: 'Send',
100
+ });
101
+ if (!ok) return;
102
+ button.disabled = true;
103
+ button.lastChild.textContent = 'Sending';
104
+ try {
105
+ const res = await flow('/api/invoicing/invoices/send-ready', { method: 'POST', body: JSON.stringify({ issueDate: localDate() }) });
106
+ const notes = [
107
+ to ? `${res.issued} on the way to ${to}.` : `${res.issued} approved.`,
108
+ res.held ? `${res.held} need a look.` : '',
109
+ res.waiting ? `${res.waiting} could not be checked with the work system yet: press Send again.` : '',
110
+ res.remaining ? `${res.remaining} more are ready: press Send again.` : '',
111
+ ].filter(Boolean);
112
+ toast(notes.join(' '), res.held || res.waiting || res.remaining ? 'info' : 'success', { stay: Boolean(res.held || res.waiting) });
113
+ } catch (error) {
114
+ toast(error.message, 'danger', { stay: true });
115
+ }
116
+ await load();
117
+ }
118
+
119
+ await load();
120
+ }
@@ -41,7 +41,8 @@ export function ruleSummary(rule, meta) {
41
41
  return `${field.label} ${test} ${value}`;
42
42
  };
43
43
  const then = rule.action.kind === 'pending' ? `Pending: ${rule.action.note}` : ACTION_LABELS[rule.action.kind] ?? rule.action.kind;
44
- return `${rule.conditions.map(part).join(' and ')} · ${then}`;
44
+ const excepted = rule.exceptions ? ` · ${rule.exceptions} ${rule.exceptions === 1 ? 'exception' : 'exceptions'}` : '';
45
+ return `${rule.conditions.map(part).join(' and ')} · ${then}${excepted}`;
45
46
  }
46
47
 
47
48
  export function rulesSection(ui, title = 'Rules') {
@@ -169,7 +170,13 @@ export function rulesSection(ui, title = 'Rules') {
169
170
  placeholder: 'Type a note',
170
171
  });
171
172
  const noteField = h('div', { class: 'fx-field' }, h('span', { class: 'fx-label' }, 'Note on the draft'), note);
172
- const showNote = () => { noteField.hidden = action.value !== 'pending'; };
173
+ // Optional: a price list code the draft offers to add in one press.
174
+ const addItem = h('input', {
175
+ class: 'fx-input', maxlength: '40', value: draft?.action.addItem ?? '', 'aria-label': 'Item to add',
176
+ placeholder: 'Price list code',
177
+ });
178
+ const addField = h('div', { class: 'fx-field' }, h('span', { class: 'fx-label' }, 'Offer to add'), addItem);
179
+ const showNote = () => { noteField.hidden = action.value !== 'pending'; addField.hidden = action.value !== 'pending'; };
173
180
  action.addEventListener('change', showNote);
174
181
  showNote();
175
182
  let editors = [];
@@ -218,6 +225,7 @@ export function rulesSection(ui, title = 'Rules') {
218
225
  label('If all of these match', list, more),
219
226
  label('Then', action),
220
227
  noteField,
228
+ addField,
221
229
  remove),
222
230
  confirm: rule ? 'Save' : 'Add rule',
223
231
  });
@@ -231,7 +239,7 @@ export function rulesSection(ui, title = 'Rules') {
231
239
  name: name.value,
232
240
  icon: icon.value(),
233
241
  conditions: editors.map((e) => e.read()),
234
- action: action.value === 'pending' ? { kind: 'pending', note: note.value } : { kind: action.value },
242
+ action: action.value === 'pending' ? { kind: 'pending', note: note.value, ...(addItem.value.trim() ? { addItem: addItem.value.trim() } : {}) } : { kind: action.value },
235
243
  };
236
244
  const body = rule
237
245
  ? await send(`/api/invoicing/rules/${rule.id}`, 'PUT', payload)
@@ -111,7 +111,7 @@ function toCents(text) {
111
111
 
112
112
  const fromCents = (cents) => (cents == null ? '' : (cents / 100).toFixed(2));
113
113
 
114
- function localDate(date = new Date()) {
114
+ export function localDate(date = new Date()) {
115
115
  const pad = (n) => String(n).padStart(2, '0');
116
116
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
117
117
  }
@@ -146,6 +146,13 @@ function readFilters() {
146
146
 
147
147
  export async function renderInvoiceList(main, ui) {
148
148
  const { h, flow, listHead, listFrame, notice, go, toast } = ui;
149
+ // A link may name the tab (/invoices?tab=draft, from Home). It is kept as
150
+ // the open tab, and the address goes back to /invoices.
151
+ const linked = new URLSearchParams(globalThis.location?.search ?? '').get('tab');
152
+ if (linked) {
153
+ sessionStorage.setItem('flow_invoice_tab', linked);
154
+ history.replaceState(null, '', location.pathname);
155
+ }
149
156
  const saved = OLD_TAB[sessionStorage.getItem('flow_invoice_tab')] ?? sessionStorage.getItem('flow_invoice_tab');
150
157
  // Back from an invoice comes here with the tab, search and scroll it left.
151
158
  const back = readNav();
@@ -933,6 +940,29 @@ const LINE_CARD_READ = { areas: ['desc desc amount', 'qty price gst'], columns:
933
940
  const brief = (text) => String(text ?? '').match(/^.*?[.!?](?=\s|$)/s)?.[0] ?? String(text ?? '');
934
941
 
935
942
  // A draft, or a new invoice before its first save.
943
+ // Lines this invoice has that its job in the work system does not (the
944
+ // invoice detail's sourceGap). Flow cannot add them there, so a yellow line
945
+ // says what to add and links to the job.
946
+ function sourceGapNotice(ui, invoice) {
947
+ const gap = invoice?.sourceGap ?? [];
948
+ if (!gap.length || !invoice.job) return null;
949
+ const system = invoice.job.systemName ?? 'the work system';
950
+ const what = gap.map((l) => `${l.description} (${formatCents(l.unitAmountCents * (l.quantity ?? 1))})`).join(', ');
951
+ const line = ui.notice(`This invoice has ${what}; the job in ${system} does not. Add it there so both agree.`, 'warn', `Not in ${system}.`);
952
+ if (!invoice.job.link?.url) return line;
953
+ return ui.h('div', { class: 'fx-offer' }, line,
954
+ ui.h('a', { class: 'fx-btn fx-btn--sm fx-btn--ghost', href: invoice.job.link.url, target: '_blank', rel: 'noopener' }, `Open in ${system}`));
955
+ }
956
+
957
+ // The exceptions people gave this job: the rule, why, and whether it still
958
+ // holds (it ends when the job's lines or the rule's item change).
959
+ function exceptionsNotice(ui, invoice) {
960
+ const list = invoice?.ruleExceptions ?? [];
961
+ if (!list.length) return null;
962
+ const text = list.map((e) => `${e.rule}: ${e.reason}${e.current ? '' : ' (ended: the job changed)'}`).join('; ');
963
+ return ui.notice(text, 'info', list.length === 1 ? 'Exception.' : 'Exceptions.');
964
+ }
965
+
936
966
  function draftView(invoice, ui, context, redraw) {
937
967
  const { h, flow, toast, go, notice, fieldError } = ui;
938
968
  const isNew = !invoice;
@@ -1267,11 +1297,33 @@ function draftView(invoice, ui, context, redraw) {
1267
1297
  : [];
1268
1298
  // Every matching rule links to its own conditions and existing controls,
1269
1299
  // including custom rules. The target enforces read-only roles itself.
1300
+ // A rule with an item to add offers it in one press, and Skip records an
1301
+ // exception with a reason (POST .../rules/:rule/add-item and /exception).
1302
+ const ruleFix = async (path, payload, done) => {
1303
+ try {
1304
+ const body = await flow(`/api/invoicing/invoices/${invoice.id}/rules/${path}`, { method: 'POST', body: JSON.stringify(payload ?? {}) });
1305
+ ui.toast(done, 'success');
1306
+ redraw(body.invoice);
1307
+ } catch (error) {
1308
+ ui.toast(error.message, 'danger');
1309
+ }
1310
+ };
1311
+ const skipRule = async (p) => {
1312
+ const reason = h('textarea', { class: 'fx-input', rows: 2, maxlength: 200, 'aria-label': 'Reason' });
1313
+ const ok = await ui.dialog({ title: `Skip ${p.short ?? 'this rule'} for this job?`, body: h('div', {}, h('p', {}, 'The rule lists this job as an exception, with your reason.'), reason), confirm: 'Skip' });
1314
+ if (!ok) return;
1315
+ if (!reason.value.trim()) { ui.toast('Give a reason.', 'danger'); return; }
1316
+ await ruleFix(`${p.rule}/exception`, { reason: reason.value.trim() }, 'Skipped. The rule shows the exception.');
1317
+ };
1270
1318
  const ruleActions = [...new Map(problemList.filter(hasRuleReview).map((p) => [p.rule, p])).values()]
1271
- .map((p) => h('a', { class: 'fx-btn fx-btn--sm',
1272
- href: `/invoicing-settings/invoices?rule=${encodeURIComponent(p.rule)}`,
1273
- onclick: (event) => go(event),
1274
- }, `Review ${p.short ?? 'rule'}`));
1319
+ .flatMap((p) => [
1320
+ p.addItem && context.canIssue ? h('button', { class: 'fx-btn fx-btn--sm fx-btn--primary', type: 'button', onclick: () => ruleFix(`${p.rule}/add-item`, null, `${p.addItem} added.`) }, `Add ${p.addItem}`) : null,
1321
+ p.addItem && context.canIssue ? h('button', { class: 'fx-btn fx-btn--sm fx-btn--ghost', type: 'button', onclick: () => skipRule(p) }, 'Skip') : null,
1322
+ h('a', { class: 'fx-btn fx-btn--sm',
1323
+ href: `/invoicing-settings/invoices?rule=${encodeURIComponent(p.rule)}`,
1324
+ onclick: (event) => go(event),
1325
+ }, `Review ${p.short ?? 'rule'}`),
1326
+ ].filter(Boolean));
1275
1327
  // Non-change holds use the Jobs workflow's existing instructions, not a
1276
1328
  // comparison that does not exist. Reading them never releases the hold.
1277
1329
  const holdSteps = fixes({ view: 'review_required', holdState: 'review',
@@ -1323,6 +1375,8 @@ function draftView(invoice, ui, context, redraw) {
1323
1375
  draftNote(to, invoice) ? ui.notice(draftNote(to, invoice), 'warn') : null,
1324
1376
  nonChargeable ? ui.notice(brief(invoice.nonChargeable?.[0]?.message ?? 'Non-chargeable.')) : null,
1325
1377
  ...problems,
1378
+ sourceGapNotice(ui, invoice),
1379
+ exceptionsNotice(ui, invoice),
1326
1380
  h('div', { class: 'fx-invoice__grid' },
1327
1381
  h('div', { class: 'fx-invoice__main' },
1328
1382
  h('section', { class: 'fx-card fx-invoice__head' },
@@ -1484,7 +1538,26 @@ function issuedView(invoice, ui, context, redraw) {
1484
1538
  target: '_blank',
1485
1539
  rel: 'noopener',
1486
1540
  }, 'Open in Xero') : null;
1487
- const problemRow = problemNotice ? (openXero ? h('div', { class: 'fx-offer' }, problemNotice, openXero) : problemNotice) : null;
1541
+ // Send stopped it for a Xero invoice of the same client and Reference: that
1542
+ // Xero invoice is the bill. One press voids this copy and gives the job the
1543
+ // Xero invoice (POST /api/xero/invoices/:id/use-xero), never a bare void.
1544
+ const useXero = matched?.code === 'xero_same_reference_different_amount' && invoice.status === 'issued' && context.canIssue && organisationId
1545
+ ? h('button', {
1546
+ class: 'fx-btn fx-btn--sm', type: 'button',
1547
+ onclick: async (event) => {
1548
+ event.currentTarget.disabled = true;
1549
+ try {
1550
+ const body = await ui.flow(`/api/xero/invoices/${invoice.id}/use-xero`, { method: 'POST', body: JSON.stringify({ organisationId, xeroInvoiceId: matched.xero.invoiceId }) });
1551
+ ui.toast(`${invoice.number} is void. The job has ${matched.xero.number ?? 'the Xero invoice'}.`, 'success');
1552
+ ui.go(`/invoices/${body.invoice.id}`);
1553
+ } catch (error) {
1554
+ event.currentTarget.disabled = false;
1555
+ ui.toast(error.message, 'danger');
1556
+ }
1557
+ },
1558
+ }, 'Use Xero invoice') : null;
1559
+ const offers = [useXero, openXero].filter(Boolean);
1560
+ const problemRow = problemNotice ? (offers.length ? h('div', { class: 'fx-offer' }, problemNotice, ...offers) : problemNotice) : null;
1488
1561
  const header = h('header', { class: 'fx-ph' },
1489
1562
  h('div', { class: 'fx-ph__text' },
1490
1563
  h('h1', { class: 'fx-title' }, invoice.number),
@@ -1512,6 +1585,8 @@ function issuedView(invoice, ui, context, redraw) {
1512
1585
  chrome.pager,
1513
1586
  header,
1514
1587
  problemRow,
1588
+ sourceGapNotice(ui, invoice),
1589
+ exceptionsNotice(ui, invoice),
1515
1590
  h('div', { class: 'fx-invoice__grid' },
1516
1591
  h('div', { class: 'fx-invoice__main' },
1517
1592
  h('section', { class: 'fx-card fx-doc' },
@@ -81,7 +81,7 @@ const sentence = (text) => capital(String(text).trim().replace(/\.?$/, '.'));
81
81
  // wrong, what to do, and where to go to do it (`go`, see GO). A job's fix
82
82
  // speaks of Flow's own invoice only. How that invoice stands in an
83
83
  // accounting system is the invoice's business, on the invoice page.
84
- function reasonFix(reason) {
84
+ function reasonFix(reason, o = {}) {
85
85
  const fix = (label, todo, go = null) => ({ label, text: sentence(reason), todo, go });
86
86
  if (reason === 'the accounting system was not checked') {
87
87
  return { label: 'Not checked yet', text: 'Flow did not check for an invoice for this job yet.', todo: 'Wait for the next check, a few minutes. If it stays, check Settings, Integrations.', go: 'integrations' };
@@ -111,7 +111,12 @@ function reasonFix(reason) {
111
111
  }
112
112
  if (/^invoice .* was (voided|deleted)$/.test(reason)) {
113
113
  const voided = /voided$/.test(reason);
114
- return { label: `Invoice ${voided ? 'voided' : 'deleted'}`, text: `The invoice for this job was ${voided ? 'voided' : 'deleted'}.`, todo: 'Make a new invoice, or close the job.', go: 'invoice' };
114
+ const label = `Invoice ${voided ? 'voided' : 'deleted'}`;
115
+ const text = `The invoice for this job was ${voided ? 'voided' : 'deleted'}.`;
116
+ // No Flow invoice: the voided one is in the accounting system only, so
117
+ // the job's own buttons make its new invoice (jobs.js).
118
+ if (!o.invoiceId) return { label, text, todo: null, go: 'choose' };
119
+ return { label, text, todo: 'Make a new invoice, or close the job.', go: 'invoice' };
115
120
  }
116
121
  if (/^the accounting system reports /.test(reason)) {
117
122
  return { label: 'May be invoiced', text: 'Flow found an invoice that may be for this job.', todo: 'Check the invoice number on the job in the work system.', go: 'work' };
@@ -160,7 +165,7 @@ export const GO = ['invoice', 'work', 'price-list', 'rule', 'integrations', 'rev
160
165
  export function fixes(o) {
161
166
  if (invoiceStage(o) !== 'problem') return [];
162
167
  const found = [];
163
- if (PROBLEM_VIEWS.includes(o.view)) found.push(...(o.reasons ?? []).map((r) => reasonFix(r)));
168
+ if (PROBLEM_VIEWS.includes(o.view)) found.push(...(o.reasons ?? []).map((r) => reasonFix(r, o)));
164
169
  found.push(...(o.waitingRules ?? []).map(ruleFix));
165
170
  if (o.invoiceStatus === 'void') {
166
171
  found.push({ label: 'Invoice void', text: 'The invoice for this job is void.', todo: 'Make a new invoice, or close the job.', go: 'invoice' });
package/server/ui/jobs.js CHANGED
@@ -12,6 +12,7 @@
12
12
  // shows what to do there and the card waits in its new lane. On a phone a
13
13
  // long press opens Move to. With a keyboard, the job's panel has the same moves.
14
14
 
15
+ import { alertStrip } from '/assets/ui/alerts.js';
15
16
  import { bulkBar } from '/assets/ui/bulk-bar.js';
16
17
  import { captureButton } from '/assets/ui/captures.js';
17
18
  import { columnTable, dateCell, fieldColumn, groupHead, personCell, rowCells, viewSaver } from '/assets/ui/columns.js';
@@ -128,7 +129,10 @@ export async function renderJobs(main, ui) {
128
129
  const frame = listFrame(main, host);
129
130
  // A form photo from the Jobs page; Capture lists it with its result.
130
131
  const shoot = captureButton(ui, { icon: true, added: () => ui.toast('Sent to Capture.', 'success') });
131
- main.append(...listHead('Jobs', { views: views.element, controls, end: [find.element, toggle, ...shoot.nodes] }).nodes, frame.element, bulk.element);
132
+ // Finished jobs that nothing is billing (server/core/alerts.js).
133
+ const alerts = alertStrip(ui, 'jobs');
134
+ const [title, ...top] = listHead('Jobs', { views: views.element, controls, end: [find.element, toggle, ...shoot.nodes] }).nodes;
135
+ main.append(title, alerts.element, ...top, frame.element, bulk.element);
132
136
 
133
137
  const drawToggle = () => toggle.replaceChildren(...[['board', 'Board'], ['list', 'List']].map(([id, label]) => h('button', {
134
138
  type: 'button', role: 'radio', class: 'fx-seg__item', 'aria-checked': String(state.view.layout === id),
@@ -336,6 +340,9 @@ export async function renderJobs(main, ui) {
336
340
  state.systemFields = fields?.systemFields ?? [];
337
341
  state.people = fields?.people ?? [];
338
342
  state.values = new Map((values?.values ?? []).map((v) => [valueKey(v, v.fieldId), { value: v.value, updatedAt: v.updatedAt }]));
343
+ // The strip loads itself once; a later load follows a change.
344
+ if (state.loaded) alerts.refresh();
345
+ state.loaded = true;
339
346
  } catch (error) {
340
347
  host.replaceChildren(notice(error.message, 'danger'));
341
348
  return;
@@ -378,7 +385,12 @@ export async function renderJobs(main, ui) {
378
385
  h('span', { class: 'fx-job__space' }),
379
386
  problemMark(job),
380
387
  h('span', { class: 'fx-job__amount' }, job.totalCents == null ? '—' : formatCents(job.totalCents))),
381
- job.pendingMove ? h('span', { class: 'fx-job__wait', title: `Set the status to ${job.pendingMove.to} in ${system}. Flow then reads the change.` }, `Waiting for ${system}`) : null);
388
+ job.pendingMove ? h('span', { class: 'fx-job__wait', title: `Set the status to ${job.pendingMove.to} in ${system}. Flow then reads the change.` }, `Waiting for ${system}`) : null,
389
+ // A rule asks for an item the job lacks in the work system (job-board.js).
390
+ // Fix it there: the lab prints from it too.
391
+ ...(job.obligation?.sourceNeeds ?? []).map((need) => h('span', {
392
+ class: 'fx-job__wait fx-job__wait--warn', title: `${need.note} Add it in ${system}.`,
393
+ }, `${system} needs ${need.item}`)));
382
394
  cardJobs.set(card, job);
383
395
  return card;
384
396
  }
@@ -1,9 +1,10 @@
1
1
  // Which sidebar pages a person has pinned. Stored with the account in
2
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
3
+ // Home, Jobs and Invoices stay pinned: a request that leaves them out is saved
4
4
  // with them put back. A page Flow does not know is dropped.
5
5
 
6
6
  export const NAV_PAGES = Object.freeze([
7
+ 'home',
7
8
  'jobs',
8
9
  'captures',
9
10
  'invoices',
@@ -16,12 +17,13 @@ export const NAV_PAGES = Object.freeze([
16
17
  'integrations',
17
18
  ]);
18
19
 
19
- export const NAV_LOCKED = Object.freeze(['jobs', 'invoices']);
20
+ export const NAV_LOCKED = Object.freeze(['home', 'jobs', 'invoices']);
20
21
 
21
- // Today's sidebar, before a person has chosen: daily work (Jobs, Capture,
22
+ // Today's sidebar, before a person has chosen: Home, daily work (Jobs, Capture,
22
23
  // Invoices, Contacts, Reconcile), Price list and Rules. Automations, Custom fields
23
24
  // and Integrations start in More.
24
25
  export const NAV_DEFAULT = Object.freeze([
26
+ 'home',
25
27
  'jobs',
26
28
  'captures',
27
29
  'invoices',
@@ -470,13 +470,15 @@ async function renderGap(main, ui, id) {
470
470
  };
471
471
  });
472
472
  results.replaceChildren(...[
473
- job.open ? null : h('p', { class: 'fx-notice' }, 'This job has an invoice already.'),
473
+ job.open || job.unclear ? null : h('p', { class: 'fx-notice' }, 'This job has an invoice already.'),
474
474
  candidates.length ? compareView({ ours: jobFacts(job), oursTitle: 'This job', oursSub: system, candidates })
475
475
  : h('p', { class: 'fx-empty' }, q.trim() ? 'No Flow invoice matches the search.' : 'Flow found no invoice that may be for this job. Find one, or make it.'),
476
476
  ].flat().filter(Boolean));
477
477
  actions.replaceChildren(...[
478
478
  skip(),
479
- canAct && job.open ? h('button', {
479
+ // An unclear job (a voided or deleted Xero link) is not open to a link,
480
+ // but Make invoice settles it.
481
+ canAct && (job.open || job.unclear) ? h('button', {
480
482
  class: `fx-btn${candidates.some((c) => c.best) ? '' : ' fx-btn--primary'}`, type: 'button',
481
483
  onclick: () => act(async () => {
482
484
  if (job.unclear) await flow(`/api/invoicing/jobs/${job.id}/no-invoice`, { method: 'POST', body: '{}' });
@@ -22,7 +22,7 @@ const themeDir = join(here, '..', 'theme');
22
22
  const logoDir = join(here, 'logos');
23
23
  const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
24
24
  const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
25
- export const UI_FILES = new Set(['sync-status.js', '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', 'xero-contact.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', 'captures.js', 'capture-fields.js', 'pager.js', 'nav-pins.js', 'flow-mark.svg']);
25
+ export const UI_FILES = new Set(['sync-status.js', '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', 'xero-contact.js', 'jobs.js', 'home.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', 'captures.js', 'capture-fields.js', 'pager.js', 'nav-pins.js', 'flow-mark.svg']);
26
26
  const iconDir = join(dirname(require.resolve('lucide-static/package.json')), 'icons');
27
27
  const ICON_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/;
28
28
  const MAX_AGE = '1h';