@arcforgelabs/flow 2026.11.1 → 2026.11.3
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 +11 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/api/invoicing.js +78 -12
- package/server/auth/routes.js +20 -0
- package/server/auth/session.js +38 -1
- package/server/auth/settings.js +16 -0
- package/server/core/invoice-rules.js +25 -10
- package/server/core/job-board.js +12 -4
- package/server/core/job-match.js +6 -4
- package/server/core/ledger.js +84 -13
- package/server/core/line-mapping.js +7 -3
- package/server/core/pipeline.js +6 -2
- package/server/core/sending.js +31 -9
- 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/ledger.js +5 -1
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +9 -6
- package/server/ui/flow.css +75 -4
- package/server/ui/home.js +120 -0
- package/server/ui/invoice-rules.js +24 -3
- package/server/ui/invoicing.js +50 -7
- package/server/ui/job-board.js +20 -10
- package/server/ui/job-change.js +63 -15
- package/server/ui/jobs.js +4 -4
- 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 +237 -0
- package/skills/flow/flow.sh +90 -0
|
@@ -24,8 +24,10 @@ import { FlowError, notFound } from './errors.js';
|
|
|
24
24
|
//
|
|
25
25
|
// A mapped line keeps the work system's amount and tax flag; the price item
|
|
26
26
|
// gives its code, its text and, when the work system gave none, its price and
|
|
27
|
-
// tax.
|
|
28
|
-
//
|
|
27
|
+
// tax. A closest-name guess cannot supply missing source tax; it needs an
|
|
28
|
+
// explicit tax flag before mapping. With `linePrice: 'list'` the price item's
|
|
29
|
+
// price wins instead. A line no step answers keeps no code, so the ledger
|
|
30
|
+
// holds the job.
|
|
29
31
|
|
|
30
32
|
export const STEPS = Object.freeze([
|
|
31
33
|
'same_code', 'account', 'saved', 'catalog_name', 'price_alias', 'catalog_name_price', 'catalog_score', 'alias',
|
|
@@ -207,7 +209,9 @@ export function mapLine(line, context) {
|
|
|
207
209
|
break;
|
|
208
210
|
}
|
|
209
211
|
case 'catalog_score': {
|
|
210
|
-
|
|
212
|
+
// A guessed item would also supply its tax here (or in the ledger).
|
|
213
|
+
// Leave unknown tax for an exact match or a person's saved choice.
|
|
214
|
+
if (price === null || line.taxCode == null) break;
|
|
211
215
|
const scored = (context.byPrice.get(price) ?? [])
|
|
212
216
|
.map((item) => ({ item, score: nameScore(item, { exact, lookup, original: lineText(original) }) }))
|
|
213
217
|
.sort((a, b) => b.score - a.score || a.item.name.localeCompare(b.item.name) || a.item.code.localeCompare(b.item.code));
|
package/server/core/pipeline.js
CHANGED
|
@@ -342,6 +342,10 @@ export function rankCandidates(obligation, invoices, { prior = null, rivals = []
|
|
|
342
342
|
|
|
343
343
|
const amountsAgree = (obligation, invoice) => totalsFor(obligation).has(invoice.totalCents);
|
|
344
344
|
const withoutReissue = (ref) => String(ref).replace(/R$/i, '');
|
|
345
|
+
// A voided or deleted accounting invoice bills nothing: it never links a job
|
|
346
|
+
// by itself. A lab that reissued work voids the first invoice, and that one
|
|
347
|
+
// keeps the work system's number.
|
|
348
|
+
const live = (invoice) => !['voided', 'deleted'].includes(invoice.state);
|
|
345
349
|
|
|
346
350
|
async function saveSuggestions(db, organisationId, obligationId, system, ranked) {
|
|
347
351
|
const keep = ranked.slice(0, 5);
|
|
@@ -392,14 +396,14 @@ export async function matchJob(db, organisationId, obligation, {
|
|
|
392
396
|
});
|
|
393
397
|
if (prior) {
|
|
394
398
|
const [invoice] = await find({ invoiceIds: [prior.externalId] });
|
|
395
|
-
if (invoice && !rejected.has(invoice.id) && (prior.matchedBy === 'person' || amountsAgree(obligation, invoice))) {
|
|
399
|
+
if (invoice && live(invoice) && !rejected.has(invoice.id) && (prior.matchedBy === 'person' || amountsAgree(obligation, invoice))) {
|
|
396
400
|
return exact(invoice, 'prior_link');
|
|
397
401
|
}
|
|
398
402
|
}
|
|
399
403
|
if (obligation.sourceInvoiceRef) {
|
|
400
404
|
const numbers = [obligation.sourceInvoiceRef, withoutReissue(obligation.sourceInvoiceRef)];
|
|
401
405
|
const same = (await find({ numbers }))
|
|
402
|
-
.filter((i) => !rejected.has(i.id) && (!xeroContactId || i.contactId === xeroContactId));
|
|
406
|
+
.filter((i) => live(i) && !rejected.has(i.id) && (!xeroContactId || i.contactId === xeroContactId));
|
|
403
407
|
if (same.length === 1) return exact(same[0], 'source_invoice_ref');
|
|
404
408
|
}
|
|
405
409
|
// Without the customer's accounting contact there is nothing to compare.
|
package/server/core/sending.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
|
|
3
|
-
import { billingObligations, contacts, invoiceLines, invoices } from '../db/schema.js';
|
|
3
|
+
import { billingObligations, contacts, invoiceLines, invoices, people } from '../db/schema.js';
|
|
4
4
|
import { FlowError } from './errors.js';
|
|
5
5
|
import { listRules, ruleNonChargeable, rulePending, ruleSkip } from './invoice-rules.js';
|
|
6
6
|
import { customerBillingByContact, effectiveSettings, readInvoicingSettings } from './invoicing-rules.js';
|
|
@@ -50,20 +50,24 @@ const REFUSAL_CODE = /^[a-z0-9_]{1,64}$/i;
|
|
|
50
50
|
const SENT = new Set(['created', 'reconciled', 'existing', 'already_in_xero']);
|
|
51
51
|
|
|
52
52
|
// `short` is the few words the Invoices list shows; `message` says how to fix
|
|
53
|
-
// it.
|
|
54
|
-
|
|
53
|
+
// it. Draft warnings must name their resolution UI at the owner, not in a
|
|
54
|
+
// separate warning-code inventory. The invoice page dispatches this intent.
|
|
55
|
+
const problem = (code, message, short, resolution, extra = {}) => {
|
|
56
|
+
if (typeof resolution?.kind !== 'string' || !resolution.kind) throw new FlowError('warning_resolution_required', `Draft warning ${code} needs a resolution UI.`);
|
|
57
|
+
return { code, message, ...(short ? { short } : {}), ...extra, resolution };
|
|
58
|
+
};
|
|
55
59
|
|
|
56
60
|
// An issued invoice the accounting system refused. A refusal stored without
|
|
57
61
|
// its few words (before 2026-10-28) shows "Not sent"; its message stays.
|
|
58
|
-
const refusal = (sendError) => (sendError ? { short: 'Not sent', ...sendError } :
|
|
62
|
+
const refusal = (sendError) => (sendError ? { short: 'Not sent', ...sendError } : { code: 'send_failed', message: 'Sending failed.', short: 'Not sent' });
|
|
59
63
|
|
|
60
64
|
// Flow's own checks of one draft. `lines` are its invoice lines.
|
|
61
65
|
export function draftProblems(invoice, lines) {
|
|
62
66
|
const problems = [];
|
|
63
|
-
if (!lines.length) problems.push(problem('no_lines', 'The invoice has no lines.', 'No lines'));
|
|
64
|
-
else if (invoice.totalCents <= 0) problems.push(problem('no_amount', 'The total is $0.00 or less.', 'Total $0'));
|
|
67
|
+
if (!lines.length) problems.push(problem('no_lines', 'The invoice has no lines.', 'No lines', { kind: 'invoice_edit' }));
|
|
68
|
+
else if (invoice.totalCents <= 0) problems.push(problem('no_amount', 'The total is $0.00 or less.', 'Total $0', { kind: 'invoice_edit' }));
|
|
65
69
|
for (const line of lines) {
|
|
66
|
-
if (!line.priceItemId && !line.accountCode) problems.push(problem('line_no_item', `Line ${line.position} has no price item.`, `Line ${line.position}: no item`, { fields: ['line_item'], line: line.position }));
|
|
70
|
+
if (!line.priceItemId && !line.accountCode) problems.push(problem('line_no_item', `Line ${line.position} has no price item.`, `Line ${line.position}: no item`, { kind: 'invoice_edit' }, { fields: ['line_item'], line: line.position }));
|
|
67
71
|
}
|
|
68
72
|
return problems;
|
|
69
73
|
}
|
|
@@ -118,14 +122,16 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
|
|
|
118
122
|
// draft was made, or the work system deleted it) waits for a person.
|
|
119
123
|
for (const { obligation } of jobs) {
|
|
120
124
|
for (const reason of sourceBillingProblems(obligation)) {
|
|
121
|
-
const found = problem('job_review', 'Check the source invoice: ' + reason + '.', 'Source tax');
|
|
125
|
+
const found = { ...problem('job_review', 'Check the source invoice: ' + reason + '.', 'Source tax', { kind: 'job_review' }), sourceBilling: true };
|
|
122
126
|
problems.set(obligation.invoiceId, [...(problems.get(obligation.invoiceId) ?? []), found]);
|
|
123
127
|
}
|
|
124
128
|
}
|
|
125
129
|
const reviewed = jobs.map((j) => j.obligation).filter((o) => o.holdState === 'review');
|
|
126
130
|
for (const obligation of reviewed) {
|
|
127
131
|
const { invoiceId, holdReason: reason } = obligation;
|
|
128
|
-
const
|
|
132
|
+
const resolution = reason === CHANGED_REASON ? { kind: 'job_review' }
|
|
133
|
+
: { kind: 'job_hold', reason: reason ?? 'the job is review' };
|
|
134
|
+
const found = problem('job_review', `The job needs a look before this invoice goes: ${reason ?? 'it was sent to review'}.`, reviewShort(obligation), resolution);
|
|
129
135
|
problems.set(invoiceId, [...(problems.get(invoiceId) ?? []), found]);
|
|
130
136
|
}
|
|
131
137
|
if (accounting) {
|
|
@@ -424,6 +430,11 @@ export function refusalReport(result) {
|
|
|
424
430
|
return `[sending] the accounting system refused ${count} ${word}${detail ? `: ${detail}` : ''}`;
|
|
425
431
|
}
|
|
426
432
|
|
|
433
|
+
async function requesterActive(db, personId) {
|
|
434
|
+
const [row] = await db.select({ status: people.status }).from(people).where(eq(people.id, personId));
|
|
435
|
+
return row?.status === 'active';
|
|
436
|
+
}
|
|
437
|
+
|
|
427
438
|
// One pass of the send worker: up to `limit` queued invoices, oldest first.
|
|
428
439
|
export async function sendQueued(db, organisationId, { accounting, limit = WORKER_BATCH }) {
|
|
429
440
|
const rows = await db.select({ id: invoices.id, number: invoices.number, by: invoices.sendRequestedBy }).from(invoices)
|
|
@@ -433,6 +444,17 @@ export async function sendQueued(db, organisationId, { accounting, limit = WORKE
|
|
|
433
444
|
const summary = { sent: 0, failed: 0, waiting: 0 };
|
|
434
445
|
for (const [by, group] of Map.groupBy(rows, (r) => r.by ?? null)) {
|
|
435
446
|
const known = new Map(group.map((row) => [row.id, row]));
|
|
447
|
+
// A send waits in the queue. The person who asked for it must still be
|
|
448
|
+
// active when it goes, so turning a person (or an assistant's key) off
|
|
449
|
+
// also stops what they queued.
|
|
450
|
+
if (by && !(await requesterActive(db, by))) {
|
|
451
|
+
for (const row of group) {
|
|
452
|
+
await settle(db, organisationId, row.id, { sendState: 'failed', sendError: { code: 'requester_disabled', message: 'The person who asked to send this is turned off. Send it again.' } });
|
|
453
|
+
summary.failed += 1;
|
|
454
|
+
}
|
|
455
|
+
summary.refused = [...new Set([...(summary.refused ?? []), 'requester_disabled'])];
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
436
458
|
const { invoices: results } = await accounting.capabilities.pushInvoices({
|
|
437
459
|
organisationId, invoiceIds: group.map((r) => r.id), pushedBy: by,
|
|
438
460
|
});
|
|
@@ -53,6 +53,7 @@ export const SEAZONA_SETUP = {
|
|
|
53
53
|
{ name: 'SEAZONA_API_CLIENT_SECRET', label: 'Client secret', secret: true, help: 'Stored encrypted. Flow never shows it again.' },
|
|
54
54
|
{ name: 'SEAZONA_ORDER_URL', label: 'Order address', kind: 'url', help: 'Optional. Open any order in Seazona and paste its address here, or write {ref} where the order id goes. Stage moves on the Jobs board and Open in Seazona use it.' },
|
|
55
55
|
{ name: 'SEAZONA_KEEP_PATIENT_REFERENCE', label: 'Keep patient names', kind: 'onoff', help: 'On: Flow keeps the patient name of each job for the invoice Reference.' },
|
|
56
|
+
{ name: 'SEAZONA_API_READ_HIDE_PATIENT', label: 'Hide patient details', kind: 'onoff', help: 'On: an AI assistant reading Seazona through Flow gets no patient, note, file or clinical fields.' },
|
|
56
57
|
],
|
|
57
58
|
};
|
|
58
59
|
|
|
@@ -85,7 +86,11 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
85
86
|
schema: seazonaSchema,
|
|
86
87
|
setup: SEAZONA_SETUP,
|
|
87
88
|
// The history import's routes (routes.js), under /api/seazona.
|
|
88
|
-
routes: ({ db, personAuth, log }) => createSeazonaRouter({
|
|
89
|
+
routes: ({ db, personAuth, log }) => createSeazonaRouter({
|
|
90
|
+
db: runtime?.db ?? db, personAuth, settings: current().settings, log,
|
|
91
|
+
read: (path, query) => current().client.get(path, query),
|
|
92
|
+
hidePatientFields: () => Boolean(current().settings.sync.hidePatientFields),
|
|
93
|
+
}),
|
|
89
94
|
// Every Seazona call goes through this call guard, inside the rate
|
|
90
95
|
// governor (governor.js; docs/INTEGRATIONS.md, "Outbound calls").
|
|
91
96
|
callGuard: SEAZONA_CALL_GUARD,
|
|
@@ -36,7 +36,16 @@ import { readBackfill, startBackfill, stopBackfill } from './history/backfill.js
|
|
|
36
36
|
// (state: one state, or needs_person)
|
|
37
37
|
// POST /history/ledger/:obligationId/release owner or admin: the job may draft
|
|
38
38
|
//
|
|
39
|
-
// None of these call Seazona
|
|
39
|
+
// None of these call Seazona, except one read:
|
|
40
|
+
//
|
|
41
|
+
// GET /api/<resource>[/<id>]?<query> owner or admin: one GET to the
|
|
42
|
+
// Seazona REST API, through Flow's
|
|
43
|
+
// rate governor, answered as
|
|
44
|
+
// { ok, data }. resource is invoices,
|
|
45
|
+
// orders, clients, products or users.
|
|
46
|
+
// It never writes to Seazona. It is how
|
|
47
|
+
// an AI assistant reads Seazona with its
|
|
48
|
+
// Flow key (docs/AGENT_ACCESS.md).
|
|
40
49
|
|
|
41
50
|
export const MAX_UPLOAD = '25mb';
|
|
42
51
|
|
|
@@ -56,7 +65,40 @@ const handle = (log, fn) => async (req, res) => {
|
|
|
56
65
|
}
|
|
57
66
|
};
|
|
58
67
|
|
|
59
|
-
|
|
68
|
+
const API_RESOURCES = ['invoices', 'orders', 'clients', 'products', 'users'];
|
|
69
|
+
|
|
70
|
+
// With "Hide patient details" on (SEAZONA_API_READ_HIDE_PATIENT), any field
|
|
71
|
+
// whose name says patient, note, file, prescription and the like is dropped
|
|
72
|
+
// from the read, at any depth. Off by default.
|
|
73
|
+
const PRIVATE_FIELD = /patient|note|comment|instruction|remark|file|attach|image|photo|prescri|shade|clinical|medical|birth|dob/i;
|
|
74
|
+
// The value is parsed JSON, so it has no cycles.
|
|
75
|
+
export function withoutPrivateFields(value) {
|
|
76
|
+
if (Array.isArray(value)) return value.map(withoutPrivateFields);
|
|
77
|
+
if (!value || typeof value !== 'object') return value;
|
|
78
|
+
return Object.fromEntries(Object.entries(value)
|
|
79
|
+
.filter(([key]) => !PRIVATE_FIELD.test(key))
|
|
80
|
+
.map(([key, item]) => [key, withoutPrivateFields(item)]));
|
|
81
|
+
}
|
|
82
|
+
// A list read must carry the filter Flow's own sync uses. Seazona answers a
|
|
83
|
+
// list without it with an error, and every error counts against the call
|
|
84
|
+
// guard that also protects Flow's sync, so Flow refuses it before the call.
|
|
85
|
+
const SEAZONA_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/;
|
|
86
|
+
const LIST_FILTERS = {
|
|
87
|
+
orders: { key: 'ordered', hint: 'ordered=2026-11-11T00:00:00 (one day)' },
|
|
88
|
+
invoices: { key: 'lastModified', hint: 'lastModified=2026-11-01T00:00:00' },
|
|
89
|
+
clients: { key: 'lastModified', hint: 'lastModified=2026-11-01T00:00:00' },
|
|
90
|
+
};
|
|
91
|
+
export function listQueryProblem(resource, query) {
|
|
92
|
+
const filter = LIST_FILTERS[resource];
|
|
93
|
+
if (filter && !SEAZONA_TIME.test(query[filter.key] ?? '')) return `A ${resource} list needs ${filter.hint}.`;
|
|
94
|
+
for (const key of ['page', 'pageSize']) {
|
|
95
|
+
if (key in query && !/^[1-9]\d{0,2}$/.test(query[key])) return `${key} must be a whole number from 1 to 999.`;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
const API_PATH = new RegExp(`^/api/(${API_RESOURCES.join('|')})(?:/([A-Za-z0-9-]{1,64}))?/?$`);
|
|
100
|
+
|
|
101
|
+
export function createSeazonaRouter({ db, personAuth, settings, read = null, hidePatientFields = () => false, log = console }) {
|
|
60
102
|
const router = express.Router();
|
|
61
103
|
// Sign-in off: every route answers 503, as the Xero routes do.
|
|
62
104
|
const signedIn = personAuth
|
|
@@ -72,6 +114,27 @@ export function createSeazonaRouter({ db, personAuth, settings, log = console })
|
|
|
72
114
|
return router;
|
|
73
115
|
}
|
|
74
116
|
|
|
117
|
+
// One read from the Seazona API (above). Only plain query values pass on.
|
|
118
|
+
router.get(API_PATH, ...manager, handle(log, async (req, res) => {
|
|
119
|
+
if (!read) throw Object.assign(new FlowError('seazona_off', 'Seazona is not connected'), { status: 503 });
|
|
120
|
+
const [, resource, id] = API_PATH.exec(req.path);
|
|
121
|
+
const query = {};
|
|
122
|
+
for (const [key, value] of Object.entries(req.query)) {
|
|
123
|
+
if (typeof value === 'string' && /^[A-Za-z][A-Za-z0-9_]{0,40}$/.test(key)) query[key] = value.slice(0, 200);
|
|
124
|
+
}
|
|
125
|
+
const problem = id ? null : listQueryProblem(resource, query);
|
|
126
|
+
if (problem) return res.status(400).json({ ok: false, code: 'invalid', error: problem });
|
|
127
|
+
try {
|
|
128
|
+
const data = await read(id ? `${resource}/${id}` : `${resource}/`, query);
|
|
129
|
+
log.info?.(`[seazona] api read ${resource}${id ? '/:id' : ''} by ${actor(req)}`);
|
|
130
|
+
res.json({ ok: true, data: hidePatientFields() ? withoutPrivateFields(data) : data });
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error?.name !== 'SeazonaApiError' && error?.name !== 'SeazonaCallsStopped') throw error;
|
|
133
|
+
const status = error.statusCode === 404 ? 404 : error.statusCode === 400 ? 400 : error.name === 'SeazonaCallsStopped' || error.statusCode === 429 || error.kind === 'governor_timeout' ? 429 : 502;
|
|
134
|
+
res.status(status).json({ ok: false, code: 'seazona_read_failed', error: error.message });
|
|
135
|
+
}
|
|
136
|
+
}));
|
|
137
|
+
|
|
75
138
|
router.get('/history', ...signedIn, handle(log, async (req, res) => {
|
|
76
139
|
res.json({ ok: true, history: await historySummary(db), backfill: await readBackfill(db), batches: await listBatches(db) });
|
|
77
140
|
}));
|
|
@@ -187,6 +187,9 @@ export function resolveSeazonaSettings(env = process.env) {
|
|
|
187
187
|
// print one. Off by default; on, orders and invoices keep Seazona's
|
|
188
188
|
// patient field as the reference.
|
|
189
189
|
keepPatientReference: ['1', 'true', 'on', 'yes'].includes(text(env.SEAZONA_KEEP_PATIENT_REFERENCE).toLowerCase()),
|
|
190
|
+
// Off by default: the Seazona read (routes.js) answers what Seazona gives.
|
|
191
|
+
// On, it drops patient, note, file and clinical fields first.
|
|
192
|
+
hidePatientFields: ['1', 'true', 'on', 'yes'].includes(text(env.SEAZONA_API_READ_HIDE_PATIENT).toLowerCase()),
|
|
190
193
|
};
|
|
191
194
|
|
|
192
195
|
const settings = {
|
|
@@ -119,7 +119,11 @@ export async function checkInvoice(db, runtime, client, organisationId, obligati
|
|
|
119
119
|
// 3. The work system's own invoice number, as Xero's number or reference.
|
|
120
120
|
if (obligation.sourceInvoiceRef) {
|
|
121
121
|
const byId = new Map();
|
|
122
|
-
|
|
122
|
+
// Its reissue too (a trailing R): a lab that reissued the work voided the
|
|
123
|
+
// first invoice and bills the R one. One call: Xero takes a list.
|
|
124
|
+
const base = obligation.sourceInvoiceRef.replace(/R$/i, '');
|
|
125
|
+
const numbers = [...new Set([obligation.sourceInvoiceRef, base, `${base}R`])].join(',');
|
|
126
|
+
for (const inv of sales(await client.findInvoicesByNumber(numbers))) byId.set(inv.InvoiceID, inv);
|
|
123
127
|
for (const inv of sales(await client.findInvoicesByReference(obligation.sourceInvoiceRef))) byId.set(inv.InvoiceID, inv);
|
|
124
128
|
const found = [...byId.values()];
|
|
125
129
|
if (found.length === 1) return evidenceFor(runtime, client, found[0], 'source_invoice_ref', checkedAt);
|
package/server/ui/app-page.js
CHANGED
|
@@ -51,7 +51,7 @@ const BODY = `<div class="fx-app">
|
|
|
51
51
|
<div id="toasts" class="fx-toasts" aria-live="polite"></div>
|
|
52
52
|
</div>`;
|
|
53
53
|
|
|
54
|
-
export const APP_PATHS = ['/sign-in', '/jobs', '/captures', '/invoices', '/invoices/:id', '/contacts', '/contacts/:id', '/reconcile', '/reconcile/:id', '/xero-import', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/invoicing-settings/:id', '/automations', '/price-list', '/price-list/:id', '/custom-fields'];
|
|
54
|
+
export const APP_PATHS = ['/sign-in', '/home', '/jobs', '/captures', '/invoices', '/invoices/:id', '/contacts', '/contacts/:id', '/reconcile', '/reconcile/:id', '/xero-import', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/invoicing-settings/:id', '/automations', '/price-list', '/price-list/:id', '/custom-fields'];
|
|
55
55
|
|
|
56
56
|
export function appPage({ defaultFamily }) {
|
|
57
57
|
return renderPage({
|
package/server/ui/app.js
CHANGED
|
@@ -18,6 +18,7 @@ import { renderPriceList } from '/assets/ui/price-list.js';
|
|
|
18
18
|
import { renderPriceReconcile } from '/assets/ui/price-reconcile.js';
|
|
19
19
|
import { renderCustomFields } from '/assets/ui/custom-fields.js';
|
|
20
20
|
import { renderJobs } from '/assets/ui/jobs.js';
|
|
21
|
+
import { renderHome } from '/assets/ui/home.js';
|
|
21
22
|
import { renderReconcile } from '/assets/ui/reconcile.js';
|
|
22
23
|
import { renderXeroImport } from '/assets/ui/xero-import.js';
|
|
23
24
|
import { syncStatus } from '/assets/ui/sync-status.js';
|
|
@@ -27,7 +28,7 @@ import { isSearchKey, openSearch } from '/assets/ui/search.js';
|
|
|
27
28
|
import { alertStrip } from '/assets/ui/alerts.js';
|
|
28
29
|
import { initialsOf, personLabel } from '/assets/ui/person.js';
|
|
29
30
|
import { startTips } from '/assets/ui/tip.js';
|
|
30
|
-
import { crumbs, dialog, edgeFade, fieldError, h, holdButton, iconPicker, linksOpenWith, listFrame, listHead, notice, pageHeader, pickOne, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
|
|
31
|
+
import { cardRows, crumbs, dialog, edgeFade, fieldError, h, holdButton, iconPicker, linksOpenWith, listFrame, listHead, notice, pageHeader, pickOne, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
|
|
31
32
|
import { NAV_DEFAULT, NAV_LOCKED } from '/assets/ui/nav-pins.js';
|
|
32
33
|
|
|
33
34
|
const $ = (id) => document.getElementById(id);
|
|
@@ -153,6 +154,7 @@ let me = null;
|
|
|
153
154
|
// menu. `sidebar: false`: opened by a link or Search only. Jobs and Invoices
|
|
154
155
|
// cannot be unpinned (nav-pins.js).
|
|
155
156
|
const VIEWS = {
|
|
157
|
+
home: { label: 'Home', icon: 'home', render: (main) => renderHome(main, ui()) },
|
|
156
158
|
jobs: { label: 'Jobs', icon: 'jobs', render: (main) => renderJobs(main, ui()) },
|
|
157
159
|
captures: { label: 'Capture', icon: 'camera', render: (main) => renderCaptures(main, ui()) },
|
|
158
160
|
invoices: { label: 'Invoices', icon: 'invoices', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
|
|
@@ -174,6 +176,7 @@ const VIEWS = {
|
|
|
174
176
|
|
|
175
177
|
// Line icons for the sidebar, drawn on a 24-unit grid in the text colour.
|
|
176
178
|
const ICON_PATHS = {
|
|
179
|
+
home: '<path d="M3 11l9-7 9 7"/><path d="M5 10v10h14V10"/><path d="M10 20v-6h4v6"/>',
|
|
177
180
|
jobs: '<rect x="3" y="4" width="5" height="16" rx="1"/><rect x="10" y="4" width="5" height="11" rx="1"/><rect x="17" y="4" width="4" height="14" rx="1"/>',
|
|
178
181
|
invoices: '<path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5M9 13h7M9 17h5"/>',
|
|
179
182
|
contacts: '<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="2"/><path d="M6 16c.4-1.6 1.5-2.4 3-2.4s2.6.8 3 2.4M14 9h5M14 13h4"/>',
|
|
@@ -293,9 +296,9 @@ function reportMenuItem(openReport) {
|
|
|
293
296
|
return accountMenuItem('Report a problem', 'flag', () => { openReport().catch((error) => toast(error.message, 'danger')); });
|
|
294
297
|
}
|
|
295
298
|
|
|
296
|
-
// The page a person lands on after sign-in, and for / or an unknown path
|
|
297
|
-
//
|
|
298
|
-
const START_VIEW = '
|
|
299
|
+
// The page a person lands on after sign-in, and for / or an unknown path:
|
|
300
|
+
// Home, with the invoices ready to send and one Send.
|
|
301
|
+
const START_VIEW = 'home';
|
|
299
302
|
|
|
300
303
|
// The view and, for one record, its id: /invoices/<id> is ['invoices', '<id>'].
|
|
301
304
|
function currentView() {
|
|
@@ -336,7 +339,7 @@ function navigate(event) {
|
|
|
336
339
|
// The shared pieces a view module (invoicing.js) builds with. Its `go`
|
|
337
340
|
// takes a path, or a link's click event.
|
|
338
341
|
const ui = () => ({
|
|
339
|
-
h, flow, toast, notice, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, settingRow, dialog, pickOne, iconPicker, me, holdButton, tileCard, tileSection, crumbs,
|
|
342
|
+
h, flow, toast, notice, cardRows, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, settingRow, dialog, pickOne, iconPicker, me, holdButton, tileCard, tileSection, crumbs,
|
|
340
343
|
go: (target, options) => (typeof target === 'string' ? go(target, options) : navigate(target)),
|
|
341
344
|
});
|
|
342
345
|
|
|
@@ -369,7 +372,7 @@ function sidebarViews() {
|
|
|
369
372
|
return Object.entries(VIEWS).filter(([, view]) => view.sidebar !== false && view.place !== 'account' && (!view.ownerOnly || me?.person.role === 'owner'));
|
|
370
373
|
}
|
|
371
374
|
|
|
372
|
-
// Null until the shell reads the account. Jobs and Invoices are always in it.
|
|
375
|
+
// Null until the shell reads the account. Home, Jobs and Invoices are always in it.
|
|
373
376
|
let pinned = null;
|
|
374
377
|
let pinEpoch = 0;
|
|
375
378
|
let pinWrite = Promise.resolve();
|
package/server/ui/flow.css
CHANGED
|
@@ -230,6 +230,9 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
|
|
|
230
230
|
background: var(--t-danger-bg); color: var(--t-text); font-size: 14px;
|
|
231
231
|
}
|
|
232
232
|
.fx-change p { margin: 0; }
|
|
233
|
+
.fx-change__steps { display: grid; gap: 10px; min-width: 0; overflow-wrap: anywhere; }
|
|
234
|
+
.fx-change__steps ol { display: grid; gap: 8px; margin: 0; padding-left: 20px; }
|
|
235
|
+
.fx-change__steps summary { cursor: pointer; }
|
|
233
236
|
.fx-change strong { color: var(--t-danger); }
|
|
234
237
|
.fx-change__head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; }
|
|
235
238
|
.fx-change__acts { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
@@ -356,11 +359,78 @@ a.fx-link:hover, a.fx-link:focus-visible { border-color: var(--t-strong); }
|
|
|
356
359
|
background: #fff; box-shadow: inset 0 0 0 1px var(--fx-line);
|
|
357
360
|
}
|
|
358
361
|
.fx-logo--icon { background: var(--t-accent-bg); color: var(--t-accent); box-shadow: none; }
|
|
362
|
+
/* Home: the invoices ready to send and one Send, then the way an invoice
|
|
363
|
+
goes after Send, drawn as one line of steps (a column on a phone). */
|
|
364
|
+
.fx-home { display: grid; gap: 20px; max-width: 880px; }
|
|
365
|
+
.fx-home__send {
|
|
366
|
+
position: relative; overflow: hidden;
|
|
367
|
+
display: grid; justify-items: start; gap: 6px; padding: 32px;
|
|
368
|
+
border: 1px solid var(--fx-line); border-radius: var(--t-radius-lg);
|
|
369
|
+
background:
|
|
370
|
+
radial-gradient(120% 140% at 100% 0%, color-mix(in srgb, var(--t-primary) 10%, transparent), transparent 60%),
|
|
371
|
+
var(--t-paper);
|
|
372
|
+
}
|
|
373
|
+
.fx-home__send[data-done] {
|
|
374
|
+
grid-template-columns: auto 1fr; align-items: center; column-gap: 16px;
|
|
375
|
+
background:
|
|
376
|
+
radial-gradient(120% 140% at 100% 0%, color-mix(in srgb, var(--t-success) 12%, transparent), transparent 60%),
|
|
377
|
+
var(--t-paper);
|
|
378
|
+
}
|
|
379
|
+
.fx-home__send[data-done] > :not(.fx-home__done) { grid-column: 2; }
|
|
380
|
+
.fx-home__done {
|
|
381
|
+
grid-row: span 2; display: grid; place-items: center; width: 52px; height: 52px; border-radius: 50%;
|
|
382
|
+
background: var(--t-success-bg); color: var(--t-success);
|
|
383
|
+
}
|
|
384
|
+
.fx-home__done .fx-lucide { width: 26px; height: 26px; }
|
|
385
|
+
.fx-home__count {
|
|
386
|
+
display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 12px;
|
|
387
|
+
color: var(--t-strong); font: 700 72px/1 var(--t-body); font-variant-numeric: tabular-nums; letter-spacing: -0.02em;
|
|
388
|
+
}
|
|
389
|
+
.fx-home__count span { font: 600 20px/1.2 var(--t-body); letter-spacing: 0; color: var(--t-text); }
|
|
390
|
+
.fx-home__send[data-done] .fx-home__count span { font-size: 22px; color: var(--t-strong); }
|
|
391
|
+
.fx-home__sum { color: var(--t-dim); font-size: 16px; font-variant-numeric: tabular-nums; }
|
|
392
|
+
.fx-home__btn { min-height: 56px; min-width: 200px; margin-top: 14px; padding: 0 32px; font-size: 17px; border-radius: 999px; }
|
|
393
|
+
.fx-home__btn .fx-lucide { width: 20px; height: 20px; }
|
|
394
|
+
.fx-home__note { color: var(--t-dim); font-size: 13.5px; }
|
|
395
|
+
|
|
396
|
+
/* Needs you: at most three rows beside Send; each opens its page. */
|
|
397
|
+
.fx-home__needs { display: grid; align-content: start; gap: 10px; }
|
|
398
|
+
.fx-home__needs h2 { margin: 0; }
|
|
399
|
+
.fx-home__needs ul { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
|
|
400
|
+
.fx-home__needs a {
|
|
401
|
+
display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 12px;
|
|
402
|
+
min-height: 64px; padding: 10px 16px; border: 1px solid var(--fx-line); border-radius: var(--t-radius-lg);
|
|
403
|
+
background: var(--t-paper); color: var(--t-text); text-decoration: none;
|
|
404
|
+
transition: border-color 140ms var(--fx-ease), transform 140ms var(--fx-ease);
|
|
405
|
+
}
|
|
406
|
+
.fx-home__needs a:hover, .fx-home__needs a:focus-visible { border-color: var(--t-control); transform: translateY(-1px); }
|
|
407
|
+
.fx-home__dot { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 50%; background: var(--fx-hover); color: var(--t-dim); }
|
|
408
|
+
.fx-home__dot .fx-lucide { width: 18px; height: 18px; }
|
|
409
|
+
.fx-home__needs [data-tone="danger"] .fx-home__dot { background: var(--t-danger-bg); color: var(--t-danger); }
|
|
410
|
+
.fx-home__needs [data-tone="warn"] .fx-home__dot { background: var(--t-warn-bg); color: var(--t-warn); }
|
|
411
|
+
.fx-home__needs [data-tone="info"] .fx-home__dot { background: var(--t-accent-bg); color: var(--t-accent); }
|
|
412
|
+
.fx-home__needs [data-tone="clear"] .fx-home__dot { background: var(--t-success-bg); color: var(--t-success); }
|
|
413
|
+
.fx-home__what { display: grid; font-weight: 600; color: var(--t-strong); }
|
|
414
|
+
.fx-home__what small { color: var(--t-dim); font-weight: 400; font-size: 13px; font-variant-numeric: tabular-nums; }
|
|
415
|
+
.fx-home__needs [data-tone="clear"] .fx-home__what { color: var(--t-dim); font-weight: 500; }
|
|
416
|
+
.fx-home__n { color: var(--t-strong); font: 700 22px/1 var(--t-body); font-variant-numeric: tabular-nums; }
|
|
417
|
+
.fx-home__needs [data-tone="clear"] .fx-home__n { color: var(--t-dim); font-weight: 500; }
|
|
418
|
+
|
|
419
|
+
@media (min-width: 1024px) {
|
|
420
|
+
.fx-home { grid-template-columns: minmax(0, 3fr) minmax(260px, 2fr); align-items: stretch; max-width: 1040px; }
|
|
421
|
+
.fx-home__send { align-content: center; }
|
|
422
|
+
}
|
|
423
|
+
@media (max-width: 639px) {
|
|
424
|
+
.fx-home__send { padding: 24px 20px; }
|
|
425
|
+
.fx-home__count { font-size: 60px; }
|
|
426
|
+
.fx-home__btn { width: 100%; }
|
|
427
|
+
}
|
|
428
|
+
|
|
359
429
|
.fx-tiles { display: grid; gap: 12px; }
|
|
360
430
|
.fx-tiles + .fx-tiles { margin-top: 16px; }
|
|
361
431
|
.fx-tiles__head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
362
432
|
.fx-tiles__head h2 { color: var(--t-strong); font: 600 15px/1.3 var(--t-body); }
|
|
363
|
-
.fx-tiles__more { display: inline-flex; align-items: center; gap: 4px; color: var(--t-dim); font-size: 13.5px; text-decoration: none; }
|
|
433
|
+
.fx-tiles__more { display: inline-flex; align-items: center; gap: 4px; min-height: 44px; color: var(--t-dim); font-size: 13.5px; text-decoration: none; }
|
|
364
434
|
.fx-tiles__more:hover, .fx-tiles__more:focus-visible { color: var(--t-strong); }
|
|
365
435
|
.fx-tiles__more .fx-lucide { width: 15px; height: 15px; }
|
|
366
436
|
.fx-tiles__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); gap: 12px; }
|
|
@@ -838,7 +908,7 @@ th:hover > .fx-th-grip::after { background: var(--t-brd-hi); }
|
|
|
838
908
|
each lane scrolls inside itself (jobs.js sets fx-main--board while the
|
|
839
909
|
board shows). */
|
|
840
910
|
.fx-board { --fx-lane: min(85vw, 272px); display: grid; grid-auto-flow: column; grid-auto-columns: var(--fx-lane); justify-content: start; gap: 12px; align-items: start; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin; padding-bottom: 4px; }
|
|
841
|
-
.fx-board__col { display: flex; flex-direction: column; gap: 8px; min-width: 0; min-height: 0; padding: 10px 6px 6px 8px; border-radius: var(--t-radius-lg); background:
|
|
911
|
+
.fx-board__col { display: flex; flex-direction: column; gap: 8px; min-width: 0; min-height: 0; padding: 10px 6px 6px 8px; border-radius: var(--t-radius-lg); background: transparent; }
|
|
842
912
|
.fx-board__head { display: flex; flex: none; align-items: center; gap: 8px; min-width: 0; padding: 2px 4px 4px 2px; }
|
|
843
913
|
.fx-board__sum { flex: none; margin-left: auto; color: var(--t-dim); font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
844
914
|
.fx-board__cards { display: grid; gap: 8px; align-content: start; min-height: 0; max-height: 70vh; padding-right: 2px; overflow-y: auto; scrollbar-width: thin; }
|
|
@@ -860,7 +930,7 @@ th:hover > .fx-th-grip::after { background: var(--t-brd-hi); }
|
|
|
860
930
|
.fx-fade[data-edge="right"] { inset: 0 0 0 auto; width: 24px; background: linear-gradient(to left, var(--fx-fade), transparent); }
|
|
861
931
|
.fx-fades[data-fade~="top"] > .fx-fade[data-edge="top"], .fx-fades[data-fade~="bottom"] > .fx-fade[data-edge="bottom"],
|
|
862
932
|
.fx-fades[data-fade~="left"] > .fx-fade[data-edge="left"], .fx-fades[data-fade~="right"] > .fx-fade[data-edge="right"] { opacity: 1; }
|
|
863
|
-
.fx-main--board .fx-frame__view { --fx-fade: var(--t-
|
|
933
|
+
.fx-main--board .fx-frame__view { --fx-fade: var(--t-s1); }
|
|
864
934
|
.fx-board__col.fx-fades { --fx-fade: var(--t-s1); }
|
|
865
935
|
.fx-board__col > .fx-fade[data-edge="bottom"] { border-radius: 0 0 var(--t-radius-lg) var(--t-radius-lg); }
|
|
866
936
|
.fx-frame__body .fx-panel { border: 0; border-radius: 0; box-shadow: none; overflow: visible; }
|
|
@@ -869,7 +939,8 @@ th:hover > .fx-th-grip::after { background: var(--t-brd-hi); }
|
|
|
869
939
|
.fx-frame thead th { position: sticky; top: 0; z-index: 2; }
|
|
870
940
|
.fx-frame__foot { display: flex; flex: none; align-items: center; justify-content: space-between; gap: 12px; min-height: 44px; padding: 6px 12px; border-top: 1px solid var(--fx-line); color: var(--t-dim); font-size: 13px; font-variant-numeric: tabular-nums; }
|
|
871
941
|
.fx-frame__tools { display: flex; align-items: center; gap: 8px; }
|
|
872
|
-
|
|
942
|
+
/* One work surface runs through the lanes, gaps and unused board space. */
|
|
943
|
+
.fx-main--board .fx-frame__body { padding: 12px; background: var(--t-s1); }
|
|
873
944
|
@media (min-width: 821px) and (min-height: 560px) {
|
|
874
945
|
.fx-main--frame { height: 100vh; height: 100dvh; padding-bottom: 16px; display: flex; flex-direction: column; }
|
|
875
946
|
.fx-main--frame > * { flex: none; }
|
|
@@ -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
|
+
}
|