@arcforgelabs/flow 2026.11.3 → 2026.11.5

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 (43) hide show
  1. package/SECURITY.md +2 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +1 -1
  4. package/server/agents/gate.js +25 -5
  5. package/server/api/invoicing.js +68 -1
  6. package/server/app.js +3 -2
  7. package/server/auth/session.js +13 -7
  8. package/server/auth/settings.js +18 -7
  9. package/server/core/alerts.js +38 -3
  10. package/server/core/automations.js +8 -3
  11. package/server/core/invoice-rules.js +158 -8
  12. package/server/core/invoices.js +9 -1
  13. package/server/core/invoicing-rules.js +31 -6
  14. package/server/core/job-board.js +16 -1
  15. package/server/core/ledger.js +11 -2
  16. package/server/core/reconcile.js +11 -3
  17. package/server/core/sending.js +14 -3
  18. package/server/db/migrations/0027_high_doctor_strange.sql +24 -0
  19. package/server/db/migrations/0028_sharp_power_pack.sql +2 -0
  20. package/server/db/migrations/meta/0027_snapshot.json +5050 -0
  21. package/server/db/migrations/meta/0028_snapshot.json +5062 -0
  22. package/server/db/migrations/meta/_journal.json +14 -0
  23. package/server/db/schema.js +21 -0
  24. package/server/integrations/seazona/feed.js +10 -3
  25. package/server/integrations/xero/drafts.js +1 -1
  26. package/server/integrations/xero/index.js +1 -1
  27. package/server/integrations/xero/ledger.js +12 -7
  28. package/server/integrations/xero/links.js +39 -1
  29. package/server/integrations/xero/lookup.js +3 -1
  30. package/server/integrations/xero/routes.js +18 -1
  31. package/server/ui/app.js +2 -2
  32. package/server/ui/flow.css +38 -62
  33. package/server/ui/home.js +38 -47
  34. package/server/ui/invoice-rules.js +11 -3
  35. package/server/ui/invoicing.js +79 -7
  36. package/server/ui/job-change.js +15 -9
  37. package/server/ui/jobs.js +14 -2
  38. package/server/ui/kit.js +21 -0
  39. package/server/ui/mobile.js +18 -0
  40. package/server/ui/problem-prompt.js +79 -0
  41. package/server/ui/routes.js +1 -1
  42. package/skills/flow/SKILL.md +79 -2
  43. package/skills/flow/flow.sh +32 -1
@@ -190,6 +190,20 @@
190
190
  "when": 1794019099844,
191
191
  "tag": "0026_bitter_sersi",
192
192
  "breakpoints": true
193
+ },
194
+ {
195
+ "idx": 27,
196
+ "version": "7",
197
+ "when": 1794454051454,
198
+ "tag": "0027_high_doctor_strange",
199
+ "breakpoints": true
200
+ },
201
+ {
202
+ "idx": 28,
203
+ "version": "7",
204
+ "when": 1794456064675,
205
+ "tag": "0028_sharp_power_pack",
206
+ "breakpoints": true
193
207
  }
194
208
  ]
195
209
  }
@@ -715,6 +715,27 @@ export const invoiceRules = pgTable('invoice_rules', {
715
715
  check('invoice_rules_icon_valid', sql`${t.icon} is null or (length(${t.icon}) between 1 and 64 and ${t.icon} !~ '[^a-z0-9-]')`),
716
716
  ]);
717
717
 
718
+ // A person's exception to one invoice rule for one job (docs/INVOICE_RULES.md,
719
+ // "Exceptions"): the rule matched, and a person said this job is fine as it
720
+ // is, with a reason. The rule then leaves that job alone, and its card counts
721
+ // and lists each exception, so none is hidden.
722
+ export const invoiceRuleExceptions = pgTable('invoice_rule_exceptions', {
723
+ id: id(),
724
+ organisationId: organisationId().references(() => organisations.id),
725
+ ruleId: uuid('rule_id').notNull().references(() => invoiceRules.id, { onDelete: 'cascade' }),
726
+ obligationId: uuid('obligation_id').notNull().references(() => billingObligations.id),
727
+ reason: text('reason').notNull(),
728
+ actor: text('actor').notNull(),
729
+ // What the exception was given for: the rule's item and the job's lines
730
+ // then. A change to either ends it, so a person looks again.
731
+ itemCode: text('item_code'),
732
+ lineFingerprint: text('line_fingerprint'),
733
+ createdAt: createdAt(),
734
+ }, (t) => [
735
+ uniqueIndex('invoice_rule_exceptions_rule_job').on(t.ruleId, t.obligationId),
736
+ check('invoice_rule_exceptions_reason_valid', sql`length(${t.reason}) between 1 and 200`),
737
+ ]);
738
+
718
739
  // Automations (server/core/automations.js): Flow's routines, each on or off,
