@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
|
@@ -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
|
}
|
package/server/db/schema.js
CHANGED
|
@@ -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,
|
|
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();
|
|
@@ -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 = {
|
|
@@ -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.
|
|
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.
|
|
@@ -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);
|
|
@@ -128,8 +132,8 @@ export async function checkInvoice(db, runtime, client, organisationId, obligati
|
|
|
128
132
|
|
|
129
133
|
// 4. The reference Flow would give the job's invoice, for the same
|
|
130
134
|
// customer. Not exact: it refuses a draft and goes to a person.
|
|
131
|
-
const
|
|
132
|
-
if (
|
|
135
|
+
const references = [...new Set([obligation.caseRef ?? obligation.sourceJobRef, draftReference].filter(Boolean))];
|
|
136
|
+
if (references.length) {
|
|
133
137
|
let customer = null;
|
|
134
138
|
if (obligation.contactId) {
|
|
135
139
|
const [contact] = await db.select({ name: flowContacts.name }).from(flowContacts)
|
|
@@ -143,8 +147,13 @@ export async function checkInvoice(db, runtime, client, organisationId, obligati
|
|
|
143
147
|
}
|
|
144
148
|
}
|
|
145
149
|
// Another customer's invoice with the same reference is not this job's.
|
|
146
|
-
const
|
|
147
|
-
|
|
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()];
|
|
148
157
|
if (found.length === 1) return evidenceFor(runtime, client, found[0], 'reference', checkedAt);
|
|
149
158
|
if (found.length > 1) return several(runtime, client, found, checkedAt);
|
|
150
159
|
}
|
|
@@ -155,9 +164,9 @@ export async function checkInvoice(db, runtime, client, organisationId, obligati
|
|
|
155
164
|
// draftInvoiceForObligation(db, org, id, { liveCheck: xeroLiveCheck(db, runtime, org), actor })
|
|
156
165
|
// A Xero error throws, and the ledger then refuses the draft.
|
|
157
166
|
export function xeroLiveCheck(db, runtime, organisationId) {
|
|
158
|
-
return (obligation) => withXeroLane('reads', async () => {
|
|
167
|
+
return (obligation, options = {}) => withXeroLane('reads', async () => {
|
|
159
168
|
const { client } = await runtime.clientFor(db, organisationId);
|
|
160
|
-
return checkInvoice(db, runtime, client, organisationId, obligation);
|
|
169
|
+
return checkInvoice(db, runtime, client, organisationId, obligation, options);
|
|
161
170
|
});
|
|
162
171
|
}
|
|
163
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-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';
|
|
@@ -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() {
|
|
@@ -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
|
@@ -359,11 +359,78 @@ a.fx-link:hover, a.fx-link:focus-visible { border-color: var(--t-strong); }
|
|
|
359
359
|
background: #fff; box-shadow: inset 0 0 0 1px var(--fx-line);
|
|
360
360
|
}
|
|
361
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
|
+
|
|
362
429
|
.fx-tiles { display: grid; gap: 12px; }
|
|
363
430
|
.fx-tiles + .fx-tiles { margin-top: 16px; }
|
|
364
431
|
.fx-tiles__head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
365
432
|
.fx-tiles__head h2 { color: var(--t-strong); font: 600 15px/1.3 var(--t-body); }
|
|
366
|
-
.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; }
|
|
367
434
|
.fx-tiles__more:hover, .fx-tiles__more:focus-visible { color: var(--t-strong); }
|
|
368
435
|
.fx-tiles__more .fx-lucide { width: 15px; height: 15px; }
|
|
369
436
|
.fx-tiles__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); gap: 12px; }
|
|
@@ -954,6 +1021,7 @@ th:hover > .fx-th-grip::after { background: var(--t-brd-hi); }
|
|
|
954
1021
|
follows the pointer, the lane that takes it, and the "Waiting for
|
|
955
1022
|
Seazona" chip of a stage move the work system has not made yet. */
|
|
956
1023
|
.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; }
|
|
1024
|
+
.fx-job__wait--warn { border-color: var(--t-warn-brd); background: var(--t-warn-bg); color: var(--t-warn); }
|
|
957
1025
|
.fx-job--lifted { opacity: .35; }
|
|
958
1026
|
.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; }
|
|
959
1027
|
.fx-board--dragging, .fx-board--dragging .fx-job { cursor: grabbing; user-select: none; }
|