@arcforgelabs/flow 2026.10.34 → 2026.10.36
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 +2 -1
- package/npm-shrinkwrap.json +81 -50
- package/package.json +7 -5
- package/server/api/custom-fields.js +5 -4
- package/server/api/invoicing.js +54 -12
- 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/custom-fields.js +223 -56
- package/server/ui/flow.css +155 -2
- package/server/ui/invoice-notes.js +1 -1
- package/server/ui/invoicing.js +160 -59
- 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 +11 -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;
|
|
@@ -1,10 +1,13 @@
|
|
|
1
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
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
2
|
+
// docs/CUSTOM_FIELDS.md). The fields a lab makes for its jobs, laid out like
|
|
3
|
+
// ClickUp's Custom Field Manager: one table, with a group per type that folds
|
|
4
|
+
// open and closed, and one row per field with its type icon, who made it,
|
|
5
|
+
// when, and where it applies. Search, a type filter and Filter (archived
|
|
6
|
+
// fields) sit above it. A row's Edit opens the field's dialog (name and
|
|
7
|
+
// options); its ⋯ menu has Rename, Duplicate, Copy field ID and Archive. The
|
|
8
|
+
// work systems' own fields (Seazona's stage) sit in their own group, read
|
|
9
|
+
// only. Everyone can read the page; an owner or admin changes it. The caller
|
|
10
|
+
// passes in app.js's shared pieces as `ui`.
|
|
8
11
|
|
|
9
12
|
const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/CUSTOM_FIELDS.md';
|
|
10
13
|
const TYPE_LABELS = {
|
|
@@ -18,13 +21,44 @@ const TYPE_HINTS = {
|
|
|
18
21
|
number: 'A number, for example units.',
|
|
19
22
|
text: 'A short note.',
|
|
20
23
|
};
|
|
24
|
+
// Each type's icon (Lucide, served at /assets/icons/) and group colour.
|
|
25
|
+
const TYPE_ICONS = {
|
|
26
|
+
dropdown: 'square-chevron-down', person: 'user-round', checkbox: 'square-check', date: 'calendar', number: 'hash', text: 'type',
|
|
27
|
+
};
|
|
28
|
+
const TYPE_COLORS = { dropdown: 'green', person: 'blue', checkbox: 'purple', date: 'teal', number: 'amber', text: 'gray' };
|
|
29
|
+
const FOLDED_KEY = 'flow_custom_fields_folded';
|
|
30
|
+
|
|
31
|
+
// A Lucide icon as a mask, so it takes the text colour.
|
|
32
|
+
export function lucide(h, name, label = null) {
|
|
33
|
+
return h('span', { class: 'fx-lucide', style: `--icon: url(/assets/icons/${name}.svg)`, ...(label ? { role: 'img', 'aria-label': label, title: label } : { 'aria-hidden': 'true' }) });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// "Today", "Yesterday", else d/m/yy, like ClickUp's Date created.
|
|
37
|
+
export function createdDay(iso, now = new Date()) {
|
|
38
|
+
if (!iso) return '';
|
|
39
|
+
const day = new Date(iso);
|
|
40
|
+
const start = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
41
|
+
const days = Math.round((start(now) - start(day)) / 86_400_000);
|
|
42
|
+
if (days === 0) return 'Today';
|
|
43
|
+
if (days === 1) return 'Yesterday';
|
|
44
|
+
return `${day.getDate()}/${day.getMonth() + 1}/${String(day.getFullYear()).slice(2)}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const initials = (name) => (name ?? '').split(/[\s@._-]+/).filter(Boolean).slice(0, 2).map((w) => w[0].toUpperCase()).join('') || '?';
|
|
48
|
+
|
|
49
|
+
function readFolded() {
|
|
50
|
+
try { return new Set(JSON.parse(localStorage.getItem(FOLDED_KEY) ?? '[]')); } catch { return new Set(); }
|
|
51
|
+
}
|
|
52
|
+
function saveFolded(set) {
|
|
53
|
+
try { localStorage.setItem(FOLDED_KEY, JSON.stringify([...set])); } catch { /* this browser keeps no state */ }
|
|
54
|
+
}
|
|
21
55
|
|
|
22
56
|
export async function renderCustomFields(main, ui) {
|
|
23
57
|
const { h, flow, toast, notice, dialog, pageHeader, me } = ui;
|
|
24
58
|
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
25
|
-
const host = h('div', { class: 'fx-card
|
|
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.'
|
|
59
|
+
const host = h('div', { class: 'fx-card fx-cfm__card' }, h('div', { class: 'fx-skeleton' }));
|
|
60
|
+
const addButton = h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button', disabled }, 'Create field');
|
|
61
|
+
main.append(pageHeader('Custom fields', { subtitle: 'Fields for your jobs. They stay in Flow and never change Seazona or Xero.' }), host);
|
|
28
62
|
|
|
29
63
|
let data = null;
|
|
30
64
|
|
|
@@ -39,11 +73,6 @@ export async function renderCustomFields(main, ui) {
|
|
|
39
73
|
}
|
|
40
74
|
}
|
|
41
75
|
|
|
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
76
|
|
|
48
77
|
// The option list editor for a dropdown: one line per option (name,
|
|
49
78
|
// colour, move up, remove), in lane order.
|
|
@@ -119,60 +148,198 @@ export async function renderCustomFields(main, ui) {
|
|
|
119
148
|
return load();
|
|
120
149
|
}
|
|
121
150
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
151
|
+
// ---- The manager: toolbar, then one table of groups ----
|
|
152
|
+
const view = { q: '', types: new Set(), archived: false, desc: false, folded: readFolded(), renaming: null };
|
|
153
|
+
let menuCount = 0;
|
|
154
|
+
|
|
155
|
+
// A popover menu under a button (the Jobs board's toolbar menus work the same way).
|
|
156
|
+
function popMenu(button, build) {
|
|
157
|
+
const id = `fx-cf-menu-${++menuCount}`;
|
|
158
|
+
const panel = h('div', { class: 'fx-pop', id, popover: 'auto', role: 'menu' });
|
|
159
|
+
button.setAttribute('popovertarget', id);
|
|
160
|
+
button.setAttribute('aria-haspopup', 'menu');
|
|
161
|
+
button.setAttribute('aria-expanded', 'false');
|
|
162
|
+
panel.addEventListener('toggle', (event) => {
|
|
163
|
+
button.setAttribute('aria-expanded', String(event.newState === 'open'));
|
|
164
|
+
if (event.newState !== 'open') return;
|
|
165
|
+
panel.replaceChildren(...build(() => panel.hidePopover()).filter(Boolean));
|
|
166
|
+
const at = button.getBoundingClientRect();
|
|
167
|
+
panel.style.top = `${Math.round(at.bottom + 6)}px`;
|
|
168
|
+
panel.style.left = `${Math.round(Math.max(8, Math.min(at.right - panel.offsetWidth, innerWidth - panel.offsetWidth - 8)))}px`;
|
|
169
|
+
});
|
|
170
|
+
return panel;
|
|
171
|
+
}
|
|
172
|
+
const item = (iconName, text, onclick, { disabled: off = false, title = null, role = 'menuitem', checked = null } = {}) => h('button', {
|
|
173
|
+
type: 'button', class: 'fx-pop__item', role, disabled: off, title, onclick, ...(checked === null ? {} : { 'aria-checked': String(checked) }),
|
|
174
|
+
}, h('span', { class: 'fx-pop__tick' }, iconName ? lucide(h, iconName) : null), h('span', {}, text));
|
|
175
|
+
|
|
176
|
+
const search = h('input', { class: 'fx-input fx-cfm__search', type: 'search', placeholder: 'Search fields…', 'aria-label': 'Search fields' });
|
|
177
|
+
search.addEventListener('input', () => { view.q = search.value.trim().toLowerCase(); draw(); });
|
|
178
|
+
const typeButton = h('button', { class: 'fx-chip fx-cfm__tool', type: 'button' });
|
|
179
|
+
const typeMenu = popMenu(typeButton, (close) => [
|
|
180
|
+
...data.types.map((t) => item(view.types.has(t) ? 'check' : null, TYPE_LABELS[t], () => {
|
|
181
|
+
if (view.types.has(t)) view.types.delete(t); else view.types.add(t);
|
|
182
|
+
close(); draw();
|
|
183
|
+
}, { role: 'menuitemcheckbox', checked: view.types.has(t) })),
|
|
184
|
+
view.types.size ? h('div', { class: 'fx-pop__sep' }) : null,
|
|
185
|
+
view.types.size ? item(null, 'All types', () => { view.types.clear(); close(); draw(); }) : null,
|
|
186
|
+
]);
|
|
187
|
+
const filterButton = h('button', { class: 'fx-chip fx-cfm__tool fx-cfm__tool--dashed', type: 'button' });
|
|
188
|
+
const filterMenu = popMenu(filterButton, (close) => [
|
|
189
|
+
item(view.archived ? 'check' : null, 'Show archived fields', () => { view.archived = !view.archived; close(); draw(); }, { role: 'menuitemcheckbox', checked: view.archived }),
|
|
190
|
+
]);
|
|
191
|
+
const toolbar = h('div', { class: 'fx-cfm__bar' }, search, typeButton, typeMenu, filterButton, filterMenu, h('span', { class: 'fx-cfm__gap' }), addButton);
|
|
192
|
+
const table = h('div', { class: 'fx-cfm', role: 'table', 'aria-label': 'Custom fields' });
|
|
193
|
+
host.replaceChildren(toolbar, table);
|
|
194
|
+
|
|
195
|
+
const personName = (id) => data.people?.find((p) => p.id === id)?.name ?? null;
|
|
196
|
+
const matches = (f) => !view.q || f.name.toLowerCase().includes(view.q)
|
|
197
|
+
|| (f.options ?? []).some((o) => !o.archived && o.name.toLowerCase().includes(view.q));
|
|
198
|
+
const byName = (a, b) => a.name.localeCompare(b.name) * (view.desc ? -1 : 1);
|
|
199
|
+
|
|
200
|
+
async function rename(field, name) {
|
|
201
|
+
view.renaming = null;
|
|
202
|
+
const clean = name.trim();
|
|
203
|
+
if (clean && clean !== field.name && await send(`/api/custom-fields/${field.id}`, 'PUT', { name: clean }, 'Renamed.')) return load();
|
|
204
|
+
return draw();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function duplicate(field) {
|
|
208
|
+
const taken = new Set(data.fields.map((f) => f.name.toLowerCase()));
|
|
209
|
+
let name = `${field.name} copy`.slice(0, 60);
|
|
210
|
+
for (let n = 2; taken.has(name.toLowerCase()); n += 1) name = `${field.name} copy ${n}`.slice(0, 60);
|
|
211
|
+
const payload = { name, type: field.type };
|
|
212
|
+
if (field.type === 'dropdown') payload.options = field.options.filter((o) => !o.archived).map(({ name: n, color }) => ({ name: n, color }));
|
|
213
|
+
if (await send('/api/custom-fields', 'POST', payload, `Made ${name}.`)) await load();
|
|
126
214
|
}
|
|
127
215
|
|
|
128
|
-
function
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
216
|
+
async function copyId(field) {
|
|
217
|
+
try {
|
|
218
|
+
await navigator.clipboard.writeText(field.id);
|
|
219
|
+
toast('Field ID copied.', 'success');
|
|
220
|
+
} catch {
|
|
221
|
+
toast(`Field ID: ${field.id}`, 'info');
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function setArchived(field, archived) {
|
|
226
|
+
if (await send(`/api/custom-fields/${field.id}/archive`, 'POST', { archived }, archived ? 'Archived. Its values are kept.' : 'Brought back.')) await load();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function rowMenu(field) {
|
|
230
|
+
const button = h('button', { class: 'fx-cfm__more', type: 'button', 'aria-label': `More for ${field.name}` }, lucide(h, 'ellipsis'));
|
|
231
|
+
const panel = popMenu(button, (close) => (field.archived
|
|
232
|
+
? [item('archive-restore', 'Bring back', () => { close(); setArchived(field, false); }, { disabled })]
|
|
233
|
+
: [
|
|
234
|
+
item('square-pen', 'Edit', () => { close(); edit(field); }, { disabled }),
|
|
235
|
+
item('pencil', 'Rename', () => { close(); view.renaming = field.id; draw(); }, { disabled }),
|
|
236
|
+
item('copy', 'Duplicate', () => { close(); duplicate(field); }, { disabled }),
|
|
237
|
+
item('repeat', 'Convert', null, { disabled: true, title: 'Changing a field\'s type is not in Flow yet.' }),
|
|
238
|
+
item('clipboard-copy', 'Copy field ID', () => { close(); copyId(field); }),
|
|
239
|
+
h('div', { class: 'fx-pop__sep' }),
|
|
240
|
+
item('archive', 'Archive', () => { close(); setArchived(field, true); }, { disabled }),
|
|
241
|
+
]));
|
|
242
|
+
return [button, panel];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function nameCell(field) {
|
|
246
|
+
if (view.renaming === field.id) {
|
|
247
|
+
const input = h('input', { class: 'fx-input fx-cfm__rename', value: field.name, maxlength: '60', 'aria-label': 'Field name' });
|
|
248
|
+
input.addEventListener('keydown', (event) => {
|
|
249
|
+
if (event.key === 'Enter') input.blur();
|
|
250
|
+
if (event.key === 'Escape') { view.renaming = null; draw(); }
|
|
144
251
|
});
|
|
145
|
-
|
|
252
|
+
input.addEventListener('blur', () => { if (view.renaming === field.id) rename(field, input.value); });
|
|
253
|
+
queueMicrotask(() => { input.focus(); input.select(); });
|
|
254
|
+
return h('div', { class: 'fx-cfm__name', role: 'cell' }, input);
|
|
146
255
|
}
|
|
147
|
-
|
|
256
|
+
const editButton = field.archived || disabled || field.source ? null
|
|
257
|
+
: h('button', { class: 'fx-cfm__edit', type: 'button', onclick: () => edit(field) }, 'Edit', lucide(h, 'panel-right'));
|
|
258
|
+
return h('div', { class: 'fx-cfm__name', role: 'cell' },
|
|
259
|
+
field.source ? lucide(h, 'lock', 'Read only') : null,
|
|
260
|
+
h('span', { class: 'fx-cfm__label', title: field.name }, field.name), editButton);
|
|
148
261
|
}
|
|
149
262
|
|
|
150
|
-
function
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
h('
|
|
263
|
+
function fieldRow(field) {
|
|
264
|
+
const by = field.source ? field.sourceLabel : personName(field.createdBy);
|
|
265
|
+
return h('div', { class: 'fx-cfm__row', role: 'row', 'data-archived': field.archived ? 'true' : null },
|
|
266
|
+
nameCell(field),
|
|
267
|
+
h('div', { class: 'fx-cfm__type', role: 'cell', 'data-color': TYPE_COLORS[field.type] }, lucide(h, TYPE_ICONS[field.type], TYPE_LABELS[field.type])),
|
|
268
|
+
h('div', { class: 'fx-cfm__by', role: 'cell' }, by ? h('span', { class: 'fx-cfm__avatar', title: by }, field.source ? lucide(h, 'plug') : initials(by)) : h('span', { class: 'fx-dim' }, '—')),
|
|
269
|
+
h('div', { class: 'fx-cfm__date', role: 'cell' }, field.source ? h('span', { class: 'fx-dim' }, '—') : createdDay(field.createdAt)),
|
|
270
|
+
h('div', { class: 'fx-cfm__where', role: 'cell' }, field.source ? `${field.sourceLabel} jobs` : 'Jobs'),
|
|
271
|
+
h('div', { class: 'fx-cfm__end', role: 'cell' }, field.source ? null : rowMenu(field)));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// One group: a header that folds it, its rows, and "Create … field". A
|
|
275
|
+
// search opens every group that has a match.
|
|
276
|
+
function group(key, pill, rows, { create = null } = {}) {
|
|
277
|
+
const open = Boolean(view.q) || !view.folded.has(key);
|
|
278
|
+
const toggle = h('button', { class: 'fx-cfm__fold', type: 'button', 'aria-expanded': String(open), 'aria-label': `${open ? 'Fold' : 'Open'} ${pill.text}` }, lucide(h, 'chevron-down'));
|
|
279
|
+
toggle.addEventListener('click', () => {
|
|
280
|
+
if (open) view.folded.add(key); else view.folded.delete(key);
|
|
281
|
+
saveFolded(view.folded);
|
|
282
|
+
draw();
|
|
283
|
+
});
|
|
284
|
+
const head = h('div', { class: 'fx-cfm__group', role: 'row' }, toggle,
|
|
285
|
+
h('span', { class: 'fx-cfm__pill', 'data-color': pill.color }, pill.icon ? lucide(h, pill.icon) : null, pill.text),
|
|
286
|
+
h('span', { class: 'fx-cfm__count' }, String(rows.length)));
|
|
287
|
+
if (!open) return [head];
|
|
288
|
+
const out = [head, ...rows.map(fieldRow)];
|
|
289
|
+
if (create) out.push(create);
|
|
290
|
+
return out;
|
|
155
291
|
}
|
|
156
292
|
|
|
157
293
|
function draw() {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
294
|
+
if (!data) return;
|
|
295
|
+
typeButton.replaceChildren(lucide(h, 'layers'), h('span', {}, view.types.size === 1 ? TYPE_LABELS[[...view.types][0]] : view.types.size ? `${view.types.size} types` : 'Field type'), lucide(h, 'chevron-down'));
|
|
296
|
+
typeButton.classList.toggle('fx-chip--on', view.types.size > 0);
|
|
297
|
+
filterButton.replaceChildren(lucide(h, 'list-filter'), h('span', {}, view.archived ? 'Archived shown' : 'Filter'), lucide(h, 'chevron-down'));
|
|
298
|
+
filterButton.classList.toggle('fx-chip--on', view.archived);
|
|
299
|
+
|
|
300
|
+
const shown = (type) => !view.types.size || view.types.has(type);
|
|
301
|
+
const sortHead = h('button', { class: 'fx-cfm__sort', type: 'button', 'aria-label': `Sort by name, ${view.desc ? 'Z to A' : 'A to Z'}` }, 'Name', lucide(h, view.desc ? 'arrow-up-z-a' : 'arrow-down-a-z'));
|
|
302
|
+
sortHead.addEventListener('click', () => { view.desc = !view.desc; draw(); });
|
|
303
|
+
const parts = [h('div', { class: 'fx-cfm__head', role: 'row' },
|
|
304
|
+
h('div', { role: 'columnheader' }, sortHead), h('div', { role: 'columnheader' }, 'Type'), h('div', { role: 'columnheader', class: 'fx-cfm__by' }, 'Created by'),
|
|
305
|
+
h('div', { role: 'columnheader', class: 'fx-cfm__date' }, 'Date created'), h('div', { role: 'columnheader', class: 'fx-cfm__where' }, 'Location'), h('div', { role: 'columnheader' }))];
|
|
306
|
+
if (disabled) parts.unshift(notice('Only an owner or admin can change these. Everyone can set a job\'s values.', 'info'));
|
|
307
|
+
|
|
308
|
+
// Type groups in ClickUp's order: by type name.
|
|
309
|
+
const types = [...data.types].sort((a, b) => TYPE_LABELS[a].localeCompare(TYPE_LABELS[b]));
|
|
310
|
+
let count = 0;
|
|
311
|
+
for (const type of types) {
|
|
312
|
+
if (!shown(type)) continue;
|
|
313
|
+
const fields = data.fields.filter((f) => f.type === type && matches(f)).sort(byName);
|
|
314
|
+
if (!fields.length && (view.q || view.types.size === 0)) continue;
|
|
315
|
+
count += fields.length;
|
|
316
|
+
const create = view.q || disabled ? null : h('button', { class: 'fx-cfm__create', type: 'button', onclick: () => edit(null, { type, name: '', options: undefined }) },
|
|
317
|
+
lucide(h, 'plus'), `Create ${TYPE_LABELS[type].toLowerCase()} field`);
|
|
318
|
+
parts.push(...group(type, { text: TYPE_LABELS[type], icon: TYPE_ICONS[type], color: TYPE_COLORS[type] }, fields, { create }));
|
|
319
|
+
}
|
|
320
|
+
// The work systems' own fields: one read-only group per system.
|
|
321
|
+
for (const source of [...new Set(data.systemFields.map((f) => f.source))]) {
|
|
322
|
+
const fields = data.systemFields.filter((f) => f.source === source && shown(f.type))
|
|
323
|
+
.map((f) => ({ ...f, id: `${f.source}:${f.name}`, name: `${f.sourceLabel} ${f.name.toLowerCase()}` })).filter(matches);
|
|
324
|
+
if (!fields.length) continue;
|
|
325
|
+
count += fields.length;
|
|
326
|
+
parts.push(...group(`source:${source}`, { text: `From ${fields[0].sourceLabel}`, icon: 'lock', color: 'gray' }, fields));
|
|
327
|
+
}
|
|
328
|
+
if (view.archived) {
|
|
329
|
+
const fields = data.archived.filter((f) => f.archived && shown(f.type) && matches(f)).sort(byName);
|
|
330
|
+
count += fields.length;
|
|
331
|
+
parts.push(...group('archived', { text: 'Archived', icon: 'archive', color: 'gray' }, fields));
|
|
169
332
|
}
|
|
170
|
-
if (
|
|
171
|
-
|
|
333
|
+
if (!count && (view.q || view.types.size)) parts.push(h('p', { class: 'fx-cfm__empty' }, 'No field matches.'));
|
|
334
|
+
if (!data.fields.length && !view.q && !view.types.size) {
|
|
335
|
+
parts.push(h('p', { class: 'fx-cfm__empty' }, 'No fields yet. Create one, for example Priority or Technician, to group the Jobs board by it.'));
|
|
172
336
|
}
|
|
173
|
-
|
|
174
|
-
|
|
337
|
+
table.replaceChildren(...parts);
|
|
338
|
+
foot.replaceChildren(h('span', {}, `${data.fields.length} ${data.fields.length === 1 ? 'field' : 'fields'}`),
|
|
339
|
+
h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How custom fields work'));
|
|
175
340
|
}
|
|
341
|
+
const foot = h('div', { class: 'fx-cfm__foot' });
|
|
342
|
+
host.append(foot);
|
|
176
343
|
|
|
177
344
|
async function load() {
|
|
178
345
|
try {
|