719
740
  // with its own settings. The routines are defined in code; a row holds an
720
741
  // organisation's choice. No row means the routine's default.
@@ -217,9 +217,16 @@ export const WORK_FINISHED_DAYS = 30;
217
217
  const WORK_LIMIT = 2000;
218
218
  const FINAL_SQL = sql`coalesce(${seazonaOrders.status}, '') ~* '(shipped|delivered|cancelled|canceled|void|closed|archived|deleted)'`;
219
219
 
220
+ // A finished order that still has no invoice stays on the board past
221
+ // WORK_FINISHED_DAYS: its job has no Flow invoice and no accounting invoice
222
+ // linked, and the order was not cancelled. It leaves when it is billed.
223
+ const UNBILLED_SQL = sql`(coalesce(${seazonaOrders.status}, '') !~* '(cancelled|canceled|void|deleted)' and exists (
224
+ select 1 from billing_obligations o where o.source_system = 'seazona' and o.source_job_ref = ${seazonaOrders.id}
225
+ and o.invoice_id is null and o.accounting_ref is null))`;
226
+
220
227
  // The orders behind the port's listWork, for the Jobs board: every open
221
- // order, and each final order that changed in the last WORK_FINISHED_DAYS
222
- // days, newest change first. An order deleted in Seazona is never listed.
228
+ // order, each final order that changed in the last WORK_FINISHED_DAYS
229
+ // days, and each finished order not billed yet, newest change first. An order deleted in Seazona is never listed.
223
230
  // The client name is the Seazona client's company (or full name), else the
224
231
  // order's own company. `statuses` lists each status once, with the first time
225
232
  // Flow saw an order in it, so the board can order its columns.
@@ -228,7 +235,7 @@ export async function listWork(db, { now = Date.now(), finishedDays = WORK_FINIS
228
235
  const rows = await db.select({ order: seazonaOrders, company: seazonaClients.company, fullName: seazonaClients.fullName })
229
236
  .from(seazonaOrders)
230
237
  .leftJoin(seazonaClients, eq(seazonaClients.id, seazonaOrders.clientId))
231
- .where(and(isNull(seazonaOrders.deletedInSourceAt), or(sql`not (${FINAL_SQL})`, gte(seazonaOrders.lastChangedAt, since))))
238
+ .where(and(isNull(seazonaOrders.deletedInSourceAt), or(sql`not (${FINAL_SQL})`, gte(seazonaOrders.lastChangedAt, since), UNBILLED_SQL)))
232
239
  .orderBy(desc(seazonaOrders.lastChangedAt), seazonaOrders.id)
233
240
  .limit(limit);
234
241
  const firstSeen = new Map();
@@ -532,7 +532,7 @@ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedB
532
532
  });
533
533
  if (recent) {
534
534
  throw xeroError('xero_same_reference_different_amount',
535
- `Xero ${recent.InvoiceNumber || 'invoice'} has the same client and reference at ${(cents(recent.Total) / 100).toFixed(2)}. Nothing was sent. Void the extra one in Xero or in Flow.`,
535
+ `Xero ${recent.InvoiceNumber || 'invoice'} has the same client and reference at ${(cents(recent.Total) / 100).toFixed(2)}. Nothing was sent. If that Xero invoice bills this job, press Use Xero invoice.`,
536
536
  { status: 409, details: { xero: shownXero(recent) } });
537
537
  }
538
538
  }
@@ -155,7 +155,7 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
155
155
  pullPayments: ({ organisationId, since }) => readPayments(database, current(), organisationId, { since: new Date(since) }),
156
156
  // Xero's invoice for one billable job, looked up live (ledger.js): the
157
157
  // `liveCheck` for draftInvoiceForObligation.
