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.
Files changed (83) hide show
  1. package/dist/add-frontend.js +107 -0
  2. package/dist/add-frontend.js.map +1 -0
  3. package/dist/index.js +122 -25
  4. package/dist/index.js.map +1 -1
  5. package/package.json +3 -2
  6. package/template/frontend/FRONTEND.md +92 -0
  7. package/template/frontend/_env.example +6 -0
  8. package/template/frontend/_gitignore +9 -0
  9. package/template/frontend/eslint.config.js +71 -0
  10. package/template/frontend/index.html +13 -0
  11. package/template/frontend/package.json +52 -0
  12. package/template/frontend/public/astram-logo-full.png +0 -0
  13. package/template/frontend/public/astram-logo-white.png +0 -0
  14. package/template/frontend/public/favicon.png +0 -0
  15. package/template/frontend/src/App.jsx +167 -0
  16. package/template/frontend/src/auth/AuthContext.jsx +338 -0
  17. package/template/frontend/src/components/ConfirmDialog.jsx +92 -0
  18. package/template/frontend/src/components/EntityFormModal.jsx +154 -0
  19. package/template/frontend/src/components/EntityList.jsx +480 -0
  20. package/template/frontend/src/components/PageHeader.jsx +54 -0
  21. package/template/frontend/src/components/access/CollapsibleGroup.jsx +34 -0
  22. package/template/frontend/src/components/ui/alert-dialog.tsx +175 -0
  23. package/template/frontend/src/components/ui/alert.tsx +83 -0
  24. package/template/frontend/src/components/ui/avatar.tsx +51 -0
  25. package/template/frontend/src/components/ui/badge.tsx +56 -0
  26. package/template/frontend/src/components/ui/button.tsx +67 -0
  27. package/template/frontend/src/components/ui/card.tsx +92 -0
  28. package/template/frontend/src/components/ui/checkbox.tsx +30 -0
  29. package/template/frontend/src/components/ui/date-field.tsx +47 -0
  30. package/template/frontend/src/components/ui/dialog.tsx +214 -0
  31. package/template/frontend/src/components/ui/dropdown-menu.tsx +255 -0
  32. package/template/frontend/src/components/ui/input-otp.tsx +81 -0
  33. package/template/frontend/src/components/ui/input.tsx +21 -0
  34. package/template/frontend/src/components/ui/label.tsx +46 -0
  35. package/template/frontend/src/components/ui/searchable-select.tsx +459 -0
  36. package/template/frontend/src/components/ui/select.tsx +199 -0
  37. package/template/frontend/src/components/ui/separator.tsx +26 -0
  38. package/template/frontend/src/components/ui/sheet.tsx +139 -0
  39. package/template/frontend/src/components/ui/skeleton.tsx +13 -0
  40. package/template/frontend/src/components/ui/sonner.tsx +23 -0
  41. package/template/frontend/src/components/ui/table.tsx +181 -0
  42. package/template/frontend/src/components/ui/tooltip.tsx +59 -0
  43. package/template/frontend/src/config/branding.js +80 -0
  44. package/template/frontend/src/hooks/useMobile.tsx +21 -0
  45. package/template/frontend/src/index.css +265 -0
  46. package/template/frontend/src/lib/utils.js +7 -0
  47. package/template/frontend/src/main.jsx +27 -0
  48. package/template/frontend/src/pages/Dashboard.jsx +127 -0
  49. package/template/frontend/src/pages/ForgotPassword.jsx +104 -0
  50. package/template/frontend/src/pages/Login.jsx +393 -0
  51. package/template/frontend/src/pages/ResetPassword.jsx +159 -0
  52. package/template/frontend/src/pages/access/AccessForm.jsx +416 -0
  53. package/template/frontend/src/pages/access/AccessPage.jsx +282 -0
  54. package/template/frontend/src/pages/access/RoleActionsMenu.jsx +42 -0
  55. package/template/frontend/src/pages/account/AccountLayout.jsx +229 -0
  56. package/template/frontend/src/pages/account/ChangePasswordPage.jsx +235 -0
  57. package/template/frontend/src/pages/account/ProfilePage.jsx +122 -0
  58. package/template/frontend/src/pages/account/ReenrollPage.jsx +98 -0
  59. package/template/frontend/src/pages/account/TwoFactorPage.jsx +833 -0
  60. package/template/frontend/src/pages/audit/AuditPage.jsx +268 -0
  61. package/template/frontend/src/pages/companies/CompaniesPage.jsx +232 -0
  62. package/template/frontend/src/pages/companies/CompanyForm.jsx +127 -0
  63. package/template/frontend/src/pages/roles/RoleForm.jsx +99 -0
  64. package/template/frontend/src/pages/roles/RolesPage.jsx +207 -0
  65. package/template/frontend/src/pages/users/AssignmentsEditor.jsx +193 -0
  66. package/template/frontend/src/pages/users/MembershipCard.jsx +303 -0
  67. package/template/frontend/src/pages/users/PostingRow.jsx +264 -0
  68. package/template/frontend/src/pages/users/UserForm.jsx +167 -0
  69. package/template/frontend/src/pages/users/UsersPage.jsx +232 -0
  70. package/template/frontend/src/platform/httpClient.js +125 -0
  71. package/template/frontend/src/platform/returnPathStore.js +43 -0
  72. package/template/frontend/src/platform/sessionStore.js +29 -0
  73. package/template/frontend/src/platform/shieldTransport.js +172 -0
  74. package/template/frontend/src/platform/shieldTransport.test.mjs +245 -0
  75. package/template/frontend/src/shell/AppShell.jsx +237 -0
  76. package/template/frontend/src/shell/Sidebar.jsx +257 -0
  77. package/template/frontend/src/shell/nav.js +90 -0
  78. package/template/frontend/src/theme/ThemeProvider.jsx +14 -0
  79. package/template/frontend/src/theme/ThemeToggle.jsx +44 -0
  80. package/template/frontend/tsconfig.json +22 -0
  81. package/template/frontend/vite.config.js +30 -0
  82. package/template/server/_env.example +27 -15
  83. 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
+ }