@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.
- package/SECURITY.md +12 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/agents/gate.js +25 -5
- package/server/api/invoicing.js +119 -3
- package/server/app.js +3 -2
- package/server/auth/routes.js +20 -0
- package/server/auth/session.js +44 -1
- package/server/auth/settings.js +27 -0
- package/server/core/alerts.js +38 -3
- package/server/core/automations.js +8 -3
- package/server/core/invoice-rules.js +158 -8
- package/server/core/invoices.js +9 -1
- package/server/core/invoicing-rules.js +31 -6
- package/server/core/job-board.js +16 -1
- package/server/core/job-match.js +6 -4
- package/server/core/ledger.js +67 -8
- package/server/core/pipeline.js +6 -2
- package/server/core/reconcile.js +11 -3
- package/server/core/sending.js +30 -3
- package/server/db/migrations/0027_high_doctor_strange.sql +24 -0
- package/server/db/migrations/0028_sharp_power_pack.sql +2 -0
- package/server/db/migrations/meta/0027_snapshot.json +5050 -0
- package/server/db/migrations/meta/0028_snapshot.json +5062 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +21 -0
- package/server/integrations/seazona/feed.js +10 -3
- package/server/integrations/seazona/index.js +6 -1
- package/server/integrations/seazona/routes.js +65 -2
- package/server/integrations/seazona/settings.js +3 -0
- package/server/integrations/xero/drafts.js +1 -1
- package/server/integrations/xero/index.js +1 -1
- package/server/integrations/xero/ledger.js +17 -8
- package/server/integrations/xero/links.js +39 -1
- package/server/integrations/xero/lookup.js +3 -1
- package/server/integrations/xero/routes.js +18 -1
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +7 -4
- package/server/ui/flow.css +69 -1
- package/server/ui/home.js +120 -0
- package/server/ui/invoice-rules.js +11 -3
- package/server/ui/invoicing.js +81 -6
- package/server/ui/job-board.js +8 -3
- package/server/ui/jobs.js +14 -2
- package/server/ui/nav-pins.js +5 -3
- package/server/ui/reconcile.js +4 -2
- package/server/ui/routes.js +1 -1
- package/skills/flow/SKILL.md +306 -0
- 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
|
-
|
|
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
|
-
|
|
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)
|
package/server/ui/invoicing.js
CHANGED
|
@@ -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
|
-
.
|
|
1272
|
-
|
|
1273
|
-
onclick: (
|
|
1274
|
-
|
|
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
|
-
|
|
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' },
|
package/server/ui/job-board.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/server/ui/nav-pins.js
CHANGED
|
@@ -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',
|
package/server/ui/reconcile.js
CHANGED
|
@@ -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
|
-
|
|
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: '{}' });
|
package/server/ui/routes.js
CHANGED
|
@@ -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';
|