@arcforgelabs/flow 2026.10.46 → 2026.10.48
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/VISION.md +10 -10
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/invoicing.js +89 -22
- package/server/auth/people.js +3 -1
- package/server/auth/routes.js +4 -1
- package/server/core/contacts.js +160 -0
- package/server/core/directory.js +4 -9
- package/server/core/invoice-rules.js +34 -4
- package/server/core/invoices.js +93 -1
- package/server/core/invoicing-rules.js +20 -3
- package/server/core/job-board.js +1 -1
- package/server/core/sending.js +141 -25
- package/server/db/doctor.js +1 -1
- package/server/db/index.js +19 -0
- package/server/db/migrations/0021_flow_schema.sql +68 -0
- package/server/db/migrations/0022_contacts.sql +11 -0
- package/server/db/migrations/meta/0021_snapshot.json +4377 -0
- package/server/db/migrations/meta/0022_snapshot.json +4446 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +15 -0
- package/server/integrations/port.js +2 -0
- package/server/integrations/schema.js +3 -2
- package/server/integrations/sentry/index.js +14 -7
- package/server/integrations/setup.js +1 -0
- package/server/integrations/xero/catalogue.js +82 -14
- package/server/integrations/xero/client.js +37 -0
- package/server/integrations/xero/create-contact.js +126 -0
- package/server/integrations/xero/delete-invoice.js +104 -0
- package/server/integrations/xero/drafts.js +19 -8
- package/server/integrations/xero/index.js +9 -0
- package/server/integrations/xero/migrations/0011_xero_invoice_deleted.sql +11 -0
- package/server/integrations/xero/migrations/0012_xero_contact_details.sql +16 -0
- package/server/integrations/xero/migrations/meta/_journal.json +14 -0
- package/server/integrations/xero/routes.js +21 -0
- package/server/integrations/xero/settings.js +3 -0
- package/server/ui/alerts.js +2 -1
- package/server/ui/app.js +113 -28
- package/server/ui/bulk-bar.js +18 -5
- package/server/ui/columns.js +5 -2
- package/server/ui/contacts.js +260 -0
- package/server/ui/custom-fields.js +22 -7
- package/server/ui/flow.css +275 -24
- package/server/ui/invoicing.js +374 -70
- package/server/ui/job-board.js +10 -0
- package/server/ui/job-change.js +4 -3
- package/server/ui/job-view.js +3 -1
- package/server/ui/jobs.js +150 -19
- package/server/ui/kit.js +220 -45
- package/server/ui/list-views.js +50 -10
- package/server/ui/mobile.js +729 -0
- package/server/ui/monitoring.js +51 -0
- package/server/ui/nav-pins.js +44 -0
- package/server/ui/pager.js +57 -10
- package/server/ui/price-list.js +1 -1
- package/server/ui/price-reconcile.js +12 -12
- package/server/ui/reconcile.js +18 -17
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +2 -1
- package/server/ui/sidecar.js +106 -6
- package/server/ui/view-bar.js +22 -11
- package/server/ui/xero-import.js +48 -19
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// Contacts (/contacts, /contacts/<id>). Flow's own customers and suppliers
|
|
2
|
+
// (server/core/contacts.js, docs/CONTACTS.md). The list and the record work
|
|
3
|
+
// with no integration on. When Xero is connected, the record says whether
|
|
4
|
+
// that contact is in Xero and, for an owner or admin, offers Create.
|
|
5
|
+
import { initialsOf } from '/assets/ui/person.js';
|
|
6
|
+
|
|
7
|
+
const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/CONTACTS.md';
|
|
8
|
+
const KINDS = [['customer', 'Customer'], ['supplier', 'Supplier']];
|
|
9
|
+
|
|
10
|
+
const field = (h, label, control) => h('label', { class: 'fx-field' }, h('span', { class: 'fx-label' }, label), control);
|
|
11
|
+
const input = (h, attrs) => h('input', { class: 'fx-input', autocomplete: 'off', ...attrs });
|
|
12
|
+
|
|
13
|
+
export async function renderContacts(main, ui, id) {
|
|
14
|
+
if (id) return renderContact(main, ui, id);
|
|
15
|
+
return renderContactList(main, ui);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function renderContactList(main, ui) {
|
|
19
|
+
const { h, flow, toast, notice, dialog, pageHeader, go, me } = ui;
|
|
20
|
+
const canEdit = ['owner', 'admin'].includes(me?.person.role);
|
|
21
|
+
const host = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
|
|
22
|
+
const search = input(h, { type: 'search', 'aria-label': 'Search', placeholder: 'Search', class: 'fx-input fx-contacts__search' });
|
|
23
|
+
const kind = h('select', { class: 'fx-select', 'aria-label': 'Kind' },
|
|
24
|
+
h('option', { value: 'all', selected: true }, 'All'),
|
|
25
|
+
...KINDS.map(([value, label]) => h('option', { value }, `${label}s`)));
|
|
26
|
+
const page = h('div', { class: 'fx-contacts' });
|
|
27
|
+
const add = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', hidden: !canEdit, onclick: () => createContact() }, 'New');
|
|
28
|
+
page.append(
|
|
29
|
+
pageHeader('Contacts', { help: { href: DOCS, label: 'How contacts work' }, actions: [add] }),
|
|
30
|
+
host,
|
|
31
|
+
);
|
|
32
|
+
main.append(page);
|
|
33
|
+
|
|
34
|
+
let rows = [];
|
|
35
|
+
const draw = () => {
|
|
36
|
+
const needle = search.value.trim().toLowerCase();
|
|
37
|
+
const shown = rows.filter((contact) => {
|
|
38
|
+
if (kind.value !== 'all' && contact.kind !== kind.value) return false;
|
|
39
|
+
if (!needle) return true;
|
|
40
|
+
return [contact.name, contact.email, contact.accountNumber].some((value) => String(value ?? '').toLowerCase().includes(needle));
|
|
41
|
+
});
|
|
42
|
+
const body = shown.length
|
|
43
|
+
? h('table', { class: 'fx-table fx-people' },
|
|
44
|
+
h('thead', { class: 'fx-sr' }, h('tr', {}, h('th', {}, 'Name'), h('th', {}, 'Kind'), h('th', {}, 'Account'))),
|
|
45
|
+
h('tbody', {}, shown.map((contact) => h('tr', {},
|
|
46
|
+
h('td', {}, h('a', { class: 'fx-person', href: `/contacts/${contact.id}`, onclick: go },
|
|
47
|
+
h('span', { class: 'fx-avatar', 'aria-hidden': 'true' }, initialsOf(contact.name)),
|
|
48
|
+
h('span', { class: 'fx-person__text' },
|
|
49
|
+
h('span', { class: 'fx-person__name' }, contact.name, contact.active ? null : h('span', { class: 'fx-tag' }, 'Inactive')),
|
|
50
|
+
contact.email ? h('span', { class: 'fx-person__email' }, contact.email) : null))),
|
|
51
|
+
h('td', {}, KINDS.find(([value]) => value === contact.kind)?.[1] ?? contact.kind),
|
|
52
|
+
h('td', {}, contact.accountNumber ?? '')))))
|
|
53
|
+
: h('p', { class: 'fx-empty' }, rows.length ? 'Nothing matches.' : 'No contacts.');
|
|
54
|
+
host.replaceChildren(
|
|
55
|
+
h('div', { class: 'fx-contacts__tools' }, search, kind),
|
|
56
|
+
body,
|
|
57
|
+
);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
search.addEventListener('input', draw);
|
|
61
|
+
kind.addEventListener('change', draw);
|
|
62
|
+
|
|
63
|
+
async function createContact() {
|
|
64
|
+
const name = input(h, { name: 'name', required: true, 'aria-label': 'Name' });
|
|
65
|
+
const kindInput = h('select', { class: 'fx-select', name: 'kind', 'aria-label': 'Kind' },
|
|
66
|
+
...KINDS.map(([value, label]) => h('option', { value, selected: value === 'customer' }, label)));
|
|
67
|
+
const email = input(h, { name: 'email', type: 'email', 'aria-label': 'Email' });
|
|
68
|
+
const confirmed = await dialog({
|
|
69
|
+
title: 'New contact',
|
|
70
|
+
confirm: 'Create',
|
|
71
|
+
body: h('div', { class: 'fx-fields' },
|
|
72
|
+
field(h, 'Name', name),
|
|
73
|
+
field(h, 'Kind', kindInput),
|
|
74
|
+
field(h, 'Email', email)),
|
|
75
|
+
});
|
|
76
|
+
if (!confirmed) return;
|
|
77
|
+
try {
|
|
78
|
+
const body = await flow('/api/invoicing/contacts', {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
body: JSON.stringify({
|
|
81
|
+
name: String(confirmed.get('name') ?? ''),
|
|
82
|
+
kind: String(confirmed.get('kind') ?? 'customer'),
|
|
83
|
+
email: String(confirmed.get('email') ?? '').trim() || null,
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
go(`/contacts/${body.contact.id}`);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
toast(error.message, 'danger');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
({ contacts: rows } = await flow('/api/invoicing/contacts?kind=all&active=all&limit=200'));
|
|
94
|
+
draw();
|
|
95
|
+
} catch (error) {
|
|
96
|
+
host.replaceChildren(notice(error.message, 'danger'));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function renderContact(main, ui, id) {
|
|
101
|
+
const { h, flow, toast, notice, pageHeader, crumbs, me } = ui;
|
|
102
|
+
const canEdit = ['owner', 'admin'].includes(me?.person.role);
|
|
103
|
+
const page = h('div', { class: 'fx-contacts' }, h('div', { class: 'fx-skeleton' }));
|
|
104
|
+
main.append(page);
|
|
105
|
+
let contact;
|
|
106
|
+
try {
|
|
107
|
+
({ contact } = await flow(`/api/invoicing/contacts/${id}`));
|
|
108
|
+
} catch (error) {
|
|
109
|
+
page.replaceChildren(notice(error.message, 'danger'));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const values = {
|
|
114
|
+
name: input(h, { value: contact.name ?? '' }),
|
|
115
|
+
kind: h('select', { class: 'fx-select', 'aria-label': 'Kind' },
|
|
116
|
+
...KINDS.map(([value, label]) => h('option', { value, selected: value === contact.kind }, label))),
|
|
117
|
+
firstName: input(h, { value: contact.firstName ?? '' }),
|
|
118
|
+
lastName: input(h, { value: contact.lastName ?? '' }),
|
|
119
|
+
email: input(h, { type: 'email', value: contact.email ?? '' }),
|
|
120
|
+
phone: input(h, { value: contact.phone ?? '' }),
|
|
121
|
+
mobile: input(h, { value: contact.mobile ?? '' }),
|
|
122
|
+
accountNumber: input(h, { value: contact.accountNumber ?? '' }),
|
|
123
|
+
taxNumber: input(h, { value: contact.taxNumber ?? '' }),
|
|
124
|
+
taxRate: input(h, { value: contact.taxRate ?? '' }),
|
|
125
|
+
};
|
|
126
|
+
const address = contact.address ?? {};
|
|
127
|
+
const addressInputs = {
|
|
128
|
+
line1: input(h, { value: address.line1 ?? '', 'aria-label': 'Address line 1' }),
|
|
129
|
+
line2: input(h, { value: address.line2 ?? '', 'aria-label': 'Address line 2' }),
|
|
130
|
+
city: input(h, { value: address.city ?? '', 'aria-label': 'City' }),
|
|
131
|
+
region: input(h, { value: address.region ?? '', 'aria-label': 'Region' }),
|
|
132
|
+
postalCode: input(h, { value: address.postalCode ?? '', 'aria-label': 'Postal code' }),
|
|
133
|
+
country: input(h, { value: address.country ?? '', 'aria-label': 'Country' }),
|
|
134
|
+
};
|
|
135
|
+
const practitioners = h('textarea', {
|
|
136
|
+
class: 'fx-input', rows: '4', 'aria-label': 'Practitioners',
|
|
137
|
+
}, (contact.practitioners ?? []).map((person) => person.name).join('\n'));
|
|
138
|
+
const active = h('input', { type: 'checkbox', checked: contact.active });
|
|
139
|
+
for (const control of [...Object.values(values), ...Object.values(addressInputs), practitioners, active]) {
|
|
140
|
+
if (!canEdit) control.disabled = true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const save = h('button', {
|
|
144
|
+
class: 'fx-btn fx-btn--primary', type: 'button', hidden: !canEdit,
|
|
145
|
+
onclick: async () => {
|
|
146
|
+
const addressBody = Object.fromEntries(Object.entries(addressInputs).map(([key, control]) => [key, control.value.trim()]));
|
|
147
|
+
const people = practitioners.value.split('\n').map((line) => line.trim()).filter(Boolean).map((name) => ({ name }));
|
|
148
|
+
save.disabled = true;
|
|
149
|
+
try {
|
|
150
|
+
const body = await flow(`/api/invoicing/contacts/${id}`, {
|
|
151
|
+
method: 'PATCH',
|
|
152
|
+
body: JSON.stringify({
|
|
153
|
+
name: values.name.value,
|
|
154
|
+
kind: values.kind.value,
|
|
155
|
+
firstName: values.firstName.value,
|
|
156
|
+
lastName: values.lastName.value,
|
|
157
|
+
email: values.email.value,
|
|
158
|
+
phone: values.phone.value,
|
|
159
|
+
mobile: values.mobile.value,
|
|
160
|
+
accountNumber: values.accountNumber.value,
|
|
161
|
+
taxNumber: values.taxNumber.value,
|
|
162
|
+
taxRate: values.taxRate.value,
|
|
163
|
+
address: Object.values(addressBody).some(Boolean) ? addressBody : null,
|
|
164
|
+
practitioners: people,
|
|
165
|
+
active: active.checked,
|
|
166
|
+
}),
|
|
167
|
+
});
|
|
168
|
+
toast('Saved.', 'success');
|
|
169
|
+
contact = body.contact;
|
|
170
|
+
} catch (error) {
|
|
171
|
+
toast(error.message, 'danger');
|
|
172
|
+
} finally {
|
|
173
|
+
save.disabled = false;
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
}, 'Save');
|
|
177
|
+
|
|
178
|
+
const xero = h('div');
|
|
179
|
+
page.replaceChildren(
|
|
180
|
+
crumbs(['/contacts', 'Contacts'], contact.name),
|
|
181
|
+
pageHeader(contact.name, { actions: [save] }),
|
|
182
|
+
h('section', { class: 'fx-card' },
|
|
183
|
+
h('div', { class: 'fx-contacts__grid' },
|
|
184
|
+
field(h, 'Name', values.name),
|
|
185
|
+
field(h, 'Kind', values.kind),
|
|
186
|
+
field(h, 'First name', values.firstName),
|
|
187
|
+
field(h, 'Last name', values.lastName),
|
|
188
|
+
field(h, 'Email', values.email),
|
|
189
|
+
field(h, 'Phone', values.phone),
|
|
190
|
+
field(h, 'Mobile', values.mobile),
|
|
191
|
+
field(h, 'Account', values.accountNumber),
|
|
192
|
+
field(h, 'Tax number', values.taxNumber),
|
|
193
|
+
field(h, 'Tax rate', values.taxRate),
|
|
194
|
+
h('div', { class: 'fx-contacts__wide fx-fields' },
|
|
195
|
+
field(h, 'Address', addressInputs.line1),
|
|
196
|
+
addressInputs.line2,
|
|
197
|
+
h('div', { class: 'fx-contacts__grid' },
|
|
198
|
+
field(h, 'City', addressInputs.city),
|
|
199
|
+
field(h, 'Region', addressInputs.region),
|
|
200
|
+
field(h, 'Postal code', addressInputs.postalCode),
|
|
201
|
+
field(h, 'Country', addressInputs.country))),
|
|
202
|
+
h('label', { class: 'fx-field fx-contacts__wide' },
|
|
203
|
+
h('span', { class: 'fx-label' }, 'Practitioners'),
|
|
204
|
+
practitioners),
|
|
205
|
+
h('label', { class: 'fx-check' }, active, 'Active'))),
|
|
206
|
+
xero,
|
|
207
|
+
);
|
|
208
|
+
void loadXero(ui, contact, canEdit, xero);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function loadXero(ui, contact, canEdit, host) {
|
|
212
|
+
const { h, flow, toast, notice } = ui;
|
|
213
|
+
let context;
|
|
214
|
+
try {
|
|
215
|
+
context = await flow('/api/invoicing/context');
|
|
216
|
+
} catch {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const organisationId = context.organisations?.[0]?.id;
|
|
220
|
+
if (!organisationId) return;
|
|
221
|
+
let match;
|
|
222
|
+
try {
|
|
223
|
+
match = await flow(`/api/xero/contacts/match?organisationId=${encodeURIComponent(organisationId)}&contactId=${encodeURIComponent(contact.id)}`);
|
|
224
|
+
} catch {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (match.state === 'off') return;
|
|
228
|
+
const create = match.offerCreate && canEdit
|
|
229
|
+
? h('button', {
|
|
230
|
+
class: 'fx-btn fx-btn--primary', type: 'button', title: 'Create this client in Xero',
|
|
231
|
+
onclick: async (event) => {
|
|
232
|
+
const button = event.currentTarget;
|
|
233
|
+
const confirmed = await ui.dialog({
|
|
234
|
+
title: `Create ${contact.name} in Xero?`,
|
|
235
|
+
body: h('p', {}, 'Flow adds this client to Xero from the contact.'),
|
|
236
|
+
confirm: 'Create',
|
|
237
|
+
});
|
|
238
|
+
if (!confirmed) return;
|
|
239
|
+
button.disabled = true;
|
|
240
|
+
try {
|
|
241
|
+
await flow('/api/xero/contacts', { method: 'POST', body: JSON.stringify({ organisationId, contactId: contact.id }) });
|
|
242
|
+
toast(`${contact.name} is in Xero.`, 'success');
|
|
243
|
+
host.replaceChildren(h('section', { class: 'fx-card' }, notice(`${contact.name} is in Xero.`, 'success')));
|
|
244
|
+
} catch (error) {
|
|
245
|
+
toast(error.message, error.status === 429 ? 'info' : 'danger');
|
|
246
|
+
if (error.retryAfterMs) ui.holdButton(button, error.retryAfterMs);
|
|
247
|
+
else button.disabled = false;
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
}, 'Create')
|
|
251
|
+
: null;
|
|
252
|
+
const text = match.state === 'matched'
|
|
253
|
+
? `In Xero${match.name ? ` as ${match.name}` : ''}.`
|
|
254
|
+
: match.state === 'ambiguous'
|
|
255
|
+
? `More than one Xero contact is named "${contact.name}".`
|
|
256
|
+
: `No Xero contact is named "${contact.name}".`;
|
|
257
|
+
host.replaceChildren(h('section', { class: 'fx-card fx-offer' },
|
|
258
|
+
notice(text, match.state === 'matched' ? 'success' : 'danger', match.state === 'matched' ? 'Xero.' : 'Xero.'),
|
|
259
|
+
create));
|
|
260
|
+
}
|
|
@@ -15,6 +15,21 @@
|
|
|
15
15
|
|
|
16
16
|
import { orderStatuses, statusRank } from '/assets/ui/job-board.js';
|
|
17
17
|
import { sidecar } from '/assets/ui/sidecar.js';
|
|
18
|
+
import { assignPhoneSlots, phoneSlot } from './mobile.js';
|
|
19
|
+
|
|
20
|
+
// The manager row uses the same phone slots as a column list.
|
|
21
|
+
const FIELD_SLOTS = assignPhoneSlots([
|
|
22
|
+
{ id: 'name', kind: 'text' },
|
|
23
|
+
{ id: 'type', kind: 'text' },
|
|
24
|
+
{ id: 'by', kind: 'person' },
|
|
25
|
+
{ id: 'date', kind: 'date' },
|
|
26
|
+
{ id: 'where', kind: 'text' },
|
|
27
|
+
{ id: 'menu', kind: 'text', phone: 'end' },
|
|
28
|
+
]);
|
|
29
|
+
const fieldSlot = (id) => {
|
|
30
|
+
const slot = phoneSlot(FIELD_SLOTS.find((column) => column.id === id));
|
|
31
|
+
return slot ? { 'data-phone-slot': slot } : {};
|
|
32
|
+
};
|
|
18
33
|
|
|
19
34
|
const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/CUSTOM_FIELDS.md';
|
|
20
35
|
const TYPE_LABELS = {
|
|
@@ -395,12 +410,12 @@ export async function renderCustomFields(main, ui) {
|
|
|
395
410
|
});
|
|
396
411
|
input.addEventListener('blur', () => { if (view.renaming === field.id) rename(field, input.value); });
|
|
397
412
|
queueMicrotask(() => { input.focus(); input.select(); });
|
|
398
|
-
return h('div', { class: 'fx-cfm__name', role: 'cell' }, input);
|
|
413
|
+
return h('div', { class: 'fx-cfm__name', role: 'cell', ...fieldSlot('name') }, input);
|
|
399
414
|
}
|
|
400
415
|
const editButton = field.archived || disabled || field.source ? null
|
|
401
416
|
: h('button', { class: 'fx-cfm__edit', type: 'button', onclick: () => edit(field) }, 'Edit', lucide(h, 'panel-right'));
|
|
402
417
|
const viewButton = field.source ? h('button', { class: 'fx-cfm__edit', type: 'button', onclick: () => edit(field) }, 'View', lucide(h, 'panel-right')) : null;
|
|
403
|
-
return h('div', { class: 'fx-cfm__name', role: 'cell' },
|
|
418
|
+
return h('div', { class: 'fx-cfm__name', role: 'cell', ...fieldSlot('name') },
|
|
404
419
|
h('div', { class: 'fx-cfm__title' },
|
|
405
420
|
field.source ? lucide(h, 'lock', 'Read only') : null,
|
|
406
421
|
h('span', { class: 'fx-cfm__label', title: field.name }, field.name), editButton, viewButton));
|
|
@@ -418,11 +433,11 @@ export async function renderCustomFields(main, ui) {
|
|
|
418
433
|
});
|
|
419
434
|
return h('div', { class: 'fx-cfm__row', role: 'row', 'data-id': field.id, 'data-open': sideFor === field.id ? 'true' : null, 'data-archived': field.archived ? 'true' : null },
|
|
420
435
|
nameCell(field),
|
|
421
|
-
h('div', { class: 'fx-cfm__type', role: 'cell' }, picker ? [picker.button, picker.panel] : h('span', { class: 'fx-cfm__icon', 'data-color': color }, icon)),
|
|
422
|
-
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' }, '—')),
|
|
423
|
-
h('div', { class: 'fx-cfm__date', role: 'cell', title: field.source ? `${field.sourceLabel} connected` : null }, field.createdAt ? createdDay(field.createdAt) : h('span', { class: 'fx-dim' }, '—')),
|
|
424
|
-
h('div', { class: 'fx-cfm__where', role: 'cell' }, field.source ? `${field.sourceLabel} jobs` : 'Jobs'),
|
|
425
|
-
h('div', { class: 'fx-cfm__end', role: 'cell' }, field.source ? null : rowMenu(field, picker)));
|
|
436
|
+
h('div', { class: 'fx-cfm__type', role: 'cell', ...fieldSlot('type') }, picker ? [picker.button, picker.panel] : h('span', { class: 'fx-cfm__icon', 'data-color': color }, icon)),
|
|
437
|
+
h('div', { class: 'fx-cfm__by', role: 'cell', ...fieldSlot('by') }, by ? h('span', { class: 'fx-cfm__avatar', title: by }, field.source ? lucide(h, 'plug') : initials(by)) : h('span', { class: 'fx-dim' }, '—')),
|
|
438
|
+
h('div', { class: 'fx-cfm__date', role: 'cell', ...fieldSlot('date'), title: field.source ? `${field.sourceLabel} connected` : null }, field.createdAt ? createdDay(field.createdAt) : h('span', { class: 'fx-dim' }, '—')),
|
|
439
|
+
h('div', { class: 'fx-cfm__where', role: 'cell', ...fieldSlot('where') }, field.source ? `${field.sourceLabel} jobs` : 'Jobs'),
|
|
440
|
+
h('div', { class: 'fx-cfm__end', role: 'cell', ...fieldSlot('menu') }, field.source ? null : rowMenu(field, picker)));
|
|
426
441
|
}
|
|
427
442
|
|
|
428
443
|
// One group: a header that folds it, its rows, and "Create … field". A
|