create-astram-app 0.1.3 → 0.2.0
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/dist/add-frontend.js +107 -0
- package/dist/add-frontend.js.map +1 -0
- package/dist/index.js +122 -25
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/template/frontend/FRONTEND.md +92 -0
- package/template/frontend/_env.example +6 -0
- package/template/frontend/_gitignore +9 -0
- package/template/frontend/eslint.config.js +71 -0
- package/template/frontend/index.html +13 -0
- package/template/frontend/package.json +52 -0
- package/template/frontend/public/astram-logo-full.png +0 -0
- package/template/frontend/public/astram-logo-white.png +0 -0
- package/template/frontend/public/favicon.png +0 -0
- package/template/frontend/src/App.jsx +167 -0
- package/template/frontend/src/auth/AuthContext.jsx +338 -0
- package/template/frontend/src/components/ConfirmDialog.jsx +92 -0
- package/template/frontend/src/components/EntityFormModal.jsx +154 -0
- package/template/frontend/src/components/EntityList.jsx +480 -0
- package/template/frontend/src/components/PageHeader.jsx +54 -0
- package/template/frontend/src/components/access/CollapsibleGroup.jsx +34 -0
- package/template/frontend/src/components/ui/alert-dialog.tsx +175 -0
- package/template/frontend/src/components/ui/alert.tsx +83 -0
- package/template/frontend/src/components/ui/avatar.tsx +51 -0
- package/template/frontend/src/components/ui/badge.tsx +56 -0
- package/template/frontend/src/components/ui/button.tsx +67 -0
- package/template/frontend/src/components/ui/card.tsx +92 -0
- package/template/frontend/src/components/ui/checkbox.tsx +30 -0
- package/template/frontend/src/components/ui/date-field.tsx +47 -0
- package/template/frontend/src/components/ui/dialog.tsx +214 -0
- package/template/frontend/src/components/ui/dropdown-menu.tsx +255 -0
- package/template/frontend/src/components/ui/input-otp.tsx +81 -0
- package/template/frontend/src/components/ui/input.tsx +21 -0
- package/template/frontend/src/components/ui/label.tsx +46 -0
- package/template/frontend/src/components/ui/searchable-select.tsx +459 -0
- package/template/frontend/src/components/ui/select.tsx +199 -0
- package/template/frontend/src/components/ui/separator.tsx +26 -0
- package/template/frontend/src/components/ui/sheet.tsx +139 -0
- package/template/frontend/src/components/ui/skeleton.tsx +13 -0
- package/template/frontend/src/components/ui/sonner.tsx +23 -0
- package/template/frontend/src/components/ui/table.tsx +181 -0
- package/template/frontend/src/components/ui/tooltip.tsx +59 -0
- package/template/frontend/src/config/branding.js +80 -0
- package/template/frontend/src/hooks/useMobile.tsx +21 -0
- package/template/frontend/src/index.css +265 -0
- package/template/frontend/src/lib/utils.js +7 -0
- package/template/frontend/src/main.jsx +27 -0
- package/template/frontend/src/pages/Dashboard.jsx +127 -0
- package/template/frontend/src/pages/ForgotPassword.jsx +104 -0
- package/template/frontend/src/pages/Login.jsx +393 -0
- package/template/frontend/src/pages/ResetPassword.jsx +159 -0
- package/template/frontend/src/pages/access/AccessForm.jsx +416 -0
- package/template/frontend/src/pages/access/AccessPage.jsx +282 -0
- package/template/frontend/src/pages/access/RoleActionsMenu.jsx +42 -0
- package/template/frontend/src/pages/account/AccountLayout.jsx +229 -0
- package/template/frontend/src/pages/account/ChangePasswordPage.jsx +235 -0
- package/template/frontend/src/pages/account/ProfilePage.jsx +122 -0
- package/template/frontend/src/pages/account/ReenrollPage.jsx +98 -0
- package/template/frontend/src/pages/account/TwoFactorPage.jsx +833 -0
- package/template/frontend/src/pages/audit/AuditPage.jsx +268 -0
- package/template/frontend/src/pages/companies/CompaniesPage.jsx +232 -0
- package/template/frontend/src/pages/companies/CompanyForm.jsx +127 -0
- package/template/frontend/src/pages/roles/RoleForm.jsx +99 -0
- package/template/frontend/src/pages/roles/RolesPage.jsx +207 -0
- package/template/frontend/src/pages/users/AssignmentsEditor.jsx +193 -0
- package/template/frontend/src/pages/users/MembershipCard.jsx +303 -0
- package/template/frontend/src/pages/users/PostingRow.jsx +264 -0
- package/template/frontend/src/pages/users/UserForm.jsx +167 -0
- package/template/frontend/src/pages/users/UsersPage.jsx +232 -0
- package/template/frontend/src/platform/httpClient.js +125 -0
- package/template/frontend/src/platform/returnPathStore.js +43 -0
- package/template/frontend/src/platform/sessionStore.js +29 -0
- package/template/frontend/src/platform/shieldTransport.js +172 -0
- package/template/frontend/src/platform/shieldTransport.test.mjs +245 -0
- package/template/frontend/src/shell/AppShell.jsx +237 -0
- package/template/frontend/src/shell/Sidebar.jsx +257 -0
- package/template/frontend/src/shell/nav.js +90 -0
- package/template/frontend/src/theme/ThemeProvider.jsx +14 -0
- package/template/frontend/src/theme/ThemeToggle.jsx +44 -0
- package/template/frontend/tsconfig.json +22 -0
- package/template/frontend/vite.config.js +30 -0
- package/template/server/_env.example +27 -15
- package/template/server/package.json +7 -4
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MembershipCard — one company the user belongs to, with its multi-row posting editor.
|
|
3
|
+
* The card header shows the company NAME (resolved server-side), a "★ active" toggle (the
|
|
4
|
+
* login-company pointer — exactly one active per user, radio semantics client-side; the
|
|
5
|
+
* server is the real guard), and a Remove button (gated behind a themed ConfirmDialog).
|
|
6
|
+
* The body is a table of PostingRows + "add posting", saved per-card via an ATOMIC
|
|
7
|
+
* `replacePostings` (the full row set is submitted; the server diffs).
|
|
8
|
+
*
|
|
9
|
+
* Company-scoped dropdowns: this card loads ITS company's department + branch catalogs on
|
|
10
|
+
* mount (the parent may pass a cache to avoid refetching). The Role dropdown is the growable
|
|
11
|
+
* catalog — each PostingRow self-fetches it (server-paginated + searchable) via the injected
|
|
12
|
+
* `identity` client, so no global role list is threaded through here.
|
|
13
|
+
*
|
|
14
|
+
* ⛔ No raw UUIDs: the header renders `companyName`; posting dropdowns render names with
|
|
15
|
+
* uuid values. `membershipUuid` / `companyUuid` are used only as keys/params.
|
|
16
|
+
*
|
|
17
|
+
* Props:
|
|
18
|
+
* membership — { membershipUuid, companyUuid, companyName, isActive, status, postings }
|
|
19
|
+
* userUuid — the user this card belongs to (needed to activate — one-active invariant)
|
|
20
|
+
* token — access token
|
|
21
|
+
* identity — the shared identity client service (passed to PostingRow for the Role dropdown)
|
|
22
|
+
* onActivate() / onRemoved() / onSaved() — parent refresh hooks
|
|
23
|
+
*/
|
|
24
|
+
import { useEffect, useState } from 'react';
|
|
25
|
+
import { Loader2, Plus, Star, Trash2 } from 'lucide-react';
|
|
26
|
+
import { toast } from 'sonner';
|
|
27
|
+
import { Button } from '@/components/ui/button';
|
|
28
|
+
import { Badge } from '@/components/ui/badge';
|
|
29
|
+
import { Card } from '@/components/ui/card';
|
|
30
|
+
import ConfirmDialog from '@/components/ConfirmDialog';
|
|
31
|
+
import PostingRow from './PostingRow';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Map a server posting card → the editor's local row shape (a stable client key added).
|
|
35
|
+
* The server-resolved display NAMES (roleName / departmentName / branchName) ride along so
|
|
36
|
+
* the growable Role dropdown can render the selected name even before its option page loads
|
|
37
|
+
* (⛔ no raw uuid on the trigger).
|
|
38
|
+
*/
|
|
39
|
+
function toRow(p, i) {
|
|
40
|
+
return {
|
|
41
|
+
key: p.postingUuid || `new-${i}-${Math.random().toString(36).slice(2)}`,
|
|
42
|
+
roleUuid: p.roleUuid || '',
|
|
43
|
+
roleName: p.roleName || '',
|
|
44
|
+
departmentUuid: p.departmentUuid || null,
|
|
45
|
+
branchUuid: p.branchUuid || null,
|
|
46
|
+
effectiveFrom: p.effectiveFrom || null,
|
|
47
|
+
effectiveUntil: p.effectiveUntil || null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A blank posting row (a fresh client key so React tracks it distinctly). */
|
|
52
|
+
function blankRow() {
|
|
53
|
+
return {
|
|
54
|
+
key: `new-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
55
|
+
roleUuid: '',
|
|
56
|
+
roleName: '',
|
|
57
|
+
departmentUuid: null,
|
|
58
|
+
branchUuid: null,
|
|
59
|
+
effectiveFrom: null,
|
|
60
|
+
effectiveUntil: null,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Strip the client-only `key` and normalize a row into the API PostingInput shape. */
|
|
65
|
+
function toPostingInput(row) {
|
|
66
|
+
return {
|
|
67
|
+
roleUuid: row.roleUuid,
|
|
68
|
+
departmentUuid: row.departmentUuid || null,
|
|
69
|
+
branchUuid: row.branchUuid || null,
|
|
70
|
+
effectiveFrom: row.effectiveFrom || null,
|
|
71
|
+
effectiveUntil: row.effectiveUntil || null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export default function MembershipCard({
|
|
76
|
+
membership,
|
|
77
|
+
userUuid,
|
|
78
|
+
token,
|
|
79
|
+
identity,
|
|
80
|
+
onActivate,
|
|
81
|
+
onRemoved,
|
|
82
|
+
onSaved,
|
|
83
|
+
}) {
|
|
84
|
+
const { membershipUuid, companyUuid, companyName, isActive } = membership;
|
|
85
|
+
|
|
86
|
+
const [rows, setRows] = useState(() => (membership.postings || []).map(toRow));
|
|
87
|
+
const [departments, setDepartments] = useState([]);
|
|
88
|
+
const [branches, setBranches] = useState([]);
|
|
89
|
+
const [saving, setSaving] = useState(false);
|
|
90
|
+
const [activating, setActivating] = useState(false);
|
|
91
|
+
const [confirmRemove, setConfirmRemove] = useState(false);
|
|
92
|
+
const [removing, setRemoving] = useState(false);
|
|
93
|
+
|
|
94
|
+
// Re-seed local rows if the membership's postings change (e.g. after a save + reload).
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
setRows((membership.postings || []).map(toRow));
|
|
97
|
+
}, [membership.postings]);
|
|
98
|
+
|
|
99
|
+
// Load this company's department + branch catalogs (the company-scoped dropdown options).
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (!token || !companyUuid) return undefined;
|
|
102
|
+
let active = true;
|
|
103
|
+
(async () => {
|
|
104
|
+
const [dr, br] = await Promise.all([
|
|
105
|
+
identity.listDepartments(token, companyUuid),
|
|
106
|
+
identity.listBranches(token, companyUuid),
|
|
107
|
+
]);
|
|
108
|
+
if (!active) return;
|
|
109
|
+
setDepartments(dr.ok ? dr.rows || [] : []);
|
|
110
|
+
setBranches(br.ok ? br.rows || [] : []);
|
|
111
|
+
})();
|
|
112
|
+
return () => {
|
|
113
|
+
active = false;
|
|
114
|
+
};
|
|
115
|
+
}, [identity, token, companyUuid]);
|
|
116
|
+
|
|
117
|
+
const patchRow = (key, patch) =>
|
|
118
|
+
setRows((prev) => prev.map((r) => (r.key === key ? { ...r, ...patch } : r)));
|
|
119
|
+
|
|
120
|
+
const removeRow = (key) => setRows((prev) => prev.filter((r) => r.key !== key));
|
|
121
|
+
|
|
122
|
+
const addRow = () => setRows((prev) => [...prev, blankRow()]);
|
|
123
|
+
|
|
124
|
+
const onSave = async () => {
|
|
125
|
+
// Guard: every row must have a role (role is the required field of a posting).
|
|
126
|
+
const missingRole = rows.some((r) => !r.roleUuid);
|
|
127
|
+
if (missingRole) {
|
|
128
|
+
toast.error('Every posting needs a role.');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
setSaving(true);
|
|
132
|
+
const r = await identity.replacePostings(token, membershipUuid, rows.map(toPostingInput));
|
|
133
|
+
setSaving(false);
|
|
134
|
+
if (!r.ok) {
|
|
135
|
+
toast.error(r.error || 'Could not save the postings.');
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
toast.success('Assignments saved');
|
|
139
|
+
onSaved?.();
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const onToggleActive = async () => {
|
|
143
|
+
if (isActive) return; // already the active company — radio semantics, no-op.
|
|
144
|
+
setActivating(true);
|
|
145
|
+
const r = await identity.setMembershipActive(token, membershipUuid, userUuid);
|
|
146
|
+
setActivating(false);
|
|
147
|
+
if (!r.ok) {
|
|
148
|
+
toast.error(r.error || 'Could not activate this company.');
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
toast.success(`${companyName} is now the active company`);
|
|
152
|
+
onActivate?.();
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const onConfirmRemove = async () => {
|
|
156
|
+
setRemoving(true);
|
|
157
|
+
const r = await identity.removeMembership(token, membershipUuid);
|
|
158
|
+
setRemoving(false);
|
|
159
|
+
if (!r.ok) {
|
|
160
|
+
toast.error(r.error || 'Could not remove the membership.');
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
setConfirmRemove(false);
|
|
164
|
+
toast.success(`Removed from ${companyName}`);
|
|
165
|
+
onRemoved?.();
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<Card className="w-full min-w-0 gap-0 overflow-hidden py-0 shadow-sm">
|
|
170
|
+
{/* Header: company name + active toggle + remove. */}
|
|
171
|
+
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-3 py-2">
|
|
172
|
+
<div className="flex items-center gap-2">
|
|
173
|
+
<span className="font-medium text-foreground">{companyName || '(unknown company)'}</span>
|
|
174
|
+
{isActive && (
|
|
175
|
+
<Badge variant="success" className="gap-1">
|
|
176
|
+
<Star className="size-3 fill-current" />
|
|
177
|
+
active
|
|
178
|
+
</Badge>
|
|
179
|
+
)}
|
|
180
|
+
</div>
|
|
181
|
+
<div className="flex items-center gap-1">
|
|
182
|
+
<Button
|
|
183
|
+
type="button"
|
|
184
|
+
variant={isActive ? 'ghost' : 'soft'}
|
|
185
|
+
size="sm"
|
|
186
|
+
onClick={onToggleActive}
|
|
187
|
+
disabled={isActive || activating || saving}
|
|
188
|
+
title={isActive ? 'This is the active login company' : 'Make this the active login company'}
|
|
189
|
+
>
|
|
190
|
+
{activating ? (
|
|
191
|
+
<Loader2 className="size-4 animate-spin" />
|
|
192
|
+
) : (
|
|
193
|
+
<Star className={isActive ? 'size-4 fill-current' : 'size-4'} />
|
|
194
|
+
)}
|
|
195
|
+
{isActive ? 'Active' : 'Set active'}
|
|
196
|
+
</Button>
|
|
197
|
+
<Button
|
|
198
|
+
type="button"
|
|
199
|
+
variant="ghost"
|
|
200
|
+
size="sm"
|
|
201
|
+
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
202
|
+
onClick={() => setConfirmRemove(true)}
|
|
203
|
+
disabled={removing || saving}
|
|
204
|
+
>
|
|
205
|
+
<Trash2 className="size-4" />
|
|
206
|
+
Remove
|
|
207
|
+
</Button>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
{/* Body: the multi-row posting editor. `min-w-0` so the inner table wrapper's
|
|
212
|
+
`overflow-x-auto` scrolls the wide posting table rather than stretching the card. */}
|
|
213
|
+
<div className="min-w-0 px-3 py-2">
|
|
214
|
+
{rows.length === 0 ? (
|
|
215
|
+
<p className="py-2 text-sm text-muted-foreground">
|
|
216
|
+
No postings yet. Add a role (and optionally a department / branch / dates).
|
|
217
|
+
</p>
|
|
218
|
+
) : (
|
|
219
|
+
<>
|
|
220
|
+
{/* Desktop / tablet: the posting table (PostingRow renders <tr>s).
|
|
221
|
+
`w-max min-w-full`: the table shrinks-to-fit its natural content width
|
|
222
|
+
(so the `overflow-x-auto` wrapper SCROLLS it instead of the table painting
|
|
223
|
+
past the wrapper — a plain `w-full` table grows to its content and spills),
|
|
224
|
+
while `min-w-full` keeps it filling the wrapper when the content is narrow. */}
|
|
225
|
+
<div className="hidden overflow-x-auto sm:block">
|
|
226
|
+
<table className="w-max min-w-full border-collapse text-sm">
|
|
227
|
+
<thead>
|
|
228
|
+
<tr className="border-b border-border text-left">
|
|
229
|
+
{['Role', 'Department', 'Branch', 'From', 'Until'].map((h) => (
|
|
230
|
+
<th
|
|
231
|
+
key={h}
|
|
232
|
+
className="pb-1.5 pr-2 font-mono text-[0.66rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground"
|
|
233
|
+
>
|
|
234
|
+
{h}
|
|
235
|
+
</th>
|
|
236
|
+
))}
|
|
237
|
+
<th className="pb-1.5 font-medium" aria-label="Actions" />
|
|
238
|
+
</tr>
|
|
239
|
+
</thead>
|
|
240
|
+
<tbody>
|
|
241
|
+
{rows.map((row) => (
|
|
242
|
+
<PostingRow
|
|
243
|
+
key={row.key}
|
|
244
|
+
posting={row}
|
|
245
|
+
departments={departments}
|
|
246
|
+
branches={branches}
|
|
247
|
+
token={token}
|
|
248
|
+
identity={identity}
|
|
249
|
+
onChange={(patch) => patchRow(row.key, patch)}
|
|
250
|
+
onRemove={() => removeRow(row.key)}
|
|
251
|
+
disabled={saving}
|
|
252
|
+
layout="row"
|
|
253
|
+
/>
|
|
254
|
+
))}
|
|
255
|
+
</tbody>
|
|
256
|
+
</table>
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
{/* Mobile (≤640px): each posting reflows to a stacked mini-card. */}
|
|
260
|
+
<div className="space-y-3 sm:hidden">
|
|
261
|
+
{rows.map((row) => (
|
|
262
|
+
<PostingRow
|
|
263
|
+
key={row.key}
|
|
264
|
+
posting={row}
|
|
265
|
+
departments={departments}
|
|
266
|
+
branches={branches}
|
|
267
|
+
token={token}
|
|
268
|
+
identity={identity}
|
|
269
|
+
onChange={(patch) => patchRow(row.key, patch)}
|
|
270
|
+
onRemove={() => removeRow(row.key)}
|
|
271
|
+
disabled={saving}
|
|
272
|
+
layout="card"
|
|
273
|
+
/>
|
|
274
|
+
))}
|
|
275
|
+
</div>
|
|
276
|
+
</>
|
|
277
|
+
)}
|
|
278
|
+
|
|
279
|
+
<div className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
280
|
+
<Button type="button" variant="ghost" size="sm" onClick={addRow} disabled={saving}>
|
|
281
|
+
<Plus className="size-4" />
|
|
282
|
+
Add posting
|
|
283
|
+
</Button>
|
|
284
|
+
<Button type="button" size="sm" onClick={onSave} disabled={saving}>
|
|
285
|
+
{saving && <Loader2 className="size-4 animate-spin" />}
|
|
286
|
+
Save assignments
|
|
287
|
+
</Button>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
<ConfirmDialog
|
|
292
|
+
open={confirmRemove}
|
|
293
|
+
title={`Remove from ${companyName || 'this company'}?`}
|
|
294
|
+
message="This removes the user's membership in this company and all its postings. This cannot be undone."
|
|
295
|
+
confirmLabel="Remove"
|
|
296
|
+
destructive
|
|
297
|
+
busy={removing}
|
|
298
|
+
onConfirm={onConfirmRemove}
|
|
299
|
+
onCancel={() => setConfirmRemove(false)}
|
|
300
|
+
/>
|
|
301
|
+
</Card>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostingRow — one editable posting inside a MembershipCard: a bundled
|
|
3
|
+
* `(role, department?, branch?, from?, until?)` assignment. The dropdowns show display
|
|
4
|
+
* NAMES; their VALUES are the uuids (kept in the row's local edit state, never rendered
|
|
5
|
+
* as text). Department, Branch, From, and Until are all NULLABLE — each dropdown carries
|
|
6
|
+
* an explicit "— none —" option and the date inputs may be left blank.
|
|
7
|
+
*
|
|
8
|
+
* Controls are the shared OCA primitives: the three dropdowns use `ui/searchable-select.tsx`
|
|
9
|
+
* (the searchable + paginated dynamic dropdown) and the two date inputs use `ui/date-field.tsx`.
|
|
10
|
+
* Role is GROWABLE — it self-fetches the role catalog server-paginated + searchable via the
|
|
11
|
+
* injected `identity` client. Department and Branch are small, per-company catalogs (passed in
|
|
12
|
+
* from the card) rendered with the SAME control in CLIENT-FILTER mode (fetched once, filtered
|
|
13
|
+
* locally), so the look is consistent while avoiding per-keystroke round-trips.
|
|
14
|
+
*
|
|
15
|
+
* A row whose effective window is not "now" is flagged with a tonal Badge:
|
|
16
|
+
* - "scheduled" — effectiveFrom is in the future (info) — not yet active.
|
|
17
|
+
* - "expired" — effectiveUntil is in the past (warning) — no longer active.
|
|
18
|
+
* The chip is a reviewer cue only; the row is still saved as-is (time bounds are honored
|
|
19
|
+
* by the login resolver server-side).
|
|
20
|
+
*
|
|
21
|
+
* Responsive: `layout="row"` renders a desktop `<tr>` (used inside the MembershipCard
|
|
22
|
+
* table). `layout="card"` renders a stacked mini-card of labeled fields for ≤640px, so
|
|
23
|
+
* the posting stays readable without horizontal scroll.
|
|
24
|
+
*
|
|
25
|
+
* ⛔ No raw UUIDs: Role/Department/Branch dropdowns render display NAMES; their values are
|
|
26
|
+
* uuids. The Role trigger shows `posting.roleName` (server-resolved) so the name is visible
|
|
27
|
+
* even before its option page loads; nothing renders a uuid as visible text.
|
|
28
|
+
*
|
|
29
|
+
* Props:
|
|
30
|
+
* posting — { roleUuid, roleName, departmentUuid, branchUuid, effectiveFrom, effectiveUntil }
|
|
31
|
+
* departments / branches — [{ uuid, name }] (this company's catalogs; may be empty)
|
|
32
|
+
* token — access token (for the Role dropdown's self-fetch)
|
|
33
|
+
* identity — the shared identity client service (for `listRoles`)
|
|
34
|
+
* onChange(patch) — merge a field change into this row
|
|
35
|
+
* onRemove() — drop this row
|
|
36
|
+
* disabled — save in flight
|
|
37
|
+
* layout — 'row' (desktop table) | 'card' (mobile mini-card)
|
|
38
|
+
*/
|
|
39
|
+
import { useCallback } from 'react';
|
|
40
|
+
import { X } from 'lucide-react';
|
|
41
|
+
import { Button } from '@/components/ui/button';
|
|
42
|
+
import { Badge } from '@/components/ui/badge';
|
|
43
|
+
import { Label } from '@/components/ui/label';
|
|
44
|
+
import { DateField } from '@/components/ui/date-field';
|
|
45
|
+
import { SearchableSelect } from '@/components/ui/searchable-select';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Classify a posting's effective window relative to now:
|
|
49
|
+
* 'scheduled' (from in the future) | 'expired' (until in the past) | null (in effect).
|
|
50
|
+
* Mirrors the resolver's in-effect test: from <= now AND (until is null OR until > now).
|
|
51
|
+
*/
|
|
52
|
+
function windowState(effectiveFrom, effectiveUntil) {
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
if (effectiveFrom) {
|
|
55
|
+
const fromMs = new Date(effectiveFrom).getTime();
|
|
56
|
+
if (!Number.isNaN(fromMs) && fromMs > now) return 'scheduled';
|
|
57
|
+
}
|
|
58
|
+
if (effectiveUntil) {
|
|
59
|
+
const untilMs = new Date(effectiveUntil).getTime();
|
|
60
|
+
if (!Number.isNaN(untilMs) && untilMs <= now) return 'expired';
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A date string (or '') for a native date input, from a stored value. */
|
|
66
|
+
function toDateInput(value) {
|
|
67
|
+
if (!value) return '';
|
|
68
|
+
// Stored values may be full timestamps ("2026-07-13 09:00:00" / ISO); keep the date part.
|
|
69
|
+
const d = new Date(value);
|
|
70
|
+
if (Number.isNaN(d.getTime())) {
|
|
71
|
+
// Already a bare YYYY-MM-DD or unparseable — trust the leading date chunk.
|
|
72
|
+
return String(value).slice(0, 10);
|
|
73
|
+
}
|
|
74
|
+
return d.toISOString().slice(0, 10);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The effective-window chip (tonal Badge), or null when the posting is in effect. */
|
|
78
|
+
function WindowChip({ chip }) {
|
|
79
|
+
if (!chip) return null;
|
|
80
|
+
return (
|
|
81
|
+
<Badge variant={chip === 'expired' ? 'warning' : 'info'} className="capitalize">
|
|
82
|
+
{chip}
|
|
83
|
+
</Badge>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export default function PostingRow({
|
|
88
|
+
posting,
|
|
89
|
+
departments,
|
|
90
|
+
branches,
|
|
91
|
+
token,
|
|
92
|
+
identity,
|
|
93
|
+
onChange,
|
|
94
|
+
onRemove,
|
|
95
|
+
disabled = false,
|
|
96
|
+
layout = 'row',
|
|
97
|
+
}) {
|
|
98
|
+
const chip = windowState(posting.effectiveFrom, posting.effectiveUntil);
|
|
99
|
+
|
|
100
|
+
// Role is the growable catalog → server-paginated + searchable via `listRoles`. The row
|
|
101
|
+
// already knows the selected role's NAME (posting.roleName, resolved server-side), so the
|
|
102
|
+
// trigger shows a name even before the first page loads.
|
|
103
|
+
const fetchRoles = useCallback(
|
|
104
|
+
async ({ search, page, limit }) => {
|
|
105
|
+
const r = await identity.listRoles(token, { search, page, limit });
|
|
106
|
+
if (!r.ok) return { options: [], total: 0, page };
|
|
107
|
+
const options = (r.rows || []).map((row) => ({
|
|
108
|
+
value: row.id,
|
|
109
|
+
label: row.fields?.name || '(unnamed)',
|
|
110
|
+
}));
|
|
111
|
+
return { options, total: r.total ?? options.length, page: r.page ?? page };
|
|
112
|
+
},
|
|
113
|
+
[identity, token],
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Department / Branch: small, per-company catalogs (passed in). The SAME control in
|
|
117
|
+
// client-filter mode — `fetchPage` returns the whole list once; the panel filters locally.
|
|
118
|
+
const fetchDepartments = useCallback(
|
|
119
|
+
async () => {
|
|
120
|
+
const options = (departments || []).map((d) => ({ value: d.uuid, label: d.name }));
|
|
121
|
+
return { options, total: options.length, page: 1 };
|
|
122
|
+
},
|
|
123
|
+
[departments],
|
|
124
|
+
);
|
|
125
|
+
const fetchBranches = useCallback(
|
|
126
|
+
async () => {
|
|
127
|
+
const options = (branches || []).map((b) => ({ value: b.uuid, label: b.name }));
|
|
128
|
+
return { options, total: options.length, page: 1 };
|
|
129
|
+
},
|
|
130
|
+
[branches],
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
// Shared field controls — reused by both the desktop row and the mobile card.
|
|
134
|
+
const roleSelect = (
|
|
135
|
+
<SearchableSelect
|
|
136
|
+
aria-label="Role"
|
|
137
|
+
value={posting.roleUuid || null}
|
|
138
|
+
selectedLabel={posting.roleName}
|
|
139
|
+
onChange={(v) => onChange({ roleUuid: v || '' })}
|
|
140
|
+
onLabelChange={(label) => onChange({ roleName: label || '' })}
|
|
141
|
+
fetchPage={fetchRoles}
|
|
142
|
+
placeholder="Select a role…"
|
|
143
|
+
searchPlaceholder="Search roles…"
|
|
144
|
+
disabled={disabled}
|
|
145
|
+
/>
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const departmentSelect = (
|
|
149
|
+
<SearchableSelect
|
|
150
|
+
aria-label="Department"
|
|
151
|
+
value={posting.departmentUuid || null}
|
|
152
|
+
selectedLabel={(departments || []).find((d) => d.uuid === posting.departmentUuid)?.name}
|
|
153
|
+
onChange={(v) => onChange({ departmentUuid: v })}
|
|
154
|
+
fetchPage={fetchDepartments}
|
|
155
|
+
clientFilter
|
|
156
|
+
allowClear
|
|
157
|
+
placeholder="— none —"
|
|
158
|
+
searchPlaceholder="Search departments…"
|
|
159
|
+
disabled={disabled}
|
|
160
|
+
/>
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const branchSelect = (
|
|
164
|
+
<SearchableSelect
|
|
165
|
+
aria-label="Branch"
|
|
166
|
+
value={posting.branchUuid || null}
|
|
167
|
+
selectedLabel={(branches || []).find((b) => b.uuid === posting.branchUuid)?.name}
|
|
168
|
+
onChange={(v) => onChange({ branchUuid: v })}
|
|
169
|
+
fetchPage={fetchBranches}
|
|
170
|
+
clientFilter
|
|
171
|
+
allowClear
|
|
172
|
+
placeholder="— none —"
|
|
173
|
+
searchPlaceholder="Search branches…"
|
|
174
|
+
disabled={disabled}
|
|
175
|
+
/>
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const fromInput = (
|
|
179
|
+
<DateField
|
|
180
|
+
aria-label="Effective from"
|
|
181
|
+
value={toDateInput(posting.effectiveFrom)}
|
|
182
|
+
disabled={disabled}
|
|
183
|
+
onChange={(e) => onChange({ effectiveFrom: e.target.value || null })}
|
|
184
|
+
/>
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const untilInput = (
|
|
188
|
+
<DateField
|
|
189
|
+
aria-label="Effective until"
|
|
190
|
+
value={toDateInput(posting.effectiveUntil)}
|
|
191
|
+
disabled={disabled}
|
|
192
|
+
onChange={(e) => onChange({ effectiveUntil: e.target.value || null })}
|
|
193
|
+
/>
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const removeButton = (
|
|
197
|
+
<Button
|
|
198
|
+
type="button"
|
|
199
|
+
variant="ghost"
|
|
200
|
+
size="icon"
|
|
201
|
+
className="size-8 text-muted-foreground hover:text-destructive"
|
|
202
|
+
onClick={onRemove}
|
|
203
|
+
disabled={disabled}
|
|
204
|
+
aria-label="Remove posting"
|
|
205
|
+
>
|
|
206
|
+
<X className="size-4" />
|
|
207
|
+
</Button>
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// Mobile: a stacked mini-card of labeled fields (mirrors OCA's ≤640 .dsa-row stacking).
|
|
211
|
+
if (layout === 'card') {
|
|
212
|
+
return (
|
|
213
|
+
<div className="rounded-lg border border-border bg-background p-3">
|
|
214
|
+
<div className="mb-2 flex items-center justify-between gap-2">
|
|
215
|
+
<WindowChip chip={chip} />
|
|
216
|
+
{removeButton}
|
|
217
|
+
</div>
|
|
218
|
+
<div className="space-y-2.5">
|
|
219
|
+
<div className="space-y-1">
|
|
220
|
+
<Label variant="field">Role</Label>
|
|
221
|
+
{roleSelect}
|
|
222
|
+
</div>
|
|
223
|
+
<div className="space-y-1">
|
|
224
|
+
<Label variant="field">Department</Label>
|
|
225
|
+
{departmentSelect}
|
|
226
|
+
</div>
|
|
227
|
+
<div className="space-y-1">
|
|
228
|
+
<Label variant="field">Branch</Label>
|
|
229
|
+
{branchSelect}
|
|
230
|
+
</div>
|
|
231
|
+
<div className="grid grid-cols-2 gap-2">
|
|
232
|
+
<div className="space-y-1">
|
|
233
|
+
<Label variant="field">From</Label>
|
|
234
|
+
{fromInput}
|
|
235
|
+
</div>
|
|
236
|
+
<div className="space-y-1">
|
|
237
|
+
<Label variant="field">Until</Label>
|
|
238
|
+
{untilInput}
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Desktop: a table row inside the MembershipCard table. Each cell carries a sane
|
|
247
|
+
// `min-w-*` so, when the (`w-max`) table is wider than its scroll wrapper and scrolls,
|
|
248
|
+
// the columns stay readable rather than collapsing to their tightest fit.
|
|
249
|
+
return (
|
|
250
|
+
<tr className="border-b last:border-b-0">
|
|
251
|
+
<td className="min-w-[8rem] py-1.5 pr-2 align-top">{roleSelect}</td>
|
|
252
|
+
<td className="min-w-[8rem] py-1.5 pr-2 align-top">{departmentSelect}</td>
|
|
253
|
+
<td className="min-w-[8rem] py-1.5 pr-2 align-top">{branchSelect}</td>
|
|
254
|
+
<td className="min-w-[9rem] py-1.5 pr-2 align-top">{fromInput}</td>
|
|
255
|
+
<td className="min-w-[9rem] py-1.5 pr-2 align-top">{untilInput}</td>
|
|
256
|
+
<td className="py-1.5 align-top">
|
|
257
|
+
<div className="flex items-center gap-1.5">
|
|
258
|
+
<WindowChip chip={chip} />
|
|
259
|
+
{removeButton}
|
|
260
|
+
</div>
|
|
261
|
+
</td>
|
|
262
|
+
</tr>
|
|
263
|
+
);
|
|
264
|
+
}
|