@arcforgelabs/flow 2026.10.43 → 2026.10.45
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 -0
- package/npm-shrinkwrap.json +4 -3
- package/package.json +5 -3
- package/server/api/invoicing.js +53 -3
- package/server/auth/people.js +41 -2
- package/server/auth/routes.js +11 -0
- package/server/auth/session.js +22 -3
- package/server/core/custom-fields.js +4 -3
- package/server/core/invoice-rules.js +8 -3
- package/server/core/job-match.js +6 -2
- package/server/core/sending.js +4 -3
- package/server/release-channel.js +5 -2
- package/server/ui/app.js +53 -197
- package/server/ui/columns.js +286 -0
- package/server/ui/flow.css +230 -43
- package/server/ui/invoice-rules.js +3 -3
- package/server/ui/invoicing-settings.js +52 -29
- package/server/ui/invoicing.js +219 -72
- package/server/ui/job-view.js +276 -60
- package/server/ui/jobs.js +133 -137
- package/server/ui/kit.js +279 -0
- package/server/ui/list-views.js +115 -0
- package/server/ui/monitoring.js +51 -8
- package/server/ui/pager.js +67 -0
- package/server/ui/person.js +29 -0
- package/server/ui/price-list.js +27 -7
- package/server/ui/reconcile.js +114 -61
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +141 -0
- package/server/ui/search.js +8 -0
- package/server/ui/view-bar.js +176 -70
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// A list's columns, as ClickUp's: drag a head's right edge to make the
|
|
2
|
+
// column wider or narrower (never under its kind's minimum), drag a head
|
|
3
|
+
// to move the column, and click or right-click a head for its menu (Move to
|
|
4
|
+
// start, Move to end, Hide; + shows a hidden column again). Jobs, Invoices,
|
|
5
|
+
// Reconcile and the Price list build their tables here, so every list works
|
|
6
|
+
// the same.
|
|
7
|
+
//
|
|
8
|
+
// The view a person keeps for a list (with their account, PUT
|
|
9
|
+
// /api/me/views/:page) holds two keys from here:
|
|
10
|
+
// columns the shown columns' ids, in order
|
|
11
|
+
// widths { [id]: px } for the columns the person made wider or narrower
|
|
12
|
+
// The rules (no DOM) are in job-view.js ("Columns"), so the server checks a
|
|
13
|
+
// saved view with them (server/auth/people.js) and tests read them.
|
|
14
|
+
//
|
|
15
|
+
// columnTable(h, config), once per page, answers { table, menu, openMenu }:
|
|
16
|
+
// order() the shown columns' ids, as the view keeps them
|
|
17
|
+
// widths() the saved widths
|
|
18
|
+
// change(patch) saves { columns } or { widths }
|
|
19
|
+
// hidden() [{ id, label }] the + menu lists (no hidden: no +)
|
|
20
|
+
// extra(column, item) menu nodes before Move (Sort, Edit custom field)
|
|
21
|
+
// table({ columns, rows, lead, className }), per draw, answers the table:
|
|
22
|
+
// columns the shown columns, in order: { id, label, kind, cell(row),
|
|
23
|
+
// num?, className?, sort?(): node, icon?, fixed? }. An icon
|
|
24
|
+
// shows in place of the label (the label is its name). A fixed
|
|
25
|
+
// column keeps its place: no drag, no Move, no Hide.
|
|
26
|
+
// rows the <tbody> nodes (rowCells builds a row's cells)
|
|
27
|
+
// lead <th> nodes before the columns (the tick column)
|
|
28
|
+
import { lucide } from '/assets/ui/custom-fields.js';
|
|
29
|
+
import { endWidth, LEAD_WIDTH, MAX_WIDTH, minWidth, moveColumn, TAIL_WIDTH, tableWidth, widthOf } from '/assets/ui/job-view.js';
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
// A row's cells for the shown columns: a class per kind, the label for
|
|
33
|
+
// the phone layout, the full text as the cell's name on hover.
|
|
34
|
+
export function rowCells(h, columns, row) {
|
|
35
|
+
return columns.map((c) => {
|
|
36
|
+
const td = h('td', {
|
|
37
|
+
class: [`fx-c-${c.kind}`, c.num ? 'fx-num' : null, c.className].filter(Boolean).join(' '),
|
|
38
|
+
'data-label': c.label,
|
|
39
|
+
}, c.cell(row));
|
|
40
|
+
// A cell stays on one line and ends in "…" when the column is narrow
|
|
41
|
+
// (flow.css, .fx-table--sized); the whole text shows on hover.
|
|
42
|
+
const text = td.textContent?.trim();
|
|
43
|
+
if (text && text !== '—') td.title = text;
|
|
44
|
+
return td;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── The table ──────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
// One menu for every column head, as a popover: made at the first open and
|
|
51
|
+
// kept for every page after.
|
|
52
|
+
function columnPopover(h) {
|
|
53
|
+
let pop = null;
|
|
54
|
+
const close = () => pop?.hidePopover?.();
|
|
55
|
+
const item = (icon, text, run) => h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { close(); run(); } },
|
|
56
|
+
h('span', { class: 'fx-pop__tick' }, lucide(h, icon)), h('span', {}, text));
|
|
57
|
+
const sep = () => h('div', { class: 'fx-pop__sep' });
|
|
58
|
+
// Open with these nodes, at the pointer or under the node clicked.
|
|
59
|
+
function show(parts, event) {
|
|
60
|
+
pop = document.getElementById('fx-colmenu');
|
|
61
|
+
if (!pop) {
|
|
62
|
+
pop = h('div', { class: 'fx-pop', id: 'fx-colmenu', popover: 'auto', role: 'menu' });
|
|
63
|
+
document.body.append(pop);
|
|
64
|
+
}
|
|
65
|
+
pop.replaceChildren(...parts);
|
|
66
|
+
pop.showPopover?.();
|
|
67
|
+
const box = event.currentTarget?.getBoundingClientRect?.();
|
|
68
|
+
const x = event.clientX || box?.left || 0;
|
|
69
|
+
const y = event.clientY || box?.bottom || 0;
|
|
70
|
+
pop.style.top = `${Math.round(Math.min(y + 4, innerHeight - pop.offsetHeight - 8))}px`;
|
|
71
|
+
pop.style.left = `${Math.round(Math.max(8, Math.min(x, innerWidth - pop.offsetWidth - 8)))}px`;
|
|
72
|
+
}
|
|
73
|
+
return { close, item, sep, show };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A popover opened while the right button is still down closes again on
|
|
77
|
+
// its release (light dismiss), so open it after the release.
|
|
78
|
+
export function onRightButton(event, open) {
|
|
79
|
+
if (event.buttons & 2) addEventListener('pointerup', () => setTimeout(open), { once: true });
|
|
80
|
+
else open();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function columnTable(h, config) {
|
|
84
|
+
const menu = columnPopover(h);
|
|
85
|
+
const ids = () => config.order?.() ?? [];
|
|
86
|
+
const widths = () => config.widths?.() ?? {};
|
|
87
|
+
|
|
88
|
+
function openMenu(column, event) {
|
|
89
|
+
const parts = [];
|
|
90
|
+
if (column) {
|
|
91
|
+
parts.push(...(config.extra?.(column, menu.item) ?? []));
|
|
92
|
+
if (!column.fixed) {
|
|
93
|
+
const order = ids();
|
|
94
|
+
parts.push(menu.item('arrow-left-to-line', 'Move to start', () => config.change({ columns: moveColumn(order, column.id, 0) })),
|
|
95
|
+
menu.item('arrow-right-to-line', 'Move to end', () => config.change({ columns: moveColumn(order, column.id, order.length) })));
|
|
96
|
+
if (config.hidden) parts.push(menu.sep(), menu.item('eye-off', 'Hide column', () => config.change({ columns: order.filter((id) => id !== column.id) })));
|
|
97
|
+
}
|
|
98
|
+
if (widths()[column.id] != null) {
|
|
99
|
+
parts.push(menu.item('move-horizontal', 'Reset width', () => config.change({ widths: withoutKey(widths(), column.id) })));
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
const hidden = config.hidden?.() ?? [];
|
|
103
|
+
parts.push(...hidden.map((c) => h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { menu.close(); config.change({ columns: [...ids(), c.id] }); } }, c.label)));
|
|
104
|
+
if (!hidden.length) parts.push(h('p', { class: 'fx-pop__head' }, 'Every column shows'));
|
|
105
|
+
}
|
|
106
|
+
if (parts.length) menu.show(parts, event);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function table({ columns, rows, lead = [], className = '' }) {
|
|
110
|
+
const saved = widths();
|
|
111
|
+
// Each column keeps its own width: never stretched to the page. As
|
|
112
|
+
// HubSpot's, the head and every row still run to the right edge: the
|
|
113
|
+
// end column (+ at its right) takes the space after the last column, so
|
|
114
|
+
// a row looks whole, not cut off. A resize changes the table only; the
|
|
115
|
+
// tools stay put.
|
|
116
|
+
const cols = columns.map((c) => h('col', { style: { width: `${widthOf(c, saved)}px` } }));
|
|
117
|
+
const leadCols = lead.map(() => h('col', { style: { width: `${LEAD_WIDTH}px` } }));
|
|
118
|
+
const endCol = h('col', { style: { width: `${TAIL_WIDTH}px` } });
|
|
119
|
+
const tail = config.hidden ? h('th', { class: 'fx-th-add' }, h('button', {
|
|
120
|
+
type: 'button', class: 'fx-th fx-th--add', 'aria-label': 'Show a column', title: 'Show a column', onclick: (event) => openMenu(null, event),
|
|
121
|
+
}, '+')) : h('th', { class: 'fx-th-end', 'aria-hidden': 'true' });
|
|
122
|
+
const heads = columns.map((c, n) => head(c, n));
|
|
123
|
+
const node = h('table', { class: `fx-table fx-table--sized ${className}`.trim() },
|
|
124
|
+
h('colgroup', {}, leadCols, cols, endCol),
|
|
125
|
+
h('thead', {}, h('tr', {}, lead, heads, tail)),
|
|
126
|
+
rows);
|
|
127
|
+
const box = h('div', { class: 'fx-table-scroll' }, node);
|
|
128
|
+
// The width the columns need; past the window the table scrolls
|
|
129
|
+
// sideways. A browser shares spare width among fixed columns, so the
|
|
130
|
+
// end column is sized here, and again when the window changes size.
|
|
131
|
+
let need = tableWidth(columns, saved, lead.length);
|
|
132
|
+
const fit = () => {
|
|
133
|
+
const end = endWidth(need, box.getBoundingClientRect?.().width ?? 0);
|
|
134
|
+
endCol.style.width = `${end}px`;
|
|
135
|
+
node.style.width = `${need - TAIL_WIDTH + end}px`;
|
|
136
|
+
};
|
|
137
|
+
fit();
|
|
138
|
+
if (typeof ResizeObserver === 'function') {
|
|
139
|
+
// Once the table has left the page (a redraw), stop watching.
|
|
140
|
+
let shown = false;
|
|
141
|
+
const watch = new ResizeObserver(() => {
|
|
142
|
+
if (box.isConnected) { shown = true; fit(); } else if (shown) watch.disconnect();
|
|
143
|
+
});
|
|
144
|
+
watch.observe(box);
|
|
145
|
+
}
|
|
146
|
+
return box;
|
|
147
|
+
|
|
148
|
+
function head(c, n) {
|
|
149
|
+
const button = h('button', {
|
|
150
|
+
type: 'button', class: c.fixed ? 'fx-th fx-th--fixed' : 'fx-th', 'aria-haspopup': 'menu',
|
|
151
|
+
title: c.fixed ? c.label : `${c.label}. Drag to move, click for the menu`,
|
|
152
|
+
onclick: (event) => { if (!dragged) openMenu(c, event); },
|
|
153
|
+
}, c.icon ? lucide(h, c.icon, c.label) : c.label);
|
|
154
|
+
const grip = h('span', {
|
|
155
|
+
class: 'fx-th-grip', role: 'separator', tabindex: '0', 'aria-orientation': 'vertical',
|
|
156
|
+
'aria-label': `${c.label} width`, title: 'Drag to resize. Double-click to reset',
|
|
157
|
+
'aria-valuemin': String(minWidth(c.kind)), 'aria-valuemax': String(MAX_WIDTH), 'aria-valuenow': String(widthOf(c, widths())),
|
|
158
|
+
});
|
|
159
|
+
const th = h('th', {
|
|
160
|
+
class: [c.num ? 'fx-num' : null, c.fixed ? null : 'fx-th--drag'].filter(Boolean).join(' ') || null,
|
|
161
|
+
'data-col': c.id,
|
|
162
|
+
oncontextmenu: (event) => { event.preventDefault(); onRightButton(event, () => openMenu(c, event)); },
|
|
163
|
+
}, h('span', { class: 'fx-th-cell' }, button, c.sort?.() ?? null), grip);
|
|
164
|
+
resizer(grip, c, n);
|
|
165
|
+
if (!c.fixed) dragger(button, th, c);
|
|
166
|
+
return th;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Drag the edge: the column follows the pointer, kept to its minimum;
|
|
170
|
+
// the width saves on release. Arrow keys step it; a double-click resets.
|
|
171
|
+
function resizer(grip, c, n) {
|
|
172
|
+
const col = cols[n];
|
|
173
|
+
const set = (px) => {
|
|
174
|
+
const w = Math.round(Math.min(MAX_WIDTH, Math.max(minWidth(c.kind), px)));
|
|
175
|
+
col.style.width = `${w}px`;
|
|
176
|
+
grip.setAttribute('aria-valuenow', String(w));
|
|
177
|
+
return w;
|
|
178
|
+
};
|
|
179
|
+
const save = (w) => config.change({ widths: { ...widths(), [c.id]: w } });
|
|
180
|
+
grip.addEventListener('pointerdown', (event) => {
|
|
181
|
+
if (event.button !== 0) return;
|
|
182
|
+
event.preventDefault();
|
|
183
|
+
event.stopPropagation();
|
|
184
|
+
grip.setPointerCapture(event.pointerId);
|
|
185
|
+
const from = event.clientX;
|
|
186
|
+
const start = col.getBoundingClientRect().width || widthOf(c, widths());
|
|
187
|
+
const base = need;
|
|
188
|
+
let w = start;
|
|
189
|
+
node.dataset.resizing = '';
|
|
190
|
+
const move = (e) => {
|
|
191
|
+
w = set(start + e.clientX - from);
|
|
192
|
+
need = base + w - start;
|
|
193
|
+
fit();
|
|
194
|
+
};
|
|
195
|
+
const up = () => {
|
|
196
|
+
grip.removeEventListener('pointermove', move);
|
|
197
|
+
delete node.dataset.resizing;
|
|
198
|
+
if (Math.round(w) !== Math.round(start)) save(w);
|
|
199
|
+
};
|
|
200
|
+
grip.addEventListener('pointermove', move);
|
|
201
|
+
grip.addEventListener('pointerup', up, { once: true });
|
|
202
|
+
grip.addEventListener('pointercancel', up, { once: true });
|
|
203
|
+
});
|
|
204
|
+
grip.addEventListener('click', (event) => event.stopPropagation());
|
|
205
|
+
grip.addEventListener('dblclick', (event) => {
|
|
206
|
+
event.stopPropagation();
|
|
207
|
+
config.change({ widths: withoutKey(widths(), c.id) });
|
|
208
|
+
});
|
|
209
|
+
grip.addEventListener('keydown', (event) => {
|
|
210
|
+
const step = event.key === 'ArrowRight' ? 16 : event.key === 'ArrowLeft' ? -16 : 0;
|
|
211
|
+
if (!step) return;
|
|
212
|
+
event.preventDefault();
|
|
213
|
+
save(set(widthOf(c, widths()) + step));
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Drag a head: past a few pixels it lifts, a line shows where it lands,
|
|
219
|
+
// and the release moves it there. A click without a drag opens the menu.
|
|
220
|
+
let dragged = false;
|
|
221
|
+
function dragger(button, th, c) {
|
|
222
|
+
button.addEventListener('pointerdown', (event) => {
|
|
223
|
+
if (event.button !== 0) return;
|
|
224
|
+
const row = th.parentElement;
|
|
225
|
+
const from = event.clientX;
|
|
226
|
+
let target = null;
|
|
227
|
+
dragged = false;
|
|
228
|
+
const heads = () => [...row.querySelectorAll('th.fx-th--drag')];
|
|
229
|
+
const clear = () => { for (const other of row.children) other.removeAttribute('data-drop'); };
|
|
230
|
+
const move = (e) => {
|
|
231
|
+
if (!dragged && Math.abs(e.clientX - from) < 6) return;
|
|
232
|
+
if (!dragged) {
|
|
233
|
+
dragged = true;
|
|
234
|
+
button.setPointerCapture(event.pointerId);
|
|
235
|
+
th.dataset.dragging = '';
|
|
236
|
+
}
|
|
237
|
+
clear();
|
|
238
|
+
target = null;
|
|
239
|
+
for (const other of heads()) {
|
|
240
|
+
const box = other.getBoundingClientRect();
|
|
241
|
+
if (e.clientX >= box.left && e.clientX < box.right && other !== th) {
|
|
242
|
+
const side = e.clientX < box.left + box.width / 2 ? 'before' : 'after';
|
|
243
|
+
other.dataset.drop = side;
|
|
244
|
+
target = { id: other.dataset.col, side };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
const up = () => {
|
|
249
|
+
removeEventListener('pointermove', move);
|
|
250
|
+
delete th.dataset.dragging;
|
|
251
|
+
clear();
|
|
252
|
+
if (dragged && target) {
|
|
253
|
+
const order = ids().filter((id) => id !== c.id);
|
|
254
|
+
const at = order.indexOf(target.id) + (target.side === 'after' ? 1 : 0);
|
|
255
|
+
config.change({ columns: moveColumn(ids(), c.id, at) });
|
|
256
|
+
}
|
|
257
|
+
// The click that follows a drag does not open the menu.
|
|
258
|
+
setTimeout(() => { dragged = false; });
|
|
259
|
+
};
|
|
260
|
+
addEventListener('pointermove', move);
|
|
261
|
+
addEventListener('pointerup', up, { once: true });
|
|
262
|
+
addEventListener('pointercancel', up, { once: true });
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return { table, menu, openMenu };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function withoutKey(object, key) {
|
|
270
|
+
const { [key]: _gone, ...rest } = object;
|
|
271
|
+
return rest;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Saves a page's view with the person's account (PUT /api/me/views/:page)
|
|
275
|
+
// shortly after a change, so a few quick changes make one request.
|
|
276
|
+
export function viewSaver(ui, page) {
|
|
277
|
+
let timer = null;
|
|
278
|
+
return (view) => {
|
|
279
|
+
if (ui.me) ui.me.views = { ...ui.me.views, [page]: view };
|
|
280
|
+
clearTimeout(timer);
|
|
281
|
+
timer = setTimeout(() => {
|
|
282
|
+
ui.flow(`/api/me/views/${page}`, { method: 'PUT', body: JSON.stringify(view) })
|
|
283
|
+
.catch(() => ui.toast?.('Flow could not save your view. It applies on this page only.', 'danger'));
|
|
284
|
+
}, 500);
|
|
285
|
+
};
|
|
286
|
+
}
|