@arcforgelabs/flow 2026.10.32 → 2026.10.34
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 +9 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/api/custom-fields.js +114 -0
- package/server/api/invoicing.js +58 -25
- package/server/api/work-moves.js +80 -0
- package/server/app.js +16 -0
- package/server/core/custom-fields.js +300 -0
- package/server/core/invoicing-rules.js +50 -4
- package/server/core/job-board.js +17 -4
- package/server/core/ledger.js +242 -6
- package/server/core/pipeline.js +29 -21
- package/server/core/read-before-act.js +96 -0
- package/server/core/reconcile.js +350 -0
- package/server/core/sending.js +44 -6
- package/server/core/tracing.js +64 -0
- package/server/db/migrations/0017_sync_basis.sql +1 -0
- package/server/db/migrations/0018_custom_fields.sql +78 -0
- package/server/db/migrations/meta/0017_snapshot.json +3867 -0
- package/server/db/migrations/meta/0018_snapshot.json +4289 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +62 -0
- package/server/index.js +11 -1
- package/server/integrations/port.js +10 -1
- package/server/integrations/seazona/act.js +48 -0
- package/server/integrations/seazona/feed.js +70 -50
- package/server/integrations/seazona/governor.js +28 -2
- package/server/integrations/seazona/index.js +107 -4
- package/server/integrations/seazona/lanes.js +81 -8
- package/server/integrations/seazona/ledger.js +14 -9
- package/server/integrations/seazona/migrations/0005_record_reread.sql +1 -0
- package/server/integrations/seazona/migrations/0006_sweep_findings.sql +11 -0
- package/server/integrations/seazona/migrations/0007_stage_moves.sql +18 -0
- package/server/integrations/seazona/migrations/meta/0005_snapshot.json +1329 -0
- package/server/integrations/seazona/migrations/meta/0006_snapshot.json +1403 -0
- package/server/integrations/seazona/migrations/meta/0007_snapshot.json +1519 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +21 -0
- package/server/integrations/seazona/reconcile.js +40 -0
- package/server/integrations/seazona/scheduler.js +32 -4
- package/server/integrations/seazona/schema.js +47 -0
- package/server/integrations/seazona/settings.js +26 -0
- package/server/integrations/seazona/stage-moves.js +135 -0
- package/server/integrations/seazona/store.js +24 -2
- package/server/integrations/seazona/sweep.js +574 -0
- package/server/integrations/seazona/tables.js +3 -0
- package/server/integrations/sentry/index.js +61 -10
- package/server/integrations/xero/index.js +4 -0
- package/server/integrations/xero/webhooks.js +35 -0
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +40 -2
- package/server/ui/custom-fields.js +190 -0
- package/server/ui/flow.css +57 -9
- package/server/ui/invoicing.js +184 -22
- package/server/ui/job-board.js +2 -1
- package/server/ui/jobs.js +22 -2
- package/server/ui/monitoring.js +44 -2
- package/server/ui/routes.js +1 -1
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
import * as SentryNode from '@sentry/node';
|
|
2
|
+
import { scrubSpan } from '../../ui/monitoring.js';
|
|
2
3
|
|
|
3
|
-
// The Sentry adapter (docs/SENTRY.md). It sends the errors Flow logs,
|
|
4
|
-
// browser's errors and problem reports,
|
|
4
|
+
// The Sentry adapter (docs/SENTRY.md). It sends the errors Flow logs, the
|
|
5
|
+
// browser's errors and problem reports, and performance traces from both
|
|
6
|
+
// (when SENTRY_TRACES_SAMPLE_RATE is above 0), to the installation's own Sentry
|
|
5
7
|
// project. Flow ships no DSN: each installation enters its own, here or as
|
|
6
8
|
// the host setting SENTRY_DSN. It has no tables.
|
|
7
9
|
|
|
8
10
|
// The browser SDK, loaded from Sentry's CDN only while this adapter is on
|
|
9
|
-
// (server/ui/monitoring.js). Pinned, with its integrity hash.
|
|
11
|
+
// (server/ui/monitoring.js). Pinned, with its integrity hash. With traces on,
|
|
12
|
+
// the bundle that also traces; Sentry ships it only with session replay in
|
|
13
|
+
// it too, which Flow never turns on.
|
|
10
14
|
export const SENTRY_BROWSER = Object.freeze({
|
|
11
15
|
src: 'https://browser.sentry-cdn.com/11.4.0/bundle.feedback.min.js',
|
|
12
16
|
integrity: 'sha384-H9RmZ/VaqOPZrUezgCSqHp4t3LoQMd/5+0Y4oEGUB2bddZzSnwZiOYoWneK4diWt',
|
|
13
17
|
});
|
|
18
|
+
export const SENTRY_BROWSER_TRACING = Object.freeze({
|
|
19
|
+
src: 'https://browser.sentry-cdn.com/11.4.0/bundle.tracing.replay.feedback.min.js',
|
|
20
|
+
integrity: 'sha384-LjwHQKAojcITl6Q7innDSAFjziFnqV9ovZBNXnXnQwub1VXIdBMJnYrQNihtxSa6',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// The share of requests and page loads traced when the setting is empty.
|
|
24
|
+
export const DEFAULT_TRACES_SAMPLE_RATE = 0.2;
|
|
14
25
|
|
|
15
26
|
// A DSN is https://<public key>@<host>/<project id>.
|
|
16
27
|
const DSN_PATTERN = /^https:\/\/[0-9a-f]{16,64}@[a-z0-9.-]+(:\d+)?\/(\d+)$/i;
|
|
@@ -31,6 +42,7 @@ export const SENTRY_SETUP = {
|
|
|
31
42
|
{ name: 'SENTRY_DSN', label: 'DSN', kind: 'url', help: 'From the Sentry project\'s Client Keys. Browsers see it, so it is not a secret.' },
|
|
32
43
|
{ name: 'SENTRY_ENVIRONMENT', label: 'Environment', help: 'How Sentry labels this installation, for example production. Empty: production.' },
|
|
33
44
|
{ name: 'SENTRY_FEEDBACK', label: 'Report a problem button', kind: 'onoff', help: 'On: everyone signed in can send a report, with a screenshot, to this Sentry project.' },
|
|
45
|
+
{ name: 'SENTRY_TRACES_SAMPLE_RATE', label: 'Traces', help: 'The share of clicks, page loads and requests timed, from 0 (off) to 1 (all). Empty: 0.2.' },
|
|
34
46
|
],
|
|
35
47
|
};
|
|
36
48
|
|
|
@@ -40,9 +52,17 @@ export function readSentrySettings(env) {
|
|
|
40
52
|
if (!DSN_PATTERN.test(dsn)) throw new Error('SENTRY_DSN is not a Sentry DSN (https://<key>@<host>/<project id>).');
|
|
41
53
|
const environment = String(env.SENTRY_ENVIRONMENT ?? '').trim() || 'production';
|
|
42
54
|
const feedback = String(env.SENTRY_FEEDBACK ?? 'on').trim() !== 'off';
|
|
43
|
-
|
|
55
|
+
const rate = String(env.SENTRY_TRACES_SAMPLE_RATE ?? '').trim();
|
|
56
|
+
const tracesSampleRate = rate === '' ? DEFAULT_TRACES_SAMPLE_RATE : Number(rate);
|
|
57
|
+
if (!/^\d*\.?\d+$/.test(rate || '0') || !(tracesSampleRate >= 0 && tracesSampleRate <= 1)) {
|
|
58
|
+
throw new Error('SENTRY_TRACES_SAMPLE_RATE must be a number from 0 to 1, such as 0.2.');
|
|
59
|
+
}
|
|
60
|
+
return { dsn, environment, feedback, tracesSampleRate };
|
|
44
61
|
}
|
|
45
62
|
|
|
63
|
+
// No trace for the host's health check: it is not a person waiting.
|
|
64
|
+
const UNTRACED_PATHS = new Set(['/health']);
|
|
65
|
+
|
|
46
66
|
// The release Sentry groups events by: Flow's release tag, else its commit.
|
|
47
67
|
export function sentryRelease(env) {
|
|
48
68
|
const read = (value) => String(value ?? '').trim() || null;
|
|
@@ -51,7 +71,7 @@ export function sentryRelease(env) {
|
|
|
51
71
|
return release ? `flow@${release.replace(/^v/, '')}` : revision ? `flow@${revision.slice(0, 12)}` : null;
|
|
52
72
|
}
|
|
53
73
|
|
|
54
|
-
// `sdk` is for tests: anything with init,
|
|
74
|
+
// `sdk` is for tests: anything with init, captureEvent, captureException
|
|
55
75
|
// and close, as @sentry/node has.
|
|
56
76
|
export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.env } = {}) {
|
|
57
77
|
let settings = null;
|
|
@@ -71,10 +91,14 @@ export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.e
|
|
|
71
91
|
release: release ?? undefined,
|
|
72
92
|
// Flow sends what it logs; no request bodies, cookies or IP addresses.
|
|
73
93
|
sendDefaultPii: false,
|
|
74
|
-
|
|
75
|
-
|
|
94
|
+
// A request the browser traced keeps the browser's choice, so a
|
|
95
|
+
// click and the server work it caused stay one trace.
|
|
96
|
+
tracesSampler: ({ attributes, inheritOrSampleWith }) => (
|
|
97
|
+
UNTRACED_PATHS.has(attributes?.['url.path']) ? 0 : inheritOrSampleWith(settings.tracesSampleRate)
|
|
98
|
+
),
|
|
99
|
+
beforeSendSpan: scrubSpan,
|
|
76
100
|
});
|
|
77
|
-
log.info?.(`[sentry] on: environment ${settings.environment}; report button ${settings.feedback ? 'on' : 'off'}`);
|
|
101
|
+
log.info?.(`[sentry] on: environment ${settings.environment}; report button ${settings.feedback ? 'on' : 'off'}; traces ${settings.tracesSampleRate > 0 ? settings.tracesSampleRate : 'off'}`);
|
|
78
102
|
},
|
|
79
103
|
async stop() {
|
|
80
104
|
if (settings) await sdk.close(2000);
|
|
@@ -90,13 +114,40 @@ export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.e
|
|
|
90
114
|
if (!settings) return;
|
|
91
115
|
const scope = { level: 'error', tags: { source } };
|
|
92
116
|
if (error instanceof Error) sdk.captureException(error, { ...scope, extra: { log: message } });
|
|
93
|
-
|
|
117
|
+
// Not captureMessage: the SDK adds a stack to it, and Sentry then
|
|
118
|
+
// names the issue after its top frame ("Object.captureError"), not
|
|
119
|
+
// the line Flow logged. A plain event keeps the line as the title.
|
|
120
|
+
else sdk.captureEvent({ message, ...scope });
|
|
94
121
|
},
|
|
95
122
|
// What the browser needs to report to the same project. The DSN is
|
|
96
123
|
// meant for browsers.
|
|
97
124
|
browserMonitoring() {
|
|
98
125
|
if (!settings) return null;
|
|
99
|
-
|
|
126
|
+
const { tracesSampleRate } = settings;
|
|
127
|
+
return {
|
|
128
|
+
dsn: settings.dsn,
|
|
129
|
+
environment: settings.environment,
|
|
130
|
+
release,
|
|
131
|
+
feedback: settings.feedback,
|
|
132
|
+
tracesSampleRate,
|
|
133
|
+
script: tracesSampleRate > 0 ? SENTRY_BROWSER_TRACING : SENTRY_BROWSER,
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
// Runs `work` in a span of the current trace (server/core/tracing.js).
|
|
137
|
+
// Work outside a traced request (the pipeline, the workers) starts no
|
|
138
|
+
// trace of its own.
|
|
139
|
+
traceSpan({ name, op, attributes }, work) {
|
|
140
|
+
if (!settings?.tracesSampleRate) return work();
|
|
141
|
+
return sdk.startSpan({ name, op, attributes, onlyIfParent: true }, work);
|
|
142
|
+
},
|
|
143
|
+
// At a request's start: a function that names its trace once the route
|
|
144
|
+
// is known, or null when this request is not traced.
|
|
145
|
+
traceRequest() {
|
|
146
|
+
if (!settings?.tracesSampleRate) return null;
|
|
147
|
+
const span = sdk.getActiveSpan();
|
|
148
|
+
if (!span) return null;
|
|
149
|
+
const root = sdk.getRootSpan(span);
|
|
150
|
+
return (name) => sdk.updateSpanName(root, name);
|
|
100
151
|
},
|
|
101
152
|
},
|
|
102
153
|
};
|
|
@@ -9,6 +9,7 @@ import { findInvoices, listCustomers, listPriceItems, mirrorState } from './look
|
|
|
9
9
|
import { createXeroRouter } from './routes.js';
|
|
10
10
|
import { createXeroRuntime, requireXeroSettings, XERO_CALL_GUARD } from './runtime.js';
|
|
11
11
|
import { writesAllowed } from './settings.js';
|
|
12
|
+
import { readChangesFirst } from './webhooks.js';
|
|
12
13
|
|
|
13
14
|
// The Xero adapter (docs/XERO.md). FLOW_INTEGRATIONS=xero turns it on and
|
|
14
15
|
// XERO_MODE says which Xero it acts on. Its tables are created only while it
|
|
@@ -131,6 +132,9 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
|
|
|
131
132
|
// Xero's invoice for one billable job, looked up live (ledger.js): the
|
|
132
133
|
// `liveCheck` for draftInvoiceForObligation.
|
|
133
134
|
checkInvoice: ({ organisationId, obligation }) => xeroLiveCheck(database, current(), organisationId)(obligation),
|
|
135
|
+
// Xero's latest changes into the copy just before Flow drafts by rule or
|
|
136
|
+
// sends (webhooks.js); no call when the copy is current.
|
|
137
|
+
readChanges: ({ organisationId }) => readChangesFirst(database, current(), organisationId),
|
|
134
138
|
// Active Xero contacts, from Flow's copy (lookup.js).
|
|
135
139
|
listCustomers: ({ organisationId }) => listCustomers(database, organisationId),
|
|
136
140
|
// Sales invoices from the mirror, with the mirror's state (lookup.js).
|
|
@@ -262,6 +262,41 @@ export async function dueDailyChecks(db, runtime, { now = Date.now() } = {}) {
|
|
|
262
262
|
return due;
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
// Read before act (issue #45, step 5): just before Flow drafts by rule or
|
|
266
|
+
// sends a batch, Xero's latest changes reach the copy. The copy counts as
|
|
267
|
+
// current when webhooks arrive and none waits, or when it read Xero's changes
|
|
268
|
+
// in the last READ_FIRST_MS. Otherwise this reads the waiting records and the
|
|
269
|
+
// invoice and credit note changes now: about two calls. Answers { state }:
|
|
270
|
+
// fresh no call was needed
|
|
271
|
+
// read the copy read Xero now; `changed` counts invoices that changed
|
|
272
|
+
// unavailable the read could not run (`reason`); act on nothing
|
|
273
|
+
export const READ_FIRST_MS = 10 * 60 * 1000;
|
|
274
|
+
|
|
275
|
+
export async function readChangesFirst(db, runtime, organisationId, { now = Date.now } = {}) {
|
|
276
|
+
const { rows: [connection] } = await db.execute(sql`select tenant_id from xero_connections
|
|
277
|
+
where organisation_id = ${organisationId} and mode = ${runtime.settings.mode}`);
|
|
278
|
+
if (!connection) return { state: 'unavailable', reason: 'Xero is not connected' };
|
|
279
|
+
const { rows: [{ waiting }] } = await db.execute(sql`select count(*)::int as waiting from xero_webhook_events
|
|
280
|
+
where organisation_id = ${organisationId} and pending`);
|
|
281
|
+
if (!waiting && await webhooksActive(db, runtime, organisationId, { now: Number(now()) })) return { state: 'fresh' };
|
|
282
|
+
const { rows: [{ oldest }] } = await db.execute(sql`select min(last_read_at) as oldest from xero_mirror_cursors
|
|
283
|
+
where organisation_id = ${organisationId} and resource in ('invoices', 'credit_notes')`);
|
|
284
|
+
if (!waiting && oldest && Number(now()) - new Date(oldest).getTime() <= READ_FIRST_MS) return { state: 'fresh' };
|
|
285
|
+
try {
|
|
286
|
+
const inbox = await readInbox(db, runtime, organisationId, { now });
|
|
287
|
+
if (inbox.stopped) return { state: 'unavailable', reason: inbox.stopped === 'budget' ? 'the day\'s Xero calls are kept for people' : 'Xero calls are stopped' };
|
|
288
|
+
const reads = await mirrorXero(db, runtime, organisationId, { resources: ['invoices', 'credit_notes'], maxPages: 5 });
|
|
289
|
+
const short = reads.find((r) => !['done', 'refused'].includes(r.stopped));
|
|
290
|
+
if (short) return { state: 'unavailable', reason: short.stopped === 'budget' ? 'the day\'s Xero calls are kept for people' : 'Xero has more changes than one read takes' };
|
|
291
|
+
const invoices = reads.find((r) => r.resource === 'invoices');
|
|
292
|
+
const changed = inbox.invoices.inserted + inbox.invoices.changed + (invoices?.inserted ?? 0) + (invoices?.changed ?? 0);
|
|
293
|
+
if (changed && !inbox.sent) await checkSentInvoices(db, runtime, organisationId);
|
|
294
|
+
return { state: 'read', changed };
|
|
295
|
+
} catch (error) {
|
|
296
|
+
return { state: 'unavailable', reason: isCallsStopped(error) ? 'Xero calls are stopped' : `reading Xero failed (${error?.code ?? 'error'})` };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
265
300
|
// After a failed read, an organisation waits 1 minute, then 2, 4 and up to an
|
|
266
301
|
// hour, so a failing read is never tried every tick.
|
|
267
302
|
export const RETRY_FIRST_MS = 60 * 1000;
|
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', '/invoices', '/invoices/:id', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/price-list'];
|
|
54
|
+
export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/price-list', '/custom-fields'];
|
|
55
55
|
|
|
56
56
|
export function appPage({ defaultFamily }) {
|
|
57
57
|
return renderPage({
|
package/server/ui/app.js
CHANGED
|
@@ -13,6 +13,7 @@ import { renderInvoice, renderInvoiceList } from '/assets/ui/invoicing.js';
|
|
|
13
13
|
import { automationSection } from '/assets/ui/automations.js';
|
|
14
14
|
import { renderInvoicingSettings, sendModeSection } from '/assets/ui/invoicing-settings.js';
|
|
15
15
|
import { renderPriceList } from '/assets/ui/price-list.js';
|
|
16
|
+
import { renderCustomFields } from '/assets/ui/custom-fields.js';
|
|
16
17
|
import { renderJobs } from '/assets/ui/jobs.js';
|
|
17
18
|
import { startMonitoring } from '/assets/ui/monitoring.js';
|
|
18
19
|
import { openSearch } from '/assets/ui/search.js';
|
|
@@ -198,6 +199,7 @@ const VIEWS = {
|
|
|
198
199
|
invoices: { label: 'Invoices', icon: 'invoices', group: 'Work', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
|
|
199
200
|
'invoicing-settings': { label: 'Invoice rules', icon: 'invoices', group: 'Settings', render: (main) => renderInvoicingSettings(main, ui()) },
|
|
200
201
|
'price-list': { label: 'Price list', icon: 'tag', group: 'Settings', render: (main) => renderPriceList(main, ui()) },
|
|
202
|
+
'custom-fields': { label: 'Custom fields', icon: 'fields', group: 'Settings', render: (main) => renderCustomFields(main, ui()) },
|
|
201
203
|
people: { label: 'People', icon: 'people', group: 'Settings', ownerOnly: true, render: renderPeople },
|
|
202
204
|
integrations: { label: 'Integrations', icon: 'plug', group: 'Settings', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
|
|
203
205
|
// Not in the sidebar: "Manage account" in the account menu opens the same
|
|
@@ -212,6 +214,7 @@ const ICON_PATHS = {
|
|
|
212
214
|
invoices: '<path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5M9 13h7M9 17h5"/>',
|
|
213
215
|
people: '<circle cx="9" cy="8" r="3"/><path d="M3 20c0-3.5 2.7-6 6-6s6 2.5 6 6"/><path d="M16 5a3 3 0 010 6M21 20c0-3-1.6-5-4-5.7"/>',
|
|
214
216
|
tag: '<path d="M3 12V4h8l10 10-8 8z"/><circle cx="7.5" cy="8.5" r="1.5"/>',
|
|
217
|
+
fields: '<rect x="3" y="4" width="18" height="5" rx="1.5"/><rect x="3" y="13" width="18" height="5" rx="1.5"/><path d="M7 6.5h4M7 15.5h7"/>',
|
|
215
218
|
plug: '<path d="M9 3v5M15 3v5M6 8h12v3a6 6 0 01-12 0zM12 17v4"/>',
|
|
216
219
|
person: '<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-7 8-7s8 3 8 7"/>',
|
|
217
220
|
flag: '<path d="M5 21V4M5 4h11l-2 4 2 4H5"/>',
|
|
@@ -309,11 +312,15 @@ function reportMenuItem(openReport) {
|
|
|
309
312
|
};
|
|
310
313
|
}
|
|
311
314
|
|
|
315
|
+
// The page a person lands on after sign-in, and for / or an unknown path.
|
|
316
|
+
// Invoices opens on its Ready tab. A home page can take this over later.
|
|
317
|
+
const START_VIEW = 'invoices';
|
|
318
|
+
|
|
312
319
|
// The view and, for one record, its id: /invoices/<id> is ['invoices', '<id>'].
|
|
313
320
|
function currentView() {
|
|
314
321
|
const [name, id = null] = location.pathname.replace(/^\/+|\/+$/g, '').split('/');
|
|
315
322
|
if (VIEWS[name] && (!VIEWS[name].ownerOnly || me?.person.role === 'owner')) return [name, id];
|
|
316
|
-
return [
|
|
323
|
+
return [START_VIEW, null];
|
|
317
324
|
}
|
|
318
325
|
|
|
319
326
|
async function route() {
|
|
@@ -533,7 +540,7 @@ const STATE_TONE = { ready: 'success', disabled: 'mute', failed: 'danger', resta
|
|
|
533
540
|
const CATALOGUE = {
|
|
534
541
|
xero: { maker: 'Xero', category: 'accounting', blurb: 'Send invoices to Xero as drafts and bring payments back.', logo: 'xero.svg' },
|
|
535
542
|
seazona: { maker: 'Seazona', category: 'lab', blurb: 'Bring lab cases and orders into Flow as jobs.', logo: 'seazona.svg' },
|
|
536
|
-
sentry: { maker: 'Sentry', category: 'monitoring', blurb: 'Send errors
|
|
543
|
+
sentry: { maker: 'Sentry', category: 'monitoring', blurb: 'Send errors, problem reports and speed traces to your own Sentry project.', logo: 'sentry.svg' },
|
|
537
544
|
};
|
|
538
545
|
const CATEGORIES = [['all', 'All'], ['accounting', 'Accounting'], ['lab', 'Lab software'], ['monitoring', 'Monitoring']];
|
|
539
546
|
const CATEGORY_LABEL = Object.fromEntries(CATEGORIES);
|
|
@@ -774,6 +781,7 @@ async function renderIntegration(main, id) {
|
|
|
774
781
|
current.state === 'failed' && current.error ? notice(current.error, 'danger', 'It could not start.') : null,
|
|
775
782
|
current.id === 'xero' && current.state === 'ready' ? h('section', { class: 'fx-int-section' }, xeroConnection(body.organisations, canChange)) : null,
|
|
776
783
|
current.id === 'seazona' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'seazona', 'Ready to invoice')) : null,
|
|
784
|
+
on && current.state === 'ready' ? personSettingsSection(current) : null,
|
|
777
785
|
current.id === 'xero' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, sendModeSection(ui())) : null,
|
|
778
786
|
guide ? h('section', { class: 'fx-int-section' },
|
|
779
787
|
h('h2', { class: 'fx-int-section__title' }, 'Set up'),
|
|
@@ -798,6 +806,36 @@ async function renderIntegration(main, id) {
|
|
|
798
806
|
draw(found);
|
|
799
807
|
}
|
|
800
808
|
|
|
809
|
+
// "Your settings": this person's own choices in one integration
|
|
810
|
+
// (GET /api/work/my-settings), such as Seazona's "Ask before stage moves".
|
|
811
|
+
// Each person sees and changes only their own.
|
|
812
|
+
function personSettingsSection(integration) {
|
|
813
|
+
const section = h('section', { class: 'fx-int-section' },
|
|
814
|
+
h('h2', { class: 'fx-int-section__title' }, 'Your settings'), h('div', { class: 'fx-skeleton' }));
|
|
815
|
+
flow(`/api/work/my-settings?integration=${encodeURIComponent(integration.id)}`).then(({ integrations }) => {
|
|
816
|
+
const settings = integrations[0]?.settings ?? [];
|
|
817
|
+
if (!settings.length) { section.remove(); return; }
|
|
818
|
+
const rows = settings.map((setting) => {
|
|
819
|
+
const input = h('select', { class: 'fx-select' },
|
|
820
|
+
[{ value: 'off', label: 'Off' }, { value: 'on', label: 'On' }].map((c) => h('option', { value: c.value, selected: (setting.value ? 'on' : 'off') === c.value }, c.label)));
|
|
821
|
+
input.addEventListener('change', async () => {
|
|
822
|
+
input.disabled = true;
|
|
823
|
+
try {
|
|
824
|
+
await flow('/api/work/my-settings', { method: 'PUT', body: JSON.stringify({ integration: integration.id, key: setting.key, value: input.value === 'on' }) });
|
|
825
|
+
toast('Saved for you.', 'success');
|
|
826
|
+
} catch (error) {
|
|
827
|
+
input.value = setting.value ? 'on' : 'off';
|
|
828
|
+
toast(error.message, 'danger');
|
|
829
|
+
}
|
|
830
|
+
input.disabled = false;
|
|
831
|
+
});
|
|
832
|
+
return settingRow({ label: setting.label, hint: setting.hint, control: input });
|
|
833
|
+
});
|
|
834
|
+
section.replaceChildren(section.firstChild, h('div', { class: 'fx-settings' }, rows));
|
|
835
|
+
}).catch(() => section.remove());
|
|
836
|
+
return section;
|
|
837
|
+
}
|
|
838
|
+
|
|
801
839
|
// One setting, laid out the same on every settings screen: a short name, one
|
|
802
840
|
// line of smaller text under it, and the control on the right. Longer help
|
|
803
841
|
// lives behind a link, not here. A wide control (a grid of choices) sits on
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Settings, Custom fields (/custom-fields; server/core/custom-fields.js,
|
|
2
|
+
// docs/CUSTOM_FIELDS.md). The fields a lab makes for its jobs, like ClickUp's
|
|
3
|
+
// custom fields: each row is one field, with its type and, for a dropdown,
|
|
4
|
+
// its options in lane order. Create field and Edit open a dialog. The work
|
|
5
|
+
// systems' own fields (Seazona's stage) show below, read only: Flow reads
|
|
6
|
+
// them and never changes them here. Everyone can read the page; an owner or
|
|
7
|
+
// admin changes it. The caller passes in app.js's shared pieces as `ui`.
|
|
8
|
+
|
|
9
|
+
const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/CUSTOM_FIELDS.md';
|
|
10
|
+
const TYPE_LABELS = {
|
|
11
|
+
dropdown: 'Dropdown', person: 'Person', checkbox: 'Checkbox', date: 'Date', number: 'Number', text: 'Text',
|
|
12
|
+
};
|
|
13
|
+
const TYPE_HINTS = {
|
|
14
|
+
dropdown: 'One option from a list. Makes board lanes.',
|
|
15
|
+
person: 'One person in Flow. Makes board lanes.',
|
|
16
|
+
checkbox: 'Yes or no. Makes board lanes.',
|
|
17
|
+
date: 'A day, for example a promised date.',
|
|
18
|
+
number: 'A number, for example units.',
|
|
19
|
+
text: 'A short note.',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export async function renderCustomFields(main, ui) {
|
|
23
|
+
const { h, flow, toast, notice, dialog, pageHeader, me } = ui;
|
|
24
|
+
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
25
|
+
const host = h('div', { class: 'fx-card', style: { padding: '8px 20px 16px' } }, h('div', { class: 'fx-skeleton' }));
|
|
26
|
+
const addButton = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled }, 'Create field');
|
|
27
|
+
main.append(pageHeader('Custom fields', { subtitle: 'Fields for your jobs. They stay in Flow and never change Seazona or Xero.', actions: [addButton] }), host);
|
|
28
|
+
|
|
29
|
+
let data = null;
|
|
30
|
+
|
|
31
|
+
async function send(path, method, payload, done = 'Saved.') {
|
|
32
|
+
try {
|
|
33
|
+
const body = await flow(path, { method, ...(payload ? { body: JSON.stringify(payload) } : {}) });
|
|
34
|
+
if (done) toast(done, 'success');
|
|
35
|
+
return body;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
toast(error.message, 'danger');
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const row = (name, hint, control) => h('div', { class: 'fx-set__row' },
|
|
43
|
+
h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, name), hint),
|
|
44
|
+
h('div', { class: 'fx-set__control fx-rule__controls' }, control));
|
|
45
|
+
const optionChip = (o) => h('span', { class: 'fx-cf__opt', 'data-color': o.color ?? 'gray' }, o.name);
|
|
46
|
+
const typeChip = (type) => h('span', { class: 'fx-chip fx-chip--static' }, TYPE_LABELS[type] ?? type);
|
|
47
|
+
|
|
48
|
+
// The option list editor for a dropdown: one line per option (name,
|
|
49
|
+
// colour, move up, remove), in lane order.
|
|
50
|
+
function optionsEditor(start) {
|
|
51
|
+
let options = start.filter((o) => !o.archived).map((o) => ({ ...o }));
|
|
52
|
+
const list = h('div', { class: 'fx-fields fx-cf__options' });
|
|
53
|
+
const add = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button' }, 'Add option');
|
|
54
|
+
function draw() {
|
|
55
|
+
list.replaceChildren(...options.map((o, i) => {
|
|
56
|
+
const name = h('input', { class: 'fx-input', maxlength: '40', value: o.name, 'aria-label': `Option ${i + 1}`, placeholder: 'Option name' });
|
|
57
|
+
name.addEventListener('input', () => { o.name = name.value; });
|
|
58
|
+
const color = h('select', { class: 'fx-select fx-cf__color', 'aria-label': `Colour of option ${i + 1}`, 'data-color': o.color },
|
|
59
|
+
data.colors.map((c) => h('option', { value: c }, c[0].toUpperCase() + c.slice(1))));
|
|
60
|
+
color.value = o.color ?? 'gray';
|
|
61
|
+
color.addEventListener('change', () => { o.color = color.value; color.dataset.color = color.value; });
|
|
62
|
+
const up = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', 'aria-label': `Move option ${i + 1} up`, disabled: i === 0 }, '↑');
|
|
63
|
+
up.addEventListener('click', () => { [options[i - 1], options[i]] = [options[i], options[i - 1]]; draw(); });
|
|
64
|
+
const remove = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm fx-rule__delete', type: 'button', 'aria-label': `Remove option ${i + 1}` }, 'Remove');
|
|
65
|
+
remove.addEventListener('click', () => { options.splice(i, 1); draw(); });
|
|
66
|
+
return h('div', { class: 'fx-cf__line' }, name, color, up, remove);
|
|
67
|
+
}));
|
|
68
|
+
if (!options.length) list.append(h('span', { class: 'fx-dim' }, 'No options yet.'));
|
|
69
|
+
}
|
|
70
|
+
add.addEventListener('click', () => {
|
|
71
|
+
options.push({ name: '', color: data.colors[options.length % data.colors.length] });
|
|
72
|
+
draw();
|
|
73
|
+
list.querySelector('.fx-cf__line:last-child input')?.focus();
|
|
74
|
+
});
|
|
75
|
+
draw();
|
|
76
|
+
return {
|
|
77
|
+
element: h('div', { class: 'fx-fields' }, list, add),
|
|
78
|
+
value: () => options.filter((o) => o.name.trim()).map(({ id, name, color }) => ({ ...(id ? { id } : {}), name, color })),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The dialog for a new field (field null) or an existing one. The type is
|
|
83
|
+
// fixed once the field exists.
|
|
84
|
+
async function edit(field, draft = field) {
|
|
85
|
+
const label = (text, control, hint) => h('div', { class: 'fx-field' }, h('span', { class: 'fx-label' }, text), control, hint ?? null);
|
|
86
|
+
const name = h('input', { class: 'fx-input', required: true, maxlength: '60', value: draft?.name ?? '', 'aria-label': 'Field name', placeholder: 'For example Priority' });
|
|
87
|
+
const type = h('select', { class: 'fx-select', 'aria-label': 'Type', disabled: Boolean(field) },
|
|
88
|
+
data.types.map((t) => h('option', { value: t }, TYPE_LABELS[t] ?? t)));
|
|
89
|
+
type.value = draft?.type ?? 'dropdown';
|
|
90
|
+
const typeHint = h('span', { class: 'fx-dim' }, TYPE_HINTS[type.value]);
|
|
91
|
+
const editor = optionsEditor(draft?.options ?? [
|
|
92
|
+
{ name: 'To do', color: 'gray' }, { name: 'Doing', color: 'blue' }, { name: 'Done', color: 'green' },
|
|
93
|
+
]);
|
|
94
|
+
const optionsField = label('Options', editor.element, h('span', { class: 'fx-dim' }, 'Board lanes follow this order. A removed option is kept for old jobs.'));
|
|
95
|
+
const showOptions = () => { optionsField.hidden = type.value !== 'dropdown'; typeHint.textContent = TYPE_HINTS[type.value]; };
|
|
96
|
+
type.addEventListener('change', showOptions);
|
|
97
|
+
showOptions();
|
|
98
|
+
|
|
99
|
+
let action = null;
|
|
100
|
+
const parts = [label('Name', name), label('Type', type, typeHint), optionsField];
|
|
101
|
+
if (field) {
|
|
102
|
+
const archive = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm fx-rule__delete', type: 'button' }, 'Archive field');
|
|
103
|
+
archive.addEventListener('click', () => { action = 'archive'; archive.closest('dialog').close(); });
|
|
104
|
+
parts.push(archive);
|
|
105
|
+
}
|
|
106
|
+
const ok = await dialog({ title: field ? `Edit ${field.name}` : 'Create field', body: h('div', { class: 'fx-fields fx-rule__dialog' }, ...parts), confirm: field ? 'Save' : 'Create field' });
|
|
107
|
+
if (action === 'archive') {
|
|
108
|
+
if (await send(`/api/custom-fields/${field.id}/archive`, 'POST', { archived: true }, 'Archived. Its values are kept.')) return load();
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
if (!ok) return null;
|
|
112
|
+
const payload = { name: name.value };
|
|
113
|
+
if (!field) payload.type = type.value;
|
|
114
|
+
if (type.value === 'dropdown') payload.options = editor.value();
|
|
115
|
+
const body = field
|
|
116
|
+
? await send(`/api/custom-fields/${field.id}`, 'PUT', payload)
|
|
117
|
+
: await send('/api/custom-fields', 'POST', payload, 'Field created.');
|
|
118
|
+
if (!body) return edit(field, { ...draft, ...payload, type: type.value });
|
|
119
|
+
return load();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function move(index, by) {
|
|
123
|
+
const ids = data.fields.map((f) => f.id);
|
|
124
|
+
[ids[index], ids[index + by]] = [ids[index + by], ids[index]];
|
|
125
|
+
if (await send('/api/custom-fields/order', 'PUT', { ids }, null)) await load();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function fieldRow(field, index) {
|
|
129
|
+
const hint = h('div', { class: 'fx-price__hint' }, typeChip(field.type),
|
|
130
|
+
...(field.type === 'dropdown' ? field.options.filter((o) => !o.archived).map(optionChip) : [h('span', { class: 'fx-set__hint' }, TYPE_HINTS[field.type])]));
|
|
131
|
+
const controls = [];
|
|
132
|
+
if (index !== null) {
|
|
133
|
+
const up = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', disabled: disabled || index === 0, 'aria-label': `Move ${field.name} up` }, '↑');
|
|
134
|
+
up.addEventListener('click', () => move(index, -1));
|
|
135
|
+
const down = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', disabled: disabled || index === data.fields.length - 1, 'aria-label': `Move ${field.name} down` }, '↓');
|
|
136
|
+
down.addEventListener('click', () => move(index, 1));
|
|
137
|
+
const editButton = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', disabled }, 'Edit');
|
|
138
|
+
editButton.addEventListener('click', () => edit(field));
|
|
139
|
+
controls.push(up, down, editButton);
|
|
140
|
+
} else {
|
|
141
|
+
const back = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', disabled }, 'Bring back');
|
|
142
|
+
back.addEventListener('click', async () => {
|
|
143
|
+
if (await send(`/api/custom-fields/${field.id}/archive`, 'POST', { archived: false }, 'Brought back.')) await load();
|
|
144
|
+
});
|
|
145
|
+
controls.push(back);
|
|
146
|
+
}
|
|
147
|
+
return row(field.name, hint, h('div', { class: 'fx-row' }, ...controls));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function systemRow(field) {
|
|
151
|
+
const options = field.options.slice(0, 12).map((o) => h('span', { class: 'fx-cf__opt', 'data-color': o.final ? 'green' : 'gray' }, o.name));
|
|
152
|
+
if (field.options.length > 12) options.push(h('span', { class: 'fx-set__hint' }, `${field.options.length - 12} more`));
|
|
153
|
+
return row(`${field.sourceLabel} ${field.name.toLowerCase()}`, h('div', { class: 'fx-price__hint' }, typeChip(field.type), ...options),
|
|
154
|
+
h('span', { class: 'fx-status', 'data-tone': 'mute' }, `Read only · from ${field.sourceLabel}`));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function draw() {
|
|
158
|
+
const archived = data.archived.filter((f) => f.archived);
|
|
159
|
+
const sections = [
|
|
160
|
+
disabled ? notice('Only an owner or admin can change these. Everyone can set a job\'s values.', 'info') : null,
|
|
161
|
+
h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, `Your fields · ${data.fields.length}`),
|
|
162
|
+
...(data.fields.length ? data.fields.map(fieldRow)
|
|
163
|
+
: [h('p', { class: 'fx-dim' }, 'No fields yet. Create one, for example Priority or Technician, to group the Jobs board by it.')])),
|
|
164
|
+
];
|
|
165
|
+
if (data.systemFields.length) {
|
|
166
|
+
sections.push(h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, 'From integrations'),
|
|
167
|
+
...data.systemFields.map(systemRow),
|
|
168
|
+
h('p', { class: 'fx-dim', style: { margin: '8px 0 0' } }, 'Flow reads these. Change them in their own system.')));
|
|
169
|
+
}
|
|
170
|
+
if (archived.length) {
|
|
171
|
+
sections.push(h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, `Archived · ${archived.length}`), ...archived.map((f) => fieldRow(f, null))));
|
|
172
|
+
}
|
|
173
|
+
sections.push(h('p', { class: 'fx-dim', style: { margin: '8px 0 0' } }, h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How custom fields work')));
|
|
174
|
+
host.replaceChildren(...sections.filter(Boolean));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function load() {
|
|
178
|
+
try {
|
|
179
|
+
const [live, all] = await Promise.all([flow('/api/custom-fields'), flow('/api/custom-fields?archived=1')]);
|
|
180
|
+
data = { ...live, archived: all.fields };
|
|
181
|
+
} catch (error) {
|
|
182
|
+
host.replaceChildren(notice(error.message, 'danger'));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
draw();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
addButton.addEventListener('click', () => edit(null));
|
|
189
|
+
await load();
|
|
190
|
+
}
|
package/server/ui/flow.css
CHANGED
|
@@ -105,6 +105,15 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
|
|
|
105
105
|
border-radius: var(--t-radius-lg); box-shadow: var(--t-shadow);
|
|
106
106
|
}
|
|
107
107
|
.fx-panel { overflow: hidden; }
|
|
108
|
+
/* A list that is loading: a thin moving line on top. A list from another
|
|
109
|
+
tab, shown until the new one comes, is dimmed and cannot be clicked. */
|
|
110
|
+
.fx-panel[aria-busy="true"] { position: relative; }
|
|
111
|
+
.fx-panel[aria-busy="true"]::before {
|
|
112
|
+
content: ""; position: absolute; inset: 0 0 auto; height: 2px; z-index: 1;
|
|
113
|
+
background: linear-gradient(90deg, transparent, var(--t-accent), transparent) 0 0 / 200% 100%;
|
|
114
|
+
animation: fx-shimmer 1s linear infinite;
|
|
115
|
+
}
|
|
116
|
+
.fx-panel[data-stale] { opacity: .5; pointer-events: none; transition: opacity .15s; }
|
|
108
117
|
|
|
109
118
|
/* The signed-in app: a standard left sidebar, then the view. */
|
|
110
119
|
.fx-shell { min-height: 100vh; }
|
|
@@ -430,14 +439,23 @@ a.fx-strong:hover { text-decoration: underline; }
|
|
|
430
439
|
.fx-toolbar--plain .fx-search { margin-bottom: 0; }
|
|
431
440
|
.fx-empty .fx-btn { margin-top: 12px; }
|
|
432
441
|
.fx-panel.fx-empty { display: grid; justify-items: center; }
|
|
442
|
+
.fx-toolbar--end { justify-content: flex-end; }
|
|
433
443
|
/* The board never scrolls sideways: its columns (one per work system status)
|
|
434
|
-
wrap onto more rows when the page is narrow, and
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
.fx-
|
|
444
|
+
are about a phone wide, wrap onto more rows when the page is narrow, and
|
|
445
|
+
stack on a phone. On a wide, tall screen the board fills the page down to
|
|
446
|
+
its bottom edge, and each column scrolls inside itself (jobs.js sets
|
|
447
|
+
fx-main--board while the board shows). */
|
|
448
|
+
.fx-board { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 256px), 1fr)); gap: 12px; align-items: start; }
|
|
449
|
+
.fx-board__col { display: flex; flex-direction: column; gap: 8px; min-width: 0; min-height: 0; padding: 10px 6px 6px 8px; border-radius: var(--t-radius-lg); background: var(--t-s1); }
|
|
450
|
+
.fx-board__head { display: flex; flex: none; align-items: center; gap: 8px; min-width: 0; padding: 2px 4px 4px 2px; }
|
|
439
451
|
.fx-board__sum { flex: none; margin-left: auto; color: var(--t-dim); font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
440
|
-
.fx-board__cards { display: grid; gap: 8px; max-height: 70vh; overflow-y: auto; }
|
|
452
|
+
.fx-board__cards { display: grid; gap: 8px; align-content: start; min-height: 0; max-height: 70vh; padding-right: 2px; overflow-y: auto; scrollbar-width: thin; }
|
|
453
|
+
@media (min-width: 821px) and (min-height: 560px) {
|
|
454
|
+
.fx-main--board { height: 100vh; height: 100dvh; padding: 24px 24px 16px; grid-template-rows: auto auto minmax(0, 1fr); align-content: stretch; }
|
|
455
|
+
.fx-main--board > .fx-jobs { min-height: 0; }
|
|
456
|
+
.fx-main--board .fx-board { height: 100%; overflow-y: auto; grid-auto-rows: 100%; align-items: stretch; }
|
|
457
|
+
.fx-main--board .fx-board__cards { flex: 1; max-height: none; }
|
|
458
|
+
}
|
|
441
459
|
.fx-board__none { padding: 8px 4px; color: var(--t-dim); font-size: 13px; }
|
|
442
460
|
.fx-table--jobs tbody tr { cursor: pointer; }
|
|
443
461
|
|
|
@@ -467,9 +485,10 @@ a.fx-strong:hover { text-decoration: underline; }
|
|
|
467
485
|
.fx-job { position: relative; display: grid; min-width: 0; padding: 11px 12px 9px; border: 1px solid var(--fx-line); border-radius: var(--t-radius); background: var(--t-paper); box-shadow: var(--t-shadow); font-size: 13px; cursor: pointer; }
|
|
468
486
|
.fx-job:hover { border-color: var(--t-brd-hi, var(--fx-line)); }
|
|
469
487
|
.fx-job:focus-visible { outline: 2px solid var(--t-ring, var(--t-accent)); outline-offset: 2px; }
|
|
470
|
-
.fx-job__client {
|
|
471
|
-
.fx-job__who { display: flex; align-items:
|
|
472
|
-
.fx-job__who span { min-width: 0; overflow
|
|
488
|
+
.fx-job__client { color: var(--t-strong); font-size: 14px; font-weight: 600; line-height: 1.3; overflow-wrap: anywhere; }
|
|
489
|
+
.fx-job__who { display: flex; align-items: flex-start; gap: 6px; min-width: 0; margin-top: 5px; color: var(--t-dim); font-size: 12.5px; }
|
|
490
|
+
.fx-job__who span { min-width: 0; overflow-wrap: anywhere; }
|
|
491
|
+
.fx-job__who svg { margin-top: 2px; }
|
|
473
492
|
.fx-job__who svg, .fx-job__date svg { flex: none; color: var(--t-mute); }
|
|
474
493
|
.fx-job__foot { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--fx-line-soft); color: var(--t-dim); font-size: 12px; }
|
|
475
494
|
.fx-job__date { display: inline-flex; align-items: center; gap: 5px; min-width: 0; overflow: hidden; white-space: nowrap; }
|
|
@@ -746,6 +765,18 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
746
765
|
.fx-price__systems { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 10px; }
|
|
747
766
|
.fx-set__control .fx-price__link { width: 260px; min-width: 0; }
|
|
748
767
|
@media (max-width: 560px) { .fx-set__control .fx-price__link { width: 150px; } }
|
|
768
|
+
/* Custom fields (custom-fields.js): option chips with a colour dot, and the
|
|
769
|
+
dialog's option lines. The option colours are fixed, like ClickUp's. */
|
|
770
|
+
.fx-cf__opt { display: inline-flex; align-items: center; gap: 6px; padding: 2px 9px; border: 1px solid var(--fx-line); border-radius: 999px; font-size: 12.5px; color: var(--t-text); white-space: nowrap; }
|
|
771
|
+
.fx-cf__opt::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--cf); flex: none; }
|
|
772
|
+
[data-color="gray"] { --cf: #8a8f98; } [data-color="blue"] { --cf: #3b82f6; } [data-color="teal"] { --cf: #14b8a6; }
|
|
773
|
+
[data-color="green"] { --cf: #22a35a; } [data-color="amber"] { --cf: #e0a019; } [data-color="red"] { --cf: #e5484d; }
|
|
774
|
+
[data-color="purple"] { --cf: #8b5cf6; } [data-color="pink"] { --cf: #e5469a; }
|
|
775
|
+
.fx-cf__options { display: grid; gap: 8px; }
|
|
776
|
+
.fx-cf__line { display: grid; grid-template-columns: minmax(0, 1fr) 120px auto auto; gap: 8px; align-items: center; }
|
|
777
|
+
.fx-cf__line .fx-input, .fx-cf__line .fx-select { width: 100%; min-width: 0; }
|
|
778
|
+
.fx-cf__color { border-left: 4px solid var(--cf); }
|
|
779
|
+
@media (max-width: 560px) { .fx-cf__line { grid-template-columns: minmax(0, 1fr) 96px; } }
|
|
749
780
|
/* Invoice rules (invoice-rules.js): a row's Edit button and switch, and the
|
|
750
781
|
rule dialog's condition lines. */
|
|
751
782
|
.fx-rule__controls { align-items: center; gap: 10px; }
|
|
@@ -818,3 +849,20 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
818
849
|
.fx-pager__step { display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 1px solid var(--fx-line-soft); border-radius: 999px; background: transparent; color: var(--t-text); cursor: pointer; transition: background 120ms var(--fx-ease), border-color 120ms var(--fx-ease); }
|
|
819
850
|
.fx-pager__step:hover:not(:disabled) { background: var(--fx-hover); border-color: var(--fx-line); }
|
|
820
851
|
.fx-pager__step:disabled { opacity: 0.35; cursor: default; }
|
|
852
|
+
|
|
853
|
+
/* New invoice from a job: a quiet strip above the form, and a searchable
|
|
854
|
+
list of jobs with no invoice. */
|
|
855
|
+
.fx-fromjob {
|
|
856
|
+
display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px;
|
|
857
|
+
padding: 12px 16px; border: 1px dashed var(--fx-line); border-radius: var(--t-radius);
|
|
858
|
+
}
|
|
859
|
+
.fx-fromjob__text { display: grid; gap: 2px; font-size: 14px; }
|
|
860
|
+
.fx-dialog:has(.fx-pick__body) { width: min(620px, calc(100vw - 32px)); }
|
|
861
|
+
.fx-pick__body { display: grid; gap: 12px; }
|
|
862
|
+
.fx-pick { display: grid; max-height: min(420px, 55vh); overflow-y: auto; border: 1px solid var(--fx-line); border-radius: var(--t-radius); }
|
|
863
|
+
.fx-pick__row { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 12px; padding: 10px 14px; border-bottom: 1px solid var(--fx-line); cursor: pointer; }
|
|
864
|
+
.fx-pick__row:last-child { border-bottom: 0; }
|
|
865
|
+
.fx-pick__row:hover, .fx-pick__row:has(input:checked) { background: var(--fx-hover); }
|
|
866
|
+
.fx-pick__row[hidden] { display: none; }
|
|
867
|
+
.fx-pick__main { display: grid; gap: 2px; min-width: 0; }
|
|
868
|
+
.fx-pick > .fx-dim { padding: 14px; }
|