158
- checkInvoice: ({ organisationId, obligation }) => xeroLiveCheck(database, current(), organisationId)(obligation),
158
+ checkInvoice: ({ organisationId, obligation, reference = null }) => xeroLiveCheck(database, current(), organisationId)(obligation, { reference }),
159
159
  // Xero's latest changes into the copy just before Flow drafts by rule or
160
160
  // sends (webhooks.js); no call when the copy is current.
161
161
  readChanges: ({ organisationId }) => readChangesFirst(database, current(), organisationId),
@@ -81,7 +81,7 @@ async function several(runtime, client, found, checkedAt) {
81
81
 
82
82
  // The Xero invoice for one obligation (a row of billing_obligations), looked
83
83
  // up live. Costs 1 to 4 Xero calls.
84
- export async function checkInvoice(db, runtime, client, organisationId, obligation) {
84
+ export async function checkInvoice(db, runtime, client, organisationId, obligation, { reference: draftReference = null } = {}) {
85
85
  const checkedAt = new Date(runtime.now()).toISOString();
86
86
 
87
87
  // 1. The Xero invoice Flow made from the job's Flow invoice.
@@ -132,8 +132,8 @@ export async function checkInvoice(db, runtime, client, organisationId, obligati
132
132
 
133
133
  // 4. The reference Flow would give the job's invoice, for the same
134
134
  // customer. Not exact: it refuses a draft and goes to a person.
135
- const reference = obligation.caseRef ?? obligation.sourceJobRef;
136
- if (reference) {
135
+ const references = [...new Set([obligation.caseRef ?? obligation.sourceJobRef, draftReference].filter(Boolean))];
136
+ if (references.length) {
137
137
  let customer = null;
138
138
  if (obligation.contactId) {
139
139
  const [contact] = await db.select({ name: flowContacts.name }).from(flowContacts)
@@ -147,8 +147,13 @@ export async function checkInvoice(db, runtime, client, organisationId, obligati
147
147
  }
148
148
  }
149
149
  // Another customer's invoice with the same reference is not this job's.
150
- const found = sales(await client.findInvoicesByReference(reference))
151
- .filter((inv) => !customer || inv.Contact?.ContactID === customer);
150
+ const byId = new Map();
151
+ for (const reference of references) {
152
+ for (const inv of sales(await client.findInvoicesByReference(reference))) {
153
+ if (!customer || inv.Contact?.ContactID === customer) byId.set(inv.InvoiceID, inv);
154
+ }
155
+ }
156
+ const found = [...byId.values()];
152
157
  if (found.length === 1) return evidenceFor(runtime, client, found[0], 'reference', checkedAt);
153
158
  if (found.length > 1) return several(runtime, client, found, checkedAt);
154
159
  }
@@ -159,9 +164,9 @@ export async function checkInvoice(db, runtime, client, organisationId, obligati
159
164
  // draftInvoiceForObligation(db, org, id, { liveCheck: xeroLiveCheck(db, runtime, org), actor })
160
165
  // A Xero error throws, and the ledger then refuses the draft.
161
166
  export function xeroLiveCheck(db, runtime, organisationId) {
162
- return (obligation) => withXeroLane('reads', async () => {
167
+ return (obligation, options = {}) => withXeroLane('reads', async () => {
163
168
  const { client } = await runtime.clientFor(db, organisationId);
164
- return checkInvoice(db, runtime, client, organisationId, obligation);
169
+ return checkInvoice(db, runtime, client, organisationId, obligation, options);
165
170
  });
166
171
  }
167
172
 
@@ -1,5 +1,5 @@
1
1
  import { and, eq, sql } from 'drizzle-orm';
2
- import { contacts, priceItems } from '../../db/schema.js';
2
+ import { billingObligations, contacts, invoices, obligationEvents, priceItems } from '../../db/schema.js';
3
3
  import { createContact } from '../../core/directory.js';
4
4
  import { FlowError } from '../../core/errors.js';
5
5
  import { createDraftInvoice, issueInvoice } from '../../core/invoices.js';
@@ -234,6 +234,44 @@ export async function linkXeroInvoice(db, organisationId, xeroInvoiceId, { invoi
234
234
  return { xeroInvoiceId, invoiceId };
235
235
  }
236
236
 
237
+ // "Use the Xero invoice": Send stopped a Flow invoice because Xero already
238
+ // bills the same client and reference at another amount. The Xero invoice is
239
+ // the real bill. In one step Flow voids its own copy (never sent to Xero),
240
+ // makes its Flow invoice from the Xero one, and gives that to the job. A copy
241
+ // a person voided earlier, whose job still points at it, is cleaned up the
242
+ // same way. The
243
+ // Flow copy's job is the job; an invoice billing more than one job is linked
244
+ // by hand. The Xero invoice must be the same client's.
245
+ export async function useXeroInvoice(db, organisationId, invoiceId, { xeroInvoiceId, actor }) {
246
+ if (!actor) throw new FlowError('invalid', 'actor is required');
247
+ const row = await rowFor(db, organisationId, xeroInvoiceId);
248
+ return db.transaction(async (tx) => {
249
+ const { rows: [invoice] } = await tx.execute(sql`select id, number, status, contact_id from invoices
250
+ where organisation_id = ${organisationId} and id = ${invoiceId} for update`);
251
+ if (!invoice) throw new FlowError('not_found', 'Flow invoice not found');
252
+ // A copy voided earlier, still holding its job, gives way the same way.
253
+ if (!['issued', 'void'].includes(invoice.status)) throw new FlowError('invalid', 'only an approved or void Flow invoice can give way to a Xero invoice');
254
+ if (row.contactId !== invoice.contact_id) throw new FlowError('invalid', 'the Xero invoice is for another client');
255
+ const { rows: pushed } = await tx.execute(sql`select 1 from xero_invoice_pushes where organisation_id = ${organisationId} and invoice_id = ${invoiceId} and xero_invoice_id is not null limit 1`);
256
+ if (pushed.length) throw new FlowError('invalid', 'Flow sent that invoice to Xero already');
257
+ const jobs = await tx.select({ id: billingObligations.id }).from(billingObligations)
258
+ .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.invoiceId, invoiceId)))
259
+ .for('update');
260
+ if (jobs.length > 1) throw new FlowError('invalid', 'the Flow invoice bills more than one job; link each by hand');
261
+ if (invoice.status === 'issued') await tx.update(invoices).set({ status: 'void', updatedAt: sql`now()` }).where(eq(invoices.id, invoiceId));
262
+ else if (!jobs.length) throw new FlowError('invalid', 'the void Flow invoice holds no job');
263
+ for (const job of jobs) {
264
+ await tx.update(billingObligations).set({ invoiceId: null, draftBasis: null, updatedAt: sql`now()` }).where(eq(billingObligations.id, job.id));
265
+ await tx.insert(obligationEvents).values({
266
+ createdAt: sql`clock_timestamp()`, organisationId, obligationId: job.id, kind: 'draft_deleted', actor,
267
+ evidence: { invoiceId, reason: 'used_xero_invoice', xeroInvoiceId, xeroNumber: row.number ?? null },
268
+ });
269
+ }
270
+ const made = await makeFrom(tx, organisationId, row, { jobId: jobs[0]?.id ?? null, actor });
271
+ return { voided: { id: invoiceId, number: invoice.number }, invoice: { id: made.invoice.id, number: made.invoice.number }, xeroInvoiceId, jobId: jobs[0]?.id ?? null };
272
+ });
273
+ }
274
+
237
275
  // Makes Flow's own invoice from a Xero invoice in the copy, finalised on the
238
276
  // Xero invoice's date, and links the two. With `jobId`, the job gets it as its
239
277
  // invoice. The client is Flow's customer of the same name, or a new one.
@@ -90,6 +90,7 @@ function invoiceRow(r) {
90
90
  id: r.xero_invoice_id,
91
91
  number: r.invoice_number,
92
92
  contactId: r.xero_contact_id,
93
+ reference: r.reference ?? null,
93
94
  status: r.status,
94
95
  // The ledger's accounting state for this invoice.
95
96
  state: ledgerState({ Status: r.status, AmountPaid: Number(r.amount_paid_cents ?? 0), AmountCredited: Number(r.amount_credited_cents ?? 0) }),
@@ -109,10 +110,11 @@ function invoiceRow(r) {
109
110
  // Flow invoices Flow sent, or one customer within a date range. Answers the
110
111
  // mirror's state with them, and the latest change time it holds.
111
112
  export async function findInvoices(db, organisationId, query = {}) {
112
- const { invoiceIds = [], numbers = [], flowInvoiceIds = [], contactId = null, from = null, to = null } = query;
113
+ const { invoiceIds = [], numbers = [], references = [], flowInvoiceIds = [], contactId = null, from = null, to = null } = query;
113
114
  const where = [];
114
115
  if (invoiceIds.length) where.push(sql`i.xero_invoice_id = any(${ids(invoiceIds)}::text[])`);
115
116
  if (numbers.length) where.push(sql`upper(i.invoice_number) = any(${sql.param([...new Set(numbers.map((n) => String(n).toUpperCase()))])}::text[])`);
117
+ if (references.length) where.push(sql`upper(i.reference) = any(${sql.param([...new Set(references.map((r) => String(r).toUpperCase()))])}::text[])`);
116
118
  if (flowInvoiceIds.length) where.push(sql`p.invoice_id = any(${ids(flowInvoiceIds)}::uuid[])`);
117
119
  if (contactId) {
118
120
  where.push(sql`(i.xero_contact_id = ${contactId}
@@ -8,7 +8,7 @@ import { createCustomer, linkCustomer, matchCustomer } from './create-contact.js
8
8
  import { createXeroClient } from './client.js';
9
9
  import { describePush, listPushes, MAX_BATCH, previewDrafts, pushDrafts } from './drafts.js';
10
10
  import { recordXeroEvidence, recordXeroPayments } from './ledger.js';
11
- import { IMPORT_STATES, importOverview, linkXeroInvoice, makeFlowInvoice, matchXero, saveImportSettings } from './links.js';
11
+ import { IMPORT_STATES, importOverview, linkXeroInvoice, makeFlowInvoice, matchXero, saveImportSettings, useXeroInvoice } from './links.js';
12
12
  import { queueHistoryImport, resetImport } from './import.js';
13
13
  import { MIRROR_RESOURCES, mirrorStatus, mirrorXero } from './mirror.js';
14
14
  import { accountingStatus, checkSentInvoices } from './reconcile.js';
@@ -43,6 +43,10 @@ import { messagePage as themedMessagePage } from '../../ui/page.js';
43
43
  // status and any difference, from the copy
44
44
  // POST /invoices/:invoiceId/recheck owner or admin: reads Xero's changes,
45
45
  // then checks that one invoice again (Mark resolved)
46
+ // POST /invoices/:invoiceId/use-xero owner or admin: { xeroInvoiceId } Send
47
+ // stopped this invoice for a Xero invoice of the
48
+ // same client and reference: void the Flow copy
49
+ // and give its job the Xero invoice (links.js)
46
50
  // POST /obligations/:obligationId/check owner or admin: Xero's invoice for
47
51
  // a billable job, recorded on the ledger
48
52
  // POST /payments/sync owner or admin: Xero payments to the ledger
@@ -65,6 +69,7 @@ import { messagePage as themedMessagePage } from '../../ui/page.js';
65
69
  export const XERO_ACTIONS = Object.freeze({
66
70
  sync: { cooldownMs: 5 * 60_000 },
67
71
  recheck: { cooldownMs: 30_000 },
72
+ useXero: { cooldownMs: 10_000 },
68
73
  push: { cooldownMs: 0, budget: false },
69
74
  createContact: { cooldownMs: 30_000 },
70
75
  obligationCheck: { cooldownMs: 30_000 },
@@ -355,6 +360,18 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
355
360
  res.json({ ok: true, resolved: !status?.finding, closed: result.closed, status });
356
361
  }));
357
362
 
363
+ router.post('/invoices/:invoiceId/use-xero', ...manager, act('useXero', (req) => req.params.invoiceId), handle(log, async (req, res) => {
364
+ const organisationId = await requireOrganisation(db, req.body?.organisationId);
365
+ const [invoiceId] = requireInvoiceIds([req.params.invoiceId], 'invoiceId');
366
+ const xeroInvoiceId = typeof req.body?.xeroInvoiceId === 'string' && UUID.test(req.body.xeroInvoiceId) ? req.body.xeroInvoiceId : null;
367
+ if (!xeroInvoiceId) throw new FlowError('invalid', 'xeroInvoiceId must name a Xero invoice');
368
+ // The Xero invoice may be newer than Flow's copy: read Xero's changes first.
369
+ await mirrorXero(db, runtime, organisationId, { resources: ['invoices'] });
370
+ const result = await useXeroInvoice(db, organisationId, invoiceId, { xeroInvoiceId, actor: `person:${req.person.id}` });
371
+ log.info?.(`[xero] organisation ${organisationId}: person ${req.person.id} used Xero invoice ${xeroInvoiceId} for Flow invoice ${invoiceId}`);
372
+ res.json({ ok: true, ...result });
373
+ }));
374
+
358
375
  router.post('/invoices/push', ...manager, act('push'), handle(log, async (req, res) => {
359
376
  const organisationId = await requireOrganisation(db, req.body?.organisationId);
360
377
  const invoiceIds = requireInvoiceIds(req.body?.invoiceIds);
package/server/ui/app.js CHANGED
@@ -28,7 +28,7 @@ import { isSearchKey, openSearch } from '/assets/ui/search.js';
28
28
  import { alertStrip } from '/assets/ui/alerts.js';
29
29
  import { initialsOf, personLabel } from '/assets/ui/person.js';
30
30
  import { startTips } from '/assets/ui/tip.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
+ import { cardRows, copyButton, crumbs, dialog, edgeFade, fieldError, h, holdButton, iconPicker, linksOpenWith, listFrame, listHead, notice, pageHeader, pickOne, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
32
32
  import { NAV_DEFAULT, NAV_LOCKED } from '/assets/ui/nav-pins.js';
33
33
 
34
34
  const $ = (id) => document.getElementById(id);
@@ -339,7 +339,7 @@ function navigate(event) {
339
339
  // The shared pieces a view module (invoicing.js) builds with. Its `go`
340
340
  // takes a path, or a link's click event.
341
341
  const ui = () => ({
342
- h, flow, toast, notice, cardRows, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, settingRow, dialog, pickOne, iconPicker, me, holdButton, tileCard, tileSection, crumbs,
342
+ h, flow, toast, notice, cardRows, copyButton, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, settingRow, dialog, pickOne, iconPicker, me, holdButton, tileCard, tileSection, crumbs,
343
343
  go: (target, options) => (typeof target === 'string' ? go(target, options) : navigate(target)),
344
344
  });
345
345
 
@@ -237,6 +237,8 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
237
237
  .fx-change__head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; }
238
238
  .fx-change__acts { display: flex; flex-wrap: wrap; gap: 8px; }
239
239
  .fx-change__note { font-size: 13px; color: var(--t-dim); }
240
+ .fx-copy__text { width: 100%; min-height: 200px; padding: 8px 10px; font-family: ui-monospace, monospace; font-size: 12px; resize: vertical; }
241
+ @media (max-width: 1023px) { .fx-copy { width: 44px; min-height: 44px; } }
240
242
  .fx-change__recheck { width: 30px; }
241
243
  .fx-change__recheck .fx-lucide { width: 14px; height: 14px; }
242
244
  .fx-change__recheck.is-busy .fx-lucide { animation: fx-spin 1s linear infinite; }
@@ -359,71 +361,44 @@ a.fx-link:hover, a.fx-link:focus-visible { border-color: var(--t-strong); }
359
361
  background: #fff; box-shadow: inset 0 0 0 1px var(--fx-line);
360
362
  }
361
363
  .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; }
364
+ /* Home: a row of stat cards (label, number, total). A card with work has a
365
+ thin edge in its tone; a card at 0 is plain. The link covers the card;
366
+ Send sits above it. Four across on a desk, two on a tablet, rows on a phone. */
367
+ .fx-home { display: grid; gap: 16px; max-width: 1040px; }
368
+ .fx-stats { display: grid; gap: 12px; grid-template-columns: repeat(4, minmax(0, 1fr)); max-width: 1040px; }
369
+ .fx-stat {
370
+ position: relative; display: grid; align-content: start; gap: 12px; padding: 18px 20px;
371
+ border: 1px solid var(--fx-line); border-radius: var(--t-radius-lg); background: var(--t-paper);
372
+ box-shadow: inset 3px 0 0 var(--fx-stat-edge, transparent); transition: border-color .15s;
373
+ }
374
+ .fx-stat:hover, .fx-stat:focus-within { border-color: var(--t-control); }
375
+ .fx-stat[data-tone="accent"] { --fx-stat-edge: var(--t-accent); }
376
+ .fx-stat[data-tone="danger"] { --fx-stat-edge: var(--t-danger); }
377
+ .fx-stat[data-tone="warn"] { --fx-stat-edge: var(--t-warn); }
378
+ .fx-stat[data-tone="info"] { --fx-stat-edge: var(--t-accent); }
379
+ .fx-stat__link { display: grid; gap: 6px; color: inherit; text-decoration: none; }
380
+ .fx-stat__link::after { content: ''; position: absolute; inset: 0; border-radius: inherit; }
381
+ .fx-stat__link:focus-visible { outline: none; }
382
+ .fx-stat:has(.fx-stat__link:focus-visible) { outline: 2px solid var(--t-accent); outline-offset: 2px; }
383
+ .fx-stat__n { color: var(--t-strong); font: 600 34px/1.1 var(--t-body); font-variant-numeric: tabular-nums; }
384
+ .fx-stat[data-tone="none"] .fx-stat__n { color: var(--t-dim); font-weight: 500; }
385
+ .fx-stat__sum { min-height: 1.3em; color: var(--t-dim); font-size: 14px; font-variant-numeric: tabular-nums; }
386
+ .fx-stat__act { position: relative; z-index: 1; display: grid; gap: 6px; justify-items: start; }
387
+ .fx-stat__note { color: var(--t-dim); font-size: 13px; }
418
388
 
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; }
389
+ @media (max-width: 1023px) {
390
+ .fx-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
391
+ .fx-stat__act .fx-btn { min-height: 44px; }
422
392
  }
423
393
  @media (max-width: 639px) {
424
- .fx-home__send { padding: 24px 20px; }
425
- .fx-home__count { font-size: 60px; }
426
- .fx-home__btn { width: 100%; }
394
+ .fx-stats { grid-template-columns: 1fr; gap: 8px; }
395
+ .fx-stat { grid-template-columns: 1fr auto; align-items: center; padding: 12px 16px; }
396
+ .fx-stat__link { grid-template-columns: 1fr auto; grid-column: 1 / -1; align-items: center; min-height: 44px; }
397
+ .fx-stat__link .fx-label { grid-row: 1; }
398
+ .fx-stat__sum { grid-row: 2; grid-column: 1; min-height: 0; }
399
+ .fx-stat__sum:empty { display: none; }
400
+ .fx-stat__n { grid-row: 1 / span 2; grid-column: 2; font-size: 26px; }
401
+ .fx-stat__act { grid-column: 1 / -1; justify-items: stretch; }
427
402
  }
428
403
 
429
404
  .fx-tiles { display: grid; gap: 12px; }
@@ -1021,6 +996,7 @@ th:hover > .fx-th-grip::after { background: var(--t-brd-hi); }
1021
996
  follows the pointer, the lane that takes it, and the "Waiting for
1022
997
  Seazona" chip of a stage move the work system has not made yet. */
1023
998
  .fx-job__wait { justify-self: start; margin-top: 8px; padding: 1px 8px; border: 1px dashed var(--t-accent); border-radius: 999px; color: var(--t-accent); font-size: 11.5px; font-weight: 600; }
999
+ .fx-job__wait--warn { border-color: var(--t-warn-brd); background: var(--t-warn-bg); color: var(--t-warn); }
1024
1000
  .fx-job--lifted { opacity: .35; }
1025
1001
  .fx-job--ghost { position: fixed; top: 0; left: 0; z-index: 60; pointer-events: none; box-shadow: var(--t-shadow-lg, var(--t-shadow)); cursor: grabbing; }
1026
1002
  .fx-board--dragging, .fx-board--dragging .fx-job { cursor: grabbing; user-select: none; }
package/server/ui/home.js CHANGED
@@ -1,9 +1,9 @@
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
1
+ // Home: the page a person lands on, a row of stat cards like an accounting
2
+ // dashboard. Ready (with Send), Pending, To match and Forms each show their
3
+ // count, and a total when it has one. A card with work has a thin edge in its
4
+ // tone; a card at 0 is plain. No card says "done": the counts say it. Each card
5
+ // opens its page. Send sends every ready invoice with the same step as the
6
+ // Invoices page (POST /invoices/send-ready). No calls of its own to other
7
7
  // systems: every number is its page's own.
8
8
  import { formatCents, localDate } from '/assets/ui/invoicing.js';
9
9
  import { sendModeNote } from '/assets/ui/invoice-notes.js';
@@ -11,17 +11,10 @@ import { lucide } from '/assets/ui/custom-fields.js';
11
11
 
12
12
  const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
13
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
14
  export async function renderHome(main, ui) {
22
- const { h, flow, toast, notice, pageHeader, dialog, go, me } = ui;
15
+ const { h, flow, toast, notice, pageHeader, dialog, go } = ui;
23
16
  const host = h('div', { class: 'fx-home' });
24
- main.append(pageHeader(greeting(me?.person)), host);
17
+ main.append(pageHeader('Home'), host);
25
18
  const tabQuery = (tab) => `/api/invoicing/invoices?${new URLSearchParams({ status: tab, limit: '1' })}`;
26
19
 
27
20
  async function load() {
@@ -45,45 +38,43 @@ export async function renderHome(main, ui) {
45
38
  }
46
39
  if (!host.isConnected) return;
47
40
  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 },
41
+ { key: 'pending', href: '/invoices?tab=pending', label: 'Pending', tone: 'danger', count: pending.total, sum: pending.totalCents },
42
+ { key: 'match', href: '/reconcile', label: 'Unmatched', tone: 'warn', count: toMatch ? (toMatch.jobs?.length ?? 0) + (toMatch.invoices?.length ?? 0) : null },
43
+ forms && { key: 'forms', href: '/captures', label: 'Forms', tone: 'info', count: forms.captures.filter((c) => c.state === 'ready').length },
51
44
  ].filter(Boolean));
52
45
  }
53
46
 
47
+ // One stat card. The link covers the card; Send sits above it, so the card
48
+ // has no button inside a link. `count: null`: the page could not be read.
49
+ function card({ key, href, label, tone, count, sum = null }, extra = null) {
50
+ const unknown = count == null;
51
+ return h('article', { class: 'fx-stat', 'data-tone': count ? tone : 'none', 'aria-labelledby': `home-${key}` },
52
+ h('a', {
53
+ class: 'fx-stat__link', href, onclick: open, id: `home-${key}`, title: unknown ? 'Could not load' : null,
54
+ 'aria-label': [label, unknown ? 'could not load' : String(count), count && sum != null ? formatCents(sum) : null].filter(Boolean).join(', '),
55
+ },
56
+ h('span', { class: 'fx-label' }, label),
57
+ h('span', { class: 'fx-stat__n' }, unknown ? '–' : String(count)),
58
+ h('span', { class: 'fx-stat__sum' }, count && sum != null ? formatCents(sum) : '')),
59
+ extra);
60
+ }
61
+
54
62
  function draw(context, body, items) {
55
63
  const to = body.sendsTo;
56
- const counts = body.counts ?? {};
57
- const ready = counts.ready ?? 0;
64
+ const ready = body.counts?.ready ?? 0;
58
65
  const blocked = to && body.sendMode && (!body.sendMode.connected || !body.sendMode.writesAllowed);
59
66
  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);
67
+ // Send shows only to people who can send, and only while it can work.
68
+ const sendButton = ready && canSend ? h('div', { class: 'fx-stat__act' }, h('button', {
69
+ class: 'fx-btn fx-btn--primary', type: 'button',
70
+ onclick: (event) => send(to, ready, body.sendMode, event.currentTarget),
71
+ }, lucide(h, 'send'), h('span', {}, 'Send'))) : null;
72
+ host.replaceChildren(...[
73
+ // A broken accounting connection shows at any count, not only with Ready.
74
+ blocked ? notice(sendModeNote(to, body.sendMode), 'warn') : null,
75
+ h('section', { class: 'fx-stats', 'aria-label': 'Invoices and jobs' },
76
+ card({ key: 'ready', href: '/invoices?tab=ready', label: 'Ready', tone: 'accent', count: ready, sum: body.totalCents }, sendButton),
77
+ items.map((item) => card(item)))].filter(Boolean));
87
78
  }
88
79
 
89
80
  function open(event) {
@@ -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)