@arcforgelabs/flow 2026.10.34 → 2026.10.35
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/README.md +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/api/custom-fields.js +5 -4
- package/server/api/invoicing.js +41 -11
- package/server/auth/people.js +35 -0
- package/server/auth/routes.js +14 -0
- package/server/core/custom-fields.js +8 -0
- package/server/core/job-board.js +2 -0
- package/server/core/ledger.js +82 -2
- package/server/db/migrations/0019_people_views.sql +1 -0
- package/server/db/migrations/meta/0019_snapshot.json +4295 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +5 -1
- package/server/integrations/port.js +1 -0
- package/server/integrations/seazona/index.js +17 -5
- package/server/integrations/seazona/settings.js +24 -3
- package/server/integrations/seazona/stage-moves.js +10 -12
- package/server/release-channel.js +8 -6
- package/server/ui/flow.css +81 -2
- package/server/ui/invoice-notes.js +1 -1
- package/server/ui/invoicing.js +104 -45
- package/server/ui/job-board.js +1 -1
- package/server/ui/job-change.js +78 -0
- package/server/ui/job-view.js +119 -0
- package/server/ui/jobs.js +481 -50
- package/server/ui/routes.js +1 -1
package/server/db/schema.js
CHANGED
|
@@ -177,7 +177,10 @@ export const PERSON_STATUSES = ['active', 'pending', 'disabled'];
|
|
|
177
177
|
// are stored lower-case. `clerk_user_id` is set on the person's first verified
|
|
178
178
|
// sign-in; until then the row is an invitation. `appearance` is the person's
|
|
179
179
|
// own design system, light or dark and accent (server/auth/people.js,
|
|
180
|
-
// normaliseAppearance); null until they first choose.
|
|
180
|
+
// normaliseAppearance); null until they first choose. `views` is the
|
|
181
|
+
// person's own view of each page, such as how the Jobs page groups, sorts
|
|
182
|
+
// and filters ({ jobs: … }, server/ui/job-view.js); null until they first
|
|
183
|
+
// change one.
|
|
181
184
|
export const people = pgTable('people', {
|
|
182
185
|
id: id(),
|
|
183
186
|
email: text('email').notNull(),
|
|
@@ -188,6 +191,7 @@ export const people = pgTable('people', {
|
|
|
188
191
|
invitedBy: uuid('invited_by').references(() => people.id, { onDelete: 'set null' }),
|
|
189
192
|
linkedAt: timestamp('linked_at', { withTimezone: true }),
|
|
190
193
|
appearance: jsonb('appearance'),
|
|
194
|
+
views: jsonb('views'),
|
|
191
195
|
createdAt: createdAt(),
|
|
192
196
|
updatedAt: updatedAt(),
|
|
193
197
|
}, (t) => [
|
|
@@ -23,6 +23,7 @@ export const CAPABILITIES = Object.freeze([
|
|
|
23
23
|
'checkInvoice', // does the accounting system hold an invoice for a billable job? (the ledger's liveCheck)
|
|
24
24
|
'billableJobs', // billable jobs by day from the work system's copy in Flow, for the daily reconcile; never a live call
|
|
25
25
|
'jobReferenceFields', // { patient }: the fields a job gives the invoice Reference (server/core/references.js)
|
|
26
|
+
'jobLink', // { url, exact } or null: the link to one job in the work system's own web app, for "Open in" on a job's problem; never a live call
|
|
26
27
|
'listWork', // the work system's jobs from its copy in Flow, open and lately finished, for the Jobs board; a job may carry `pendingMove`
|
|
27
28
|
'moveWork', // { sourceJobRef, toStatus, personId }: a person moved a job to another stage on the Jobs board; answers what happens (a hand-off when the system's API cannot change it)
|
|
28
29
|
'cancelMove', // { sourceJobRef }: take back a move the work system has not made yet
|
|
@@ -8,13 +8,13 @@ import { readBillableJobs } from './reconcile.js';
|
|
|
8
8
|
import { SEAZONA_CALL_GUARD } from './governor.js';
|
|
9
9
|
import { createSeazonaRouter } from './routes.js';
|
|
10
10
|
import {
|
|
11
|
-
cancelStageMove, expiredStageMoves, readPersonSettings, requestStageMove, settleStageMoves, waitingStageMoves, writePersonSetting,
|
|
11
|
+
cancelStageMove, expiredStageMoves, handOff, readPersonSettings, requestStageMove, settleStageMoves, waitingStageMoves, writePersonSetting,
|
|
12
12
|
} from './stage-moves.js';
|
|
13
13
|
import { orderPatient } from './patients.js';
|
|
14
14
|
import { createSeazonaScheduler } from './scheduler.js';
|
|
15
15
|
import { readSweepHealth } from './sweep.js';
|
|
16
16
|
import { resetFailedLanes, resetStoppedLanes } from './store.js';
|
|
17
|
-
import { describeSeazonaSettings, resolveSeazonaSettings } from './settings.js';
|
|
17
|
+
import { describeSeazonaSettings, orderLink, resolveSeazonaSettings } from './settings.js';
|
|
18
18
|
import { seazonaSchema } from './tables.js';
|
|
19
19
|
|
|
20
20
|
// The Seazona adapter (docs/SEAZONA.md). FLOW_INTEGRATIONS=seazona turns it
|
|
@@ -51,7 +51,7 @@ export const SEAZONA_SETUP = {
|
|
|
51
51
|
{ name: 'SEAZONA_API_BASE_URL', label: 'API address', kind: 'url', help: 'The API root of your account, https://…' },
|
|
52
52
|
{ name: 'SEAZONA_API_CLIENT_ID', label: 'Client id' },
|
|
53
53
|
{ name: 'SEAZONA_API_CLIENT_SECRET', label: 'Client secret', secret: true, help: 'Stored encrypted. Flow never shows it again.' },
|
|
54
|
-
{ name: 'SEAZONA_ORDER_URL', label: 'Order address', kind: 'url', help: 'Optional.
|
|
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
56
|
],
|
|
57
57
|
};
|
|
@@ -225,9 +225,15 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
225
225
|
const [work, waiting] = await Promise.all([listWork(db, { now: now() }), waitingStageMoves(db)]);
|
|
226
226
|
// A stage move Seazona has not made yet (stage-moves.js): the card
|
|
227
227
|
// shows in its new lane, waiting for Seazona.
|
|
228
|
+
// Each card also carries the link that opens its order in Seazona.
|
|
229
|
+
const { settings } = current();
|
|
228
230
|
for (const item of work.items) {
|
|
231
|
+
item.link = orderLink(settings, { sourceJobRef: item.sourceJobRef });
|
|
229
232
|
const move = waiting.get(item.sourceJobRef);
|
|
230
|
-
if (move)
|
|
233
|
+
if (move) {
|
|
234
|
+
const { to, from, requestedAt, expiresAt, openUrl, openExact } = handOff(move, { settings });
|
|
235
|
+
item.pendingMove = { to, from, requestedAt, expiresAt, openUrl, openExact };
|
|
236
|
+
}
|
|
231
237
|
}
|
|
232
238
|
return work;
|
|
233
239
|
},
|
|
@@ -237,7 +243,7 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
237
243
|
// records a hand-off and answers what the person does in Seazona.
|
|
238
244
|
async moveWork({ sourceJobRef, toStatus, personId = null }) {
|
|
239
245
|
const { db, settings } = current();
|
|
240
|
-
return requestStageMove(db, { orderId: sourceJobRef, toStatus, personId,
|
|
246
|
+
return requestStageMove(db, { orderId: sourceJobRef, toStatus, personId, settings, now: now() });
|
|
241
247
|
},
|
|
242
248
|
|
|
243
249
|
async cancelMove({ sourceJobRef }) {
|
|
@@ -267,6 +273,12 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
267
273
|
return historyJobIssues(current().db, refs);
|
|
268
274
|
},
|
|
269
275
|
|
|
276
|
+
// The link to one order in Seazona's web app, for "Open in Seazona" on
|
|
277
|
+
// a job's problem (settings.js, orderLink). Never calls Seazona.
|
|
278
|
+
jobLink({ sourceJobRef }) {
|
|
279
|
+
return runtime ? orderLink(runtime.settings, { sourceJobRef }) : null;
|
|
280
|
+
},
|
|
281
|
+
|
|
270
282
|
// Seazona's products, for the price list (server/core/price-list.js).
|
|
271
283
|
// Reads Flow's copy only.
|
|
272
284
|
async listPriceItems() {
|
|
@@ -55,9 +55,18 @@ export function normaliseBaseUrl(value) {
|
|
|
55
55
|
return `${url.origin}${path}/v1`;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
// SEAZONA_ORDER_URL: the address of one order in Seazona's own web app, with
|
|
59
|
+
// {ref} where the order id goes. Seazona's API guide gives no order page
|
|
60
|
+
// address, and each lab has its own, so a person may also paste the address
|
|
61
|
+
// of any one order as the browser shows it: the order id in it (a GUID)
|
|
62
|
+
// becomes {ref}. Answers the template, or null: https only, and an order id
|
|
63
|
+
// or {ref} in it. It never throws; an address Flow cannot read turns it off.
|
|
64
|
+
const ORDER_ID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
65
|
+
|
|
66
|
+
export function orderUrlTemplate(value) {
|
|
67
|
+
const given = text(value);
|
|
68
|
+
const raw = given.includes('{ref}') ? given : ORDER_ID.test(given) ? given.replace(ORDER_ID, '{ref}') : '';
|
|
69
|
+
if (!raw) return null;
|
|
61
70
|
try {
|
|
62
71
|
return new URL(raw.replaceAll('{ref}', 'x')).protocol === 'https:' ? raw : null;
|
|
63
72
|
} catch {
|
|
@@ -65,6 +74,18 @@ function orderUrlTemplate(value) {
|
|
|
65
74
|
}
|
|
66
75
|
}
|
|
67
76
|
|
|
77
|
+
// The link to one order for "Open in Seazona": the order's own page when the
|
|
78
|
+
// installation set SEAZONA_ORDER_URL, else the home page of the lab's
|
|
79
|
+
// Seazona web app when the API address names it (https://{lab}.seazonaapi.net),
|
|
80
|
+
// else null. `exact` is true only for the order's own page.
|
|
81
|
+
export function orderLink(settings, { sourceJobRef } = {}) {
|
|
82
|
+
const template = settings?.orderUrlTemplate ?? null;
|
|
83
|
+
if (template && sourceJobRef) return { url: template.replaceAll('{ref}', encodeURIComponent(sourceJobRef)), exact: true };
|
|
84
|
+
const host = settings?.baseUrl ? new URL(settings.baseUrl).hostname : '';
|
|
85
|
+
const lab = /^([a-z0-9-]+)\.seazonaapi\.net$/i.exec(host)?.[1];
|
|
86
|
+
return lab ? { url: `https://${lab}.seazona.net/`, exact: false } : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
68
89
|
// Returns the resolved settings, or throws SeazonaSettingsError. `mode` is
|
|
69
90
|
// null when SEAZONA_MODE is unset: the adapter is then not configured.
|
|
70
91
|
export function resolveSeazonaSettings(env = process.env) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { and, eq, gte, inArray, lt, sql } from 'drizzle-orm';
|
|
2
2
|
import { FlowError } from '../../core/errors.js';
|
|
3
3
|
import { orderStatusLabel } from './map.js';
|
|
4
|
+
import { orderLink } from './settings.js';
|
|
4
5
|
import { seazonaOrders, seazonaPersonSettings, seazonaStageMoves } from './schema.js';
|
|
5
6
|
|
|
6
7
|
// Stage moves on the Jobs board (docs/SEAZONA.md, "Stage moves"). Seazona's
|
|
@@ -16,16 +17,9 @@ export const HAND_OFF_MS = 24 * 60 * 60_000;
|
|
|
16
17
|
const EXPIRED_ALERT_MS = 7 * 24 * 60 * 60_000;
|
|
17
18
|
const MAX_STATUS = 80;
|
|
18
19
|
|
|
19
|
-
// The address that opens one order in Seazona, from SEAZONA_ORDER_URL with
|
|
20
|
-
// its {ref} placeholder, or null when the installation has not set one.
|
|
21
|
-
export function orderUrl(template, ref) {
|
|
22
|
-
if (!template || !template.includes('{ref}')) return null;
|
|
23
|
-
return template.replaceAll('{ref}', encodeURIComponent(ref));
|
|
24
|
-
}
|
|
25
|
-
|
|
26
20
|
// Record a person's move of one order to `toStatus`, and answer the hand-off
|
|
27
21
|
// the board shows. A second move of the same order replaces the first.
|
|
28
|
-
export async function requestStageMove(db, { orderId, toStatus, personId = null,
|
|
22
|
+
export async function requestStageMove(db, { orderId, toStatus, personId = null, settings = null, now = Date.now() }) {
|
|
29
23
|
const to = typeof toStatus === 'string' ? toStatus.trim() : '';
|
|
30
24
|
if (!to || to.length > MAX_STATUS) throw new FlowError('invalid', 'toStatus must be a Seazona status');
|
|
31
25
|
const [order] = await db.select().from(seazonaOrders).where(eq(seazonaOrders.id, String(orderId ?? '')));
|
|
@@ -41,7 +35,7 @@ export async function requestStageMove(db, { orderId, toStatus, personId = null,
|
|
|
41
35
|
requestedAt: at, expiresAt: new Date(at.getTime() + HAND_OFF_MS), state: 'waiting', settledAt: null,
|
|
42
36
|
};
|
|
43
37
|
await db.insert(seazonaStageMoves).values(row).onConflictDoUpdate({ target: seazonaStageMoves.orderId, set: row });
|
|
44
|
-
return handOff(row, {
|
|
38
|
+
return handOff(row, { settings });
|
|
45
39
|
}
|
|
46
40
|
|
|
47
41
|
// Take a waiting move back: the card returns to Seazona's stage.
|
|
@@ -92,8 +86,11 @@ export async function expiredStageMoves(db, { now = Date.now() } = {}) {
|
|
|
92
86
|
));
|
|
93
87
|
}
|
|
94
88
|
|
|
95
|
-
// What the board shows for a waiting move.
|
|
96
|
-
|
|
89
|
+
// What the board shows for a waiting move. `link` opens the order in
|
|
90
|
+
// Seazona's web app (settings.js, orderLink); `exact` is false when it opens
|
|
91
|
+
// only the lab's Seazona home page.
|
|
92
|
+
export function handOff(row, { settings = null } = {}) {
|
|
93
|
+
const link = orderLink(settings, { sourceJobRef: row.orderId });
|
|
97
94
|
return {
|
|
98
95
|
kind: 'hand_off',
|
|
99
96
|
system: 'seazona',
|
|
@@ -104,7 +101,8 @@ export function handOff(row, { orderUrlTemplate = null } = {}) {
|
|
|
104
101
|
state: row.state,
|
|
105
102
|
requestedAt: row.requestedAt.toISOString(),
|
|
106
103
|
expiresAt: row.expiresAt.toISOString(),
|
|
107
|
-
openUrl:
|
|
104
|
+
openUrl: link?.url ?? null,
|
|
105
|
+
openExact: Boolean(link?.exact),
|
|
108
106
|
};
|
|
109
107
|
}
|
|
110
108
|
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// GitHub Releases (with npm's dist-tags as the fallback). Used by the Replit
|
|
3
3
|
// deploy (scripts/deploy-release.js, FLOW_DEPLOY_REF=stable|beta).
|
|
4
4
|
//
|
|
5
|
-
// Release names follow docs/RELEASING.md: vYYYY.M.N is stable,
|
|
6
|
-
// vYYYY.M.N-beta.B is a beta of the upcoming YYYY.M.N.
|
|
5
|
+
// Release names follow docs/RELEASING.md: vYYYY.M.N is stable,
|
|
6
|
+
// vYYYY.M.N-beta.B is a beta of the upcoming YYYY.M.N, and vYYYY.M.N-C is a
|
|
7
|
+
// stable correction of YYYY.M.N.
|
|
7
8
|
//
|
|
8
9
|
// stable GitHub's latest release (what Flow Release Promote points at, so
|
|
9
10
|
// a rollback is followed), else npm `latest`
|
|
@@ -13,7 +14,7 @@
|
|
|
13
14
|
// Nothing here sends anything about the installation: each request is an
|
|
14
15
|
// anonymous GET of public data.
|
|
15
16
|
|
|
16
|
-
const TAG = /^v(20\d{2})\.([1-9]|1[0-2])\.([1-9]\d*)(?:-beta\.([1-9]\d*))?$/;
|
|
17
|
+
const TAG = /^v(20\d{2})\.([1-9]|1[0-2])\.([1-9]\d*)(?:-beta\.([1-9]\d*)|-([1-9]\d*))?$/;
|
|
17
18
|
export const CHANNELS = ["stable", "beta"];
|
|
18
19
|
export const DEFAULT_GITHUB_API = "https://api.github.com";
|
|
19
20
|
export const DEFAULT_GITHUB_DOWNLOADS = "https://github.com";
|
|
@@ -35,8 +36,9 @@ export function parseReleaseTag(value) {
|
|
|
35
36
|
tag,
|
|
36
37
|
version: tag.slice(1),
|
|
37
38
|
channel: beta ? "beta" : "stable",
|
|
38
|
-
// A stable release sorts after every beta of its own version
|
|
39
|
-
|
|
39
|
+
// A stable release sorts after every beta of its own version, and its
|
|
40
|
+
// corrections after it.
|
|
41
|
+
parts: [Number(match[1]), Number(match[2]), Number(match[3]), beta ?? Number.POSITIVE_INFINITY, Number(match[5] ?? 0)],
|
|
40
42
|
};
|
|
41
43
|
}
|
|
42
44
|
|
|
@@ -44,7 +46,7 @@ export function parseReleaseTag(value) {
|
|
|
44
46
|
export function compareReleases(a, b) {
|
|
45
47
|
const [x, y] = [parseReleaseTag(a), parseReleaseTag(b)];
|
|
46
48
|
if (!x || !y) throw new ReleaseUnavailable(`cannot compare ${a} with ${b}`);
|
|
47
|
-
for (let index = 0; index <
|
|
49
|
+
for (let index = 0; index < 5; index += 1) {
|
|
48
50
|
if (x.parts[index] !== y.parts[index]) return x.parts[index] < y.parts[index] ? -1 : 1;
|
|
49
51
|
}
|
|
50
52
|
return 0;
|
package/server/ui/flow.css
CHANGED
|
@@ -189,6 +189,30 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
|
|
|
189
189
|
.fx-notice[data-tone="danger"] { border-color: var(--t-danger-brd); background: var(--t-danger-bg); color: var(--t-danger); }
|
|
190
190
|
.fx-panel > .fx-notice { margin: 12px; }
|
|
191
191
|
|
|
192
|
+
/* The job changed after its invoice was made (job-change.js): was / now per
|
|
193
|
+
changed fact. Was is struck through; Now is the work system's value. */
|
|
194
|
+
.fx-change {
|
|
195
|
+
display: grid; gap: 10px; padding: 12px 14px;
|
|
196
|
+
border: 1px solid var(--t-danger-brd); border-radius: var(--t-radius);
|
|
197
|
+
background: var(--t-danger-bg); color: var(--t-text); font-size: 14px;
|
|
198
|
+
}
|
|
199
|
+
.fx-change p { margin: 0; }
|
|
200
|
+
.fx-change strong { color: var(--t-danger); }
|
|
201
|
+
.fx-change__head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; }
|
|
202
|
+
.fx-change__acts { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
203
|
+
.fx-change__note { font-size: 13px; color: var(--t-dim); }
|
|
204
|
+
.fx-change__scroll { overflow-x: auto; }
|
|
205
|
+
.fx-change__table { width: 100%; border-collapse: collapse; background: var(--t-paper); border: 1px solid var(--t-brd); font-size: 13.5px; }
|
|
206
|
+
.fx-change__table th, .fx-change__table td { padding: 8px 12px; border-bottom: 1px solid var(--t-brd); text-align: left; vertical-align: top; }
|
|
207
|
+
.fx-change__table thead th {
|
|
208
|
+
font-size: var(--t-label-size, 12px); font-weight: var(--t-label-weight, 600); letter-spacing: var(--t-label-spacing, .08em);
|
|
209
|
+
text-transform: var(--t-label-case, uppercase); color: var(--t-dim);
|
|
210
|
+
}
|
|
211
|
+
.fx-change__table tbody th { font-weight: 400; color: var(--t-dim); white-space: nowrap; }
|
|
212
|
+
.fx-change__was { background: color-mix(in srgb, var(--t-danger) 9%, transparent); text-decoration: line-through; text-decoration-thickness: 1px; }
|
|
213
|
+
.fx-change__now { background: color-mix(in srgb, var(--t-success) 11%, transparent); color: var(--t-strong); font-weight: 600; }
|
|
214
|
+
.fx-change__table .fx-num { text-align: left; font-family: var(--t-data); font-variant-numeric: tabular-nums; }
|
|
215
|
+
|
|
192
216
|
.fx-status {
|
|
193
217
|
display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px;
|
|
194
218
|
border-radius: 999px; font-size: 12.5px; font-weight: 600; white-space: nowrap;
|
|
@@ -220,7 +244,7 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
|
|
|
220
244
|
letter-spacing: var(--t-label-spacing); text-transform: var(--t-label-case);
|
|
221
245
|
}
|
|
222
246
|
.fx-table td { padding: 12px 16px; border-bottom: 1px solid var(--fx-line-soft); vertical-align: middle; }
|
|
223
|
-
.fx-table tbody tr:last-child td { border-bottom: 0; }
|
|
247
|
+
.fx-table tbody:last-child tr:last-child td { border-bottom: 0; }
|
|
224
248
|
.fx-table tbody tr:hover td { background: var(--fx-hover); }
|
|
225
249
|
.fx-table__actions { text-align: right; width: 1%; white-space: nowrap; }
|
|
226
250
|
.fx-table__actions .fx-row { flex-wrap: nowrap; }
|
|
@@ -439,7 +463,41 @@ a.fx-strong:hover { text-decoration: underline; }
|
|
|
439
463
|
.fx-toolbar--plain .fx-search { margin-bottom: 0; }
|
|
440
464
|
.fx-empty .fx-btn { margin-top: 12px; }
|
|
441
465
|
.fx-panel.fx-empty { display: grid; justify-items: center; }
|
|
442
|
-
|
|
466
|
+
/* The Jobs toolbar: Group, Sort and Filter at the left, as small outlined
|
|
467
|
+
buttons that each open a menu; search and Board/List at the right. */
|
|
468
|
+
.fx-toolbar__end { display: flex; flex: 1 1 360px; align-items: center; justify-content: flex-end; gap: 12px; min-width: 0; margin-left: auto; }
|
|
469
|
+
.fx-toolbar__end .fx-search { flex: 0 1 320px; min-width: 0; }
|
|
470
|
+
.fx-viewbar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
|
471
|
+
.fx-chip {
|
|
472
|
+
display: inline-flex; align-items: center; gap: 6px; min-height: 30px; padding: 4px 11px;
|
|
473
|
+
border: 1px solid var(--fx-line); border-radius: 999px; background: transparent;
|
|
474
|
+
color: var(--t-text); font: 600 13px/1.2 var(--t-body); white-space: nowrap; cursor: pointer;
|
|
475
|
+
}
|
|
476
|
+
.fx-chip svg { flex: none; color: var(--t-dim); }
|
|
477
|
+
.fx-chip:hover, .fx-chip[aria-expanded="true"] { border-color: var(--t-brd-hi, var(--fx-line)); background: var(--fx-hover); color: var(--t-strong); }
|
|
478
|
+
.fx-chip:focus-visible { outline: 2px solid var(--t-ring, var(--t-accent)); outline-offset: 2px; }
|
|
479
|
+
.fx-chip--on { border-color: var(--t-accent-brd, var(--t-accent)); background: var(--t-accent-bg); color: var(--t-accent); }
|
|
480
|
+
.fx-chip--on svg { color: currentColor; }
|
|
481
|
+
.fx-pop {
|
|
482
|
+
position: fixed; inset: auto; margin: 0; min-width: 220px; max-width: min(320px, calc(100vw - 16px)); max-height: min(420px, calc(100vh - 32px));
|
|
483
|
+
overflow-y: auto; padding: 6px; border: 1px solid var(--fx-line); border-radius: var(--t-radius-lg);
|
|
484
|
+
background: var(--t-paper); color: var(--t-text); box-shadow: 0 12px 32px rgba(0, 0, 0, .22);
|
|
485
|
+
}
|
|
486
|
+
.fx-pop__head { margin: 0; padding: 8px 10px 4px; color: var(--t-dim); font: var(--t-label-weight) var(--t-label-size)/1.3 var(--t-body); letter-spacing: var(--t-label-spacing); text-transform: var(--t-label-case); }
|
|
487
|
+
.fx-pop__item {
|
|
488
|
+
display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 10px; border: 0; border-radius: var(--t-radius);
|
|
489
|
+
background: none; color: var(--t-text); font: 500 13.5px/1.3 var(--t-body); text-align: left; cursor: pointer;
|
|
490
|
+
}
|
|
491
|
+
.fx-pop__item:hover, .fx-pop__item:focus-visible { background: var(--fx-hover); color: var(--t-strong); outline: none; }
|
|
492
|
+
.fx-pop__item[aria-checked="true"] { color: var(--t-strong); font-weight: 600; }
|
|
493
|
+
.fx-pop__tick { display: inline-grid; place-items: center; flex: none; width: 16px; height: 16px; color: var(--t-accent); }
|
|
494
|
+
.fx-pop__sep { height: 1px; margin: 6px 4px; background: var(--fx-line-soft); }
|
|
495
|
+
.fx-pop__foot { display: flex; justify-content: flex-end; padding: 6px 4px 2px; border-top: 1px solid var(--fx-line-soft); margin-top: 6px; }
|
|
496
|
+
.fx-table__group td { padding: 14px 16px 8px; background: var(--t-s1); }
|
|
497
|
+
.fx-table tbody tr.fx-table__group:hover td { background: var(--t-s1); }
|
|
498
|
+
.fx-table__group-row { display: flex; align-items: center; gap: 12px; }
|
|
499
|
+
.fx-table__group-row .fx-board__sum { margin-left: 0; }
|
|
500
|
+
.fx-stage[data-rank="plain"] { --stage: var(--t-dim); }
|
|
443
501
|
/* The board never scrolls sideways: its columns (one per work system status)
|
|
444
502
|
are about a phone wide, wrap onto more rows when the page is narrow, and
|
|
445
503
|
stack on a phone. On a wide, tall screen the board fills the page down to
|
|
@@ -499,6 +557,22 @@ a.fx-strong:hover { text-decoration: underline; }
|
|
|
499
557
|
.fx-job__problem:hover, .fx-job__problem:focus-visible { background: var(--t-warn-bg); }
|
|
500
558
|
.fx-job__cell { display: inline-flex; align-items: center; gap: 6px; }
|
|
501
559
|
|
|
560
|
+
/* Moves on the board (jobs.js, enableDrag): the lifted card, the copy that
|
|
561
|
+
follows the pointer, the lane that takes it, and the "Waiting for
|
|
562
|
+
Seazona" chip of a stage move the work system has not made yet. */
|
|
563
|
+
.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; }
|
|
564
|
+
.fx-job--lifted { opacity: .35; }
|
|
565
|
+
.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; }
|
|
566
|
+
.fx-board--dragging, .fx-board--dragging .fx-job { cursor: grabbing; user-select: none; }
|
|
567
|
+
.fx-board__col--over { outline: 2px solid var(--t-accent); outline-offset: -2px; background: var(--t-accent-bg, var(--t-s1)); }
|
|
568
|
+
.fx-board__lane { font-weight: 600; }
|
|
569
|
+
.fx-board__lane .fx-stage__count { margin-left: 2px; }
|
|
570
|
+
/* The stage move dialog (jobs.js, moveStage). */
|
|
571
|
+
.fx-move__path { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0; color: var(--t-dim); }
|
|
572
|
+
.fx-move__stage { padding: 2px 10px; border: 1px solid var(--fx-line); border-radius: 999px; color: var(--t-strong); font-size: 13px; font-weight: 600; }
|
|
573
|
+
.fx-move__note { margin: 0; color: var(--t-text); }
|
|
574
|
+
.fx-check { display: inline-flex; align-items: center; gap: 8px; color: var(--t-dim); font-size: 13.5px; cursor: pointer; }
|
|
575
|
+
|
|
502
576
|
/* ── Job page ───────────────────────────────────────────────────────────────
|
|
503
577
|
A job opened from the board: a large panel over it, like a task page, with
|
|
504
578
|
the activity at the side. It stacks on a phone. */
|
|
@@ -523,6 +597,11 @@ a.fx-strong:hover { text-decoration: underline; }
|
|
|
523
597
|
.fx-task__fix h3 { padding: 12px 14px 4px; color: var(--t-warn); }
|
|
524
598
|
.fx-task__fixrow { padding: 9px 14px; border-top: 1px solid var(--fx-line-soft); color: var(--t-strong); font-size: 13.5px; }
|
|
525
599
|
.fx-task__fixrow:first-of-type { border-top: 0; }
|
|
600
|
+
.fx-task__custom { margin-top: 24px; }
|
|
601
|
+
.fx-task__custom h3 { margin: 0 0 4px; color: var(--t-dim); font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
|
|
602
|
+
.fx-task__input { width: 100%; min-width: 0; }
|
|
603
|
+
.fx-task__move { display: grid; gap: 10px; max-width: 420px; margin-top: 8px; }
|
|
604
|
+
.fx-task__wait { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 10px 12px; border: 1px dashed var(--t-accent); border-radius: var(--t-radius); font-size: 13.5px; }
|
|
526
605
|
.fx-task__lines { margin-top: 24px; }
|
|
527
606
|
.fx-task__lines h3 { margin-bottom: 8px; }
|
|
528
607
|
.fx-task__lines table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 13px; }
|
|
@@ -21,6 +21,6 @@ export function sendModeNote(to, sendMode, what = 'each invoice') {
|
|
|
21
21
|
// The line under a draft's title, one fact per sentence.
|
|
22
22
|
export function draftNote(to, invoice, ready) {
|
|
23
23
|
if (!to) return ready ? 'A person checked it. A saved change keeps it ready.' : 'Flow gives it a number when you issue it.';
|
|
24
|
-
if (!ready) return `
|
|
24
|
+
if (!ready) return `Finalise it, then send it to ${to}.`;
|
|
25
25
|
return sendModeNote(to, invoice?.sendMode, 'this invoice');
|
|
26
26
|
}
|