create-astram-app 0.1.2 → 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 (101) hide show
  1. package/dist/add-frontend.js +107 -0
  2. package/dist/add-frontend.js.map +1 -0
  3. package/dist/index.js +132 -33
  4. package/dist/index.js.map +1 -1
  5. package/package.json +3 -2
  6. package/template/CLAUDE.md +51 -0
  7. package/template/README.md +43 -44
  8. package/template/_gitignore +6 -0
  9. package/template/frontend/FRONTEND.md +92 -0
  10. package/template/frontend/README.md +16 -0
  11. package/template/frontend/_env.example +6 -0
  12. package/template/frontend/_gitignore +9 -0
  13. package/template/frontend/eslint.config.js +71 -0
  14. package/template/frontend/index.html +13 -0
  15. package/template/frontend/package.json +52 -0
  16. package/template/frontend/public/astram-logo-full.png +0 -0
  17. package/template/frontend/public/astram-logo-white.png +0 -0
  18. package/template/frontend/public/favicon.png +0 -0
  19. package/template/frontend/src/App.jsx +167 -0
  20. package/template/frontend/src/auth/AuthContext.jsx +338 -0
  21. package/template/frontend/src/components/ConfirmDialog.jsx +92 -0
  22. package/template/frontend/src/components/EntityFormModal.jsx +154 -0
  23. package/template/frontend/src/components/EntityList.jsx +480 -0
  24. package/template/frontend/src/components/PageHeader.jsx +54 -0
  25. package/template/frontend/src/components/access/CollapsibleGroup.jsx +34 -0
  26. package/template/frontend/src/components/ui/alert-dialog.tsx +175 -0
  27. package/template/frontend/src/components/ui/alert.tsx +83 -0
  28. package/template/frontend/src/components/ui/avatar.tsx +51 -0
  29. package/template/frontend/src/components/ui/badge.tsx +56 -0
  30. package/template/frontend/src/components/ui/button.tsx +67 -0
  31. package/template/frontend/src/components/ui/card.tsx +92 -0
  32. package/template/frontend/src/components/ui/checkbox.tsx +30 -0
  33. package/template/frontend/src/components/ui/date-field.tsx +47 -0
  34. package/template/frontend/src/components/ui/dialog.tsx +214 -0
  35. package/template/frontend/src/components/ui/dropdown-menu.tsx +255 -0
  36. package/template/frontend/src/components/ui/input-otp.tsx +81 -0
  37. package/template/frontend/src/components/ui/input.tsx +21 -0
  38. package/template/frontend/src/components/ui/label.tsx +46 -0
  39. package/template/frontend/src/components/ui/searchable-select.tsx +459 -0
  40. package/template/frontend/src/components/ui/select.tsx +199 -0
  41. package/template/frontend/src/components/ui/separator.tsx +26 -0
  42. package/template/frontend/src/components/ui/sheet.tsx +139 -0
  43. package/template/frontend/src/components/ui/skeleton.tsx +13 -0
  44. package/template/frontend/src/components/ui/sonner.tsx +23 -0
  45. package/template/frontend/src/components/ui/table.tsx +181 -0
  46. package/template/frontend/src/components/ui/tooltip.tsx +59 -0
  47. package/template/frontend/src/config/branding.js +80 -0
  48. package/template/frontend/src/hooks/useMobile.tsx +21 -0
  49. package/template/frontend/src/index.css +265 -0
  50. package/template/frontend/src/lib/utils.js +7 -0
  51. package/template/frontend/src/main.jsx +27 -0
  52. package/template/frontend/src/pages/Dashboard.jsx +127 -0
  53. package/template/frontend/src/pages/ForgotPassword.jsx +104 -0
  54. package/template/frontend/src/pages/Login.jsx +393 -0
  55. package/template/frontend/src/pages/ResetPassword.jsx +159 -0
  56. package/template/frontend/src/pages/access/AccessForm.jsx +416 -0
  57. package/template/frontend/src/pages/access/AccessPage.jsx +282 -0
  58. package/template/frontend/src/pages/access/RoleActionsMenu.jsx +42 -0
  59. package/template/frontend/src/pages/account/AccountLayout.jsx +229 -0
  60. package/template/frontend/src/pages/account/ChangePasswordPage.jsx +235 -0
  61. package/template/frontend/src/pages/account/ProfilePage.jsx +122 -0
  62. package/template/frontend/src/pages/account/ReenrollPage.jsx +98 -0
  63. package/template/frontend/src/pages/account/TwoFactorPage.jsx +833 -0
  64. package/template/frontend/src/pages/audit/AuditPage.jsx +268 -0
  65. package/template/frontend/src/pages/companies/CompaniesPage.jsx +232 -0
  66. package/template/frontend/src/pages/companies/CompanyForm.jsx +127 -0
  67. package/template/frontend/src/pages/roles/RoleForm.jsx +99 -0
  68. package/template/frontend/src/pages/roles/RolesPage.jsx +207 -0
  69. package/template/frontend/src/pages/users/AssignmentsEditor.jsx +193 -0
  70. package/template/frontend/src/pages/users/MembershipCard.jsx +303 -0
  71. package/template/frontend/src/pages/users/PostingRow.jsx +264 -0
  72. package/template/frontend/src/pages/users/UserForm.jsx +167 -0
  73. package/template/frontend/src/pages/users/UsersPage.jsx +232 -0
  74. package/template/frontend/src/platform/httpClient.js +125 -0
  75. package/template/frontend/src/platform/returnPathStore.js +43 -0
  76. package/template/frontend/src/platform/sessionStore.js +29 -0
  77. package/template/frontend/src/platform/shieldTransport.js +172 -0
  78. package/template/frontend/src/platform/shieldTransport.test.mjs +245 -0
  79. package/template/frontend/src/shell/AppShell.jsx +237 -0
  80. package/template/frontend/src/shell/Sidebar.jsx +257 -0
  81. package/template/frontend/src/shell/nav.js +90 -0
  82. package/template/frontend/src/theme/ThemeProvider.jsx +14 -0
  83. package/template/frontend/src/theme/ThemeToggle.jsx +44 -0
  84. package/template/frontend/tsconfig.json +22 -0
  85. package/template/frontend/vite.config.js +30 -0
  86. package/template/server/_env.example +45 -0
  87. package/template/server/eslint.config.js +95 -0
  88. package/template/server/package.json +49 -0
  89. package/template/server/scripts/dev.mjs +51 -0
  90. package/template/server/scripts/sync-astram-guide.mjs +63 -0
  91. package/template/_env.example +0 -33
  92. package/template/package.json +0 -37
  93. /package/template/{src → server/src}/app.module.ts +0 -0
  94. /package/template/{src → server/src}/features/_gitkeep +0 -0
  95. /package/template/{src → server/src}/health/demo.controller.ts +0 -0
  96. /package/template/{src → server/src}/health/health.controller.ts +0 -0
  97. /package/template/{src → server/src}/main.ts +0 -0
  98. /package/template/{test → server/test}/enforcement.e2e.spec.ts +0 -0
  99. /package/template/{tsconfig.build.json → server/tsconfig.build.json} +0 -0
  100. /package/template/{tsconfig.json → server/tsconfig.json} +0 -0
  101. /package/template/{vitest.config.ts → server/vitest.config.ts} +0 -0
@@ -0,0 +1,480 @@
1
+ /**
2
+ * EntityList — a reusable, server-driven OCA-styled DataTable for the identity
3
+ * management pages (Companies / Roles / Users). Re-skinned onto the shared shadcn
4
+ * `Table` primitive (`ui/table.tsx`, tuned to OCA's matured DataTable look —
5
+ * mono-uppercase headers, hairline borders, dense cells, row hover, a horizontal-
6
+ * scroll wrapper, and a STICKY-RIGHT actions column). Wrapped in a `Card` so it
7
+ * inherits the app's light/dark card depth.
8
+ *
9
+ * SERVER-DRIVEN (OCA DataTable pattern): this component is CONTROLLED by the page.
10
+ * It renders a search box, sortable column headers, and a footer pager, but owns NO
11
+ * query state — the page holds `{ page, limit, search, sortBy, sortOrder }`, refetches
12
+ * on any change, and passes rows + total + that state + the change callbacks down.
13
+ * Search is DEBOUNCED (~300ms) inside the component so keystrokes don't spam the
14
+ * server; sorting + paging fire immediately. This fixes the real bug: the list no
15
+ * longer silently caps at the engine's default page — it pages the true `total`, and
16
+ * search + sort run on the SERVER (not a client filter over one page).
17
+ *
18
+ * Responsive: at ≤640px the table REFLOWS to a stack of cards — each row becomes a
19
+ * card of label:value pairs with the row's actions beneath — instead of only
20
+ * horizontal-scrolling. Desktop keeps the table with the sticky actions column.
21
+ *
22
+ * ⛔ NEVER renders a raw UUID as visible text. Each column supplies a `key` (or a
23
+ * `render(row)`) that resolves to a display NAME/label; the row's uuid lives only in
24
+ * `row.id`, used as the React key + handed to onEdit/onDelete for routing — never shown.
25
+ *
26
+ * Props:
27
+ * title — card heading (e.g. "Companies")
28
+ * description — optional sub-line under the heading
29
+ * icon — optional lucide icon component for the heading
30
+ * columns — [{ key, label, render?, sortable?, sortKey? }]
31
+ * render(row) wins over row.fields[key]; sortable columns get a
32
+ * clickable header (sortKey defaults to key) that toggles asc→desc.
33
+ * rows — [{ id, fields:{…} }] (the engine list shape, ONE page)
34
+ * total — the FILTERED total row count (from the server) — drives the pager
35
+ * page — current 1-based page (controlled)
36
+ * limit — current page size (controlled)
37
+ * search — current search term (controlled)
38
+ * sortBy — current sort field key ('' = unsorted, controlled)
39
+ * sortOrder — 'asc' | 'desc' (controlled)
40
+ * loading — show a loading line
41
+ * error — an error string (e.g. a graceful 403 message) shown instead of rows
42
+ * emptyLabel — text for the no-rows state
43
+ * searchPlaceholder — placeholder for the search box (default "Search…")
44
+ * addLabel — the Add button label (default "Add")
45
+ * pageSizes — selectable page sizes (default [10, 25, 50])
46
+ * onAdd — () => void
47
+ * onEdit — (row) => void
48
+ * onDelete — (row) => void
49
+ * onSearchChange — (term: string) => void (page resets to 1)
50
+ * onSortChange — (sortBy: string, sortOrder) => void
51
+ * onPageChange — (page: number) => void
52
+ * onLimitChange — (limit: number) => void (page resets to 1)
53
+ */
54
+ import { useEffect, useRef, useState } from 'react';
55
+ import {
56
+ ArrowUpDown,
57
+ ChevronDown,
58
+ ChevronLeft,
59
+ ChevronRight,
60
+ ChevronUp,
61
+ Loader2,
62
+ Pencil,
63
+ Plus,
64
+ Search,
65
+ Trash2,
66
+ } from 'lucide-react';
67
+ import { Button } from '@/components/ui/button';
68
+ import { Input } from '@/components/ui/input';
69
+ import {
70
+ Card,
71
+ CardContent,
72
+ CardDescription,
73
+ CardHeader,
74
+ CardTitle,
75
+ } from '@/components/ui/card';
76
+ import { Alert, AlertDescription } from '@/components/ui/alert';
77
+ import {
78
+ Select,
79
+ SelectContent,
80
+ SelectItem,
81
+ SelectTrigger,
82
+ SelectValue,
83
+ } from '@/components/ui/select';
84
+ import {
85
+ Table,
86
+ TableBody,
87
+ TableCell,
88
+ TableHead,
89
+ TableHeader,
90
+ TableRow,
91
+ } from '@/components/ui/table';
92
+
93
+ /** Resolve one cell's DISPLAY value — render(row) if given, else row.fields[key]. */
94
+ function cellValue(col, row) {
95
+ if (typeof col.render === 'function') return col.render(row);
96
+ const v = row?.fields?.[col.key];
97
+ return v === null || v === undefined || v === '' ? '—' : v;
98
+ }
99
+
100
+ /** The sort key a column sorts by — explicit `sortKey`, else its `key`. */
101
+ function colSortKey(col) {
102
+ return col.sortKey ?? col.key;
103
+ }
104
+
105
+ /** The Edit / Delete action buttons — shared by the desktop cell + the mobile card. */
106
+ function RowActions({ row, onEdit, onDelete }) {
107
+ return (
108
+ <div className="flex items-center justify-end gap-1">
109
+ {onEdit && (
110
+ <Button type="button" variant="ghost" size="sm" onClick={() => onEdit(row)}>
111
+ <Pencil className="size-4" />
112
+ Edit
113
+ </Button>
114
+ )}
115
+ {onDelete && (
116
+ <Button
117
+ type="button"
118
+ variant="ghost"
119
+ size="sm"
120
+ className="text-destructive hover:bg-destructive/10 hover:text-destructive"
121
+ onClick={() => onDelete(row)}
122
+ >
123
+ <Trash2 className="size-4" />
124
+ Delete
125
+ </Button>
126
+ )}
127
+ </div>
128
+ );
129
+ }
130
+
131
+ /**
132
+ * A debounced search input. Holds the keystrokes locally and only calls
133
+ * `onSearchChange` after `delay` ms of quiet — so typing doesn't hit the server on
134
+ * every character. Stays in sync if the controlled `value` prop changes externally
135
+ * (e.g. a reset).
136
+ */
137
+ function SearchBox({ value, onSearchChange, placeholder = 'Search…', delay = 300 }) {
138
+ const [draft, setDraft] = useState(value ?? '');
139
+ const timer = useRef(null);
140
+ // Sync down when the controlled value changes from the outside (not our own keystroke).
141
+ useEffect(() => {
142
+ setDraft(value ?? '');
143
+ }, [value]);
144
+
145
+ const onChange = (e) => {
146
+ const next = e.target.value;
147
+ setDraft(next);
148
+ if (timer.current) clearTimeout(timer.current);
149
+ timer.current = setTimeout(() => {
150
+ onSearchChange?.(next);
151
+ }, delay);
152
+ };
153
+
154
+ // Flush any pending debounce on unmount.
155
+ useEffect(() => () => timer.current && clearTimeout(timer.current), []);
156
+
157
+ return (
158
+ <div className="relative w-full sm:w-56">
159
+ <Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
160
+ <Input
161
+ type="search"
162
+ value={draft}
163
+ onChange={onChange}
164
+ placeholder={placeholder}
165
+ className="h-9 pl-8"
166
+ aria-label="Search"
167
+ />
168
+ </div>
169
+ );
170
+ }
171
+
172
+ /**
173
+ * EntityListControls — the search box + Add button, extracted so a page can place them
174
+ * INLINE with its PageHeader (right-aligned) instead of inside the list card. Renders
175
+ * nothing it isn't wired for (search only if `onSearchChange`, Add only if `onAdd`).
176
+ */
177
+ export function EntityListControls({
178
+ search = '',
179
+ onSearchChange,
180
+ searchPlaceholder = 'Search…',
181
+ addLabel = 'Add',
182
+ onAdd,
183
+ }) {
184
+ const showSearch = Boolean(onSearchChange);
185
+ if (!showSearch && !onAdd) return null;
186
+ return (
187
+ <>
188
+ {showSearch && (
189
+ <div className="min-w-0 flex-1 sm:flex-none">
190
+ <SearchBox
191
+ value={search}
192
+ onSearchChange={onSearchChange}
193
+ placeholder={searchPlaceholder}
194
+ />
195
+ </div>
196
+ )}
197
+ {onAdd && (
198
+ <Button type="button" size="sm" onClick={onAdd} className="shrink-0">
199
+ <Plus className="size-4" />
200
+ {addLabel}
201
+ </Button>
202
+ )}
203
+ </>
204
+ );
205
+ }
206
+
207
+ /**
208
+ * EntityListPager — the footer pager (rows-per-page + range + Prev/Next), extracted so a
209
+ * page can render it OUTSIDE/below the list card. Renders only when paging is wired and
210
+ * there's a result to page (or while loading, to avoid flicker).
211
+ */
212
+ export function EntityListPager({
213
+ total = 0,
214
+ page = 1,
215
+ limit = 25,
216
+ loading = false,
217
+ error = '',
218
+ pageSizes = [10, 25, 50],
219
+ onPageChange,
220
+ onLimitChange,
221
+ }) {
222
+ const showPager = Boolean(onPageChange || onLimitChange);
223
+ if (!showPager || error || !(total > 0 || loading)) return null;
224
+ return (
225
+ <TableFooterBar
226
+ total={total}
227
+ page={page}
228
+ limit={limit}
229
+ pageSizes={pageSizes}
230
+ onPageChange={onPageChange}
231
+ onLimitChange={onLimitChange}
232
+ />
233
+ );
234
+ }
235
+
236
+ /**
237
+ * A sortable header cell. Clicking cycles the sort for this column: if it isn't the
238
+ * active sort → asc; if active asc → desc; if active desc → asc (a simple asc↔desc
239
+ * toggle once engaged). Shows a chevron for the active direction, a neutral
240
+ * up/down glyph otherwise.
241
+ */
242
+ function SortableHead({ col, sortBy, sortOrder, onSortChange }) {
243
+ const key = colSortKey(col);
244
+ const active = sortBy === key;
245
+ const nextOrder = active && sortOrder === 'asc' ? 'desc' : 'asc';
246
+ const Indicator = !active ? ArrowUpDown : sortOrder === 'desc' ? ChevronDown : ChevronUp;
247
+ return (
248
+ <TableHead>
249
+ <button
250
+ type="button"
251
+ onClick={() => onSortChange?.(key, nextOrder)}
252
+ className="-mx-1 -my-1 inline-flex items-center gap-1 rounded px-1 py-1 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
253
+ aria-label={`Sort by ${col.label}`}
254
+ >
255
+ {col.label}
256
+ <Indicator
257
+ className={active ? 'size-3.5 text-foreground' : 'size-3.5 text-muted-foreground/60'}
258
+ />
259
+ </button>
260
+ </TableHead>
261
+ );
262
+ }
263
+
264
+ /**
265
+ * The footer pager — "X–Y of N", a page-size selector, and Prev/Next. Mono, subtle
266
+ * (OCA's `.oca-dt-foot` look). Prev disabled on page 1; Next disabled on the last page.
267
+ */
268
+ function TableFooterBar({ total, page, limit, pageSizes, onPageChange, onLimitChange }) {
269
+ const totalPages = Math.max(1, Math.ceil(total / Math.max(1, limit)));
270
+ const safePage = Math.min(Math.max(1, page), totalPages);
271
+ const from = total === 0 ? 0 : (safePage - 1) * limit + 1;
272
+ const to = Math.min(total, safePage * limit);
273
+
274
+ return (
275
+ <div className="mt-3 flex flex-wrap items-center justify-between gap-3 px-1 text-xs">
276
+ <div className="flex items-center gap-2 text-muted-foreground">
277
+ <span>Rows per page</span>
278
+ <Select value={String(limit)} onValueChange={(v) => onLimitChange?.(Number(v))}>
279
+ <SelectTrigger className="h-8 w-[4.5rem]" aria-label="Rows per page">
280
+ <SelectValue />
281
+ </SelectTrigger>
282
+ <SelectContent>
283
+ {pageSizes.map((n) => (
284
+ <SelectItem key={n} value={String(n)}>
285
+ {n}
286
+ </SelectItem>
287
+ ))}
288
+ </SelectContent>
289
+ </Select>
290
+ </div>
291
+
292
+ <div className="flex items-center gap-3">
293
+ <span className="font-mono tabular-nums text-muted-foreground">
294
+ {from}–{to} of {total}
295
+ </span>
296
+ <div className="flex items-center gap-1">
297
+ <Button
298
+ type="button"
299
+ variant="outline"
300
+ size="sm"
301
+ className="h-8 px-2"
302
+ onClick={() => onPageChange?.(safePage - 1)}
303
+ disabled={safePage <= 1}
304
+ aria-label="Previous page"
305
+ >
306
+ <ChevronLeft className="size-4" />
307
+ Prev
308
+ </Button>
309
+ <Button
310
+ type="button"
311
+ variant="outline"
312
+ size="sm"
313
+ className="h-8 px-2"
314
+ onClick={() => onPageChange?.(safePage + 1)}
315
+ disabled={safePage >= totalPages}
316
+ aria-label="Next page"
317
+ >
318
+ Next
319
+ <ChevronRight className="size-4" />
320
+ </Button>
321
+ </div>
322
+ </div>
323
+ </div>
324
+ );
325
+ }
326
+
327
+ export default function EntityList({
328
+ title,
329
+ description,
330
+ icon: Icon,
331
+ columns = [],
332
+ rows = [],
333
+ search = '',
334
+ sortBy = '',
335
+ sortOrder = 'asc',
336
+ loading = false,
337
+ error = '',
338
+ emptyLabel = 'Nothing here yet.',
339
+ onEdit,
340
+ onDelete,
341
+ onSortChange,
342
+ }) {
343
+ const hasActions = Boolean(onEdit || onDelete);
344
+
345
+ return (
346
+ <Card className="gap-0 overflow-hidden py-0">
347
+ {/* Card header carries ONLY the title/description now — the search + Add controls
348
+ moved OUT to the page's PageHeader (via <EntityListControls/>), and the pager
349
+ moved BELOW the card (via <EntityListPager/>). Rendered only when a title or
350
+ description is supplied; a page that shows its title elsewhere can omit both. */}
351
+ {(title || description) && (
352
+ <CardHeader>
353
+ {title && (
354
+ <CardTitle className="flex items-center gap-2 text-base">
355
+ {Icon && <Icon className="size-4 text-primary" />}
356
+ {title}
357
+ </CardTitle>
358
+ )}
359
+ {description && <CardDescription className="mt-1">{description}</CardDescription>}
360
+ </CardHeader>
361
+ )}
362
+ <CardContent className="p-0">
363
+ {error ? (
364
+ <Alert variant="destructive" className="m-4 w-auto">
365
+ <AlertDescription>{error}</AlertDescription>
366
+ </Alert>
367
+ ) : (
368
+ <>
369
+ {/* Desktop / tablet: the OCA table with a sticky-right Actions column. The
370
+ table SHELL (header row) always renders — on no rows / no search results
371
+ the body shows a single "No records found" row instead of the table
372
+ vanishing (loading shows a spinner row). Total column count = data columns
373
+ + the actions column, so the empty/loading cell spans the full width. */}
374
+ <div className="hidden sm:block">
375
+ <Table>
376
+ <TableHeader>
377
+ <TableRow className="hover:bg-transparent">
378
+ {columns.map((col) =>
379
+ col.sortable && onSortChange ? (
380
+ <SortableHead
381
+ key={col.key ?? col.label}
382
+ col={col}
383
+ sortBy={sortBy}
384
+ sortOrder={sortOrder}
385
+ onSortChange={onSortChange}
386
+ />
387
+ ) : (
388
+ <TableHead key={col.key ?? col.label}>{col.label}</TableHead>
389
+ ),
390
+ )}
391
+ {hasActions && <TableHead sticky>Actions</TableHead>}
392
+ </TableRow>
393
+ </TableHeader>
394
+ <TableBody>
395
+ {loading ? (
396
+ <TableRow className="hover:bg-transparent">
397
+ <TableCell colSpan={columns.length + (hasActions ? 1 : 0)}>
398
+ <div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
399
+ <Loader2 className="size-4 animate-spin" />
400
+ Loading…
401
+ </div>
402
+ </TableCell>
403
+ </TableRow>
404
+ ) : rows.length === 0 ? (
405
+ <TableRow className="hover:bg-transparent">
406
+ <TableCell colSpan={columns.length + (hasActions ? 1 : 0)}>
407
+ <p className="py-8 text-center text-sm text-muted-foreground">
408
+ {search ? `No records found for “${search}”.` : emptyLabel}
409
+ </p>
410
+ </TableCell>
411
+ </TableRow>
412
+ ) : (
413
+ rows.map((row, i) => (
414
+ <TableRow key={typeof row.id === 'string' ? row.id : i}>
415
+ {columns.map((col) => (
416
+ <TableCell key={col.key ?? col.label}>{cellValue(col, row)}</TableCell>
417
+ ))}
418
+ {hasActions && (
419
+ <TableCell sticky>
420
+ <RowActions row={row} onEdit={onEdit} onDelete={onDelete} />
421
+ </TableCell>
422
+ )}
423
+ </TableRow>
424
+ ))
425
+ )}
426
+ </TableBody>
427
+ </Table>
428
+ </div>
429
+
430
+ {/* Mobile (≤640px): the table reflows to a stack of cards — each row is a
431
+ card of label:value pairs, actions beneath. Loading + empty states mirror
432
+ the desktop table (message stays inside the card region). */}
433
+ <div className="space-y-3 sm:hidden">
434
+ {loading ? (
435
+ <div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
436
+ <Loader2 className="size-4 animate-spin" />
437
+ Loading…
438
+ </div>
439
+ ) : rows.length === 0 ? (
440
+ <p className="py-8 text-center text-sm text-muted-foreground">
441
+ {search ? `No records found for “${search}”.` : emptyLabel}
442
+ </p>
443
+ ) : (
444
+ rows.map((row, i) => (
445
+ <div
446
+ key={typeof row.id === 'string' ? row.id : i}
447
+ className="rounded-lg border border-border bg-background p-3"
448
+ >
449
+ <dl className="space-y-2">
450
+ {columns.map((col) => (
451
+ <div
452
+ key={col.key ?? col.label}
453
+ className="flex items-start justify-between gap-3"
454
+ >
455
+ <dt className="font-mono text-[0.62rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
456
+ {col.label}
457
+ </dt>
458
+ <dd className="min-w-0 text-right text-sm text-foreground/90">
459
+ {cellValue(col, row)}
460
+ </dd>
461
+ </div>
462
+ ))}
463
+ </dl>
464
+ {hasActions && (
465
+ <div className="mt-3 border-t border-border pt-2">
466
+ <RowActions row={row} onEdit={onEdit} onDelete={onDelete} />
467
+ </div>
468
+ )}
469
+ </div>
470
+ ))
471
+ )}
472
+ </div>
473
+ </>
474
+ )}
475
+ {/* The pager now lives OUTSIDE the card — render <EntityListPager/> below the
476
+ <EntityList/> in the page. */}
477
+ </CardContent>
478
+ </Card>
479
+ );
480
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * PageHeader — the top-of-page header for the management screens: a bold page title
3
+ * with a breadcrumb underneath (replacing the old one-line sub-description). The
4
+ * breadcrumb moved OUT of the app header (top bar) and down here so it sits with the
5
+ * content it labels.
6
+ *
7
+ * The breadcrumb reads "Section › Page" (e.g. Identity › Companies) — the section is a
8
+ * plain muted segment, the current page is emphasized. The leading icon sits beside the
9
+ * HEADING (not the breadcrumb), echoing the app's identity motif. When no `section` is
10
+ * given the breadcrumb shows just the page crumb.
11
+ *
12
+ * Props:
13
+ * title — the page heading (also the last, emphasized breadcrumb crumb)
14
+ * section — optional parent section label (e.g. "Identity") shown before the page
15
+ * icon — optional lucide icon shown beside the heading (default Shield)
16
+ * actions — optional node rendered right-aligned INLINE with the title on desktop;
17
+ * wraps below the title on small screens (e.g. a list's search + Add button).
18
+ */
19
+ import { ChevronRight, Shield } from 'lucide-react';
20
+
21
+ export default function PageHeader({ title, section, icon: Icon = Shield, actions }) {
22
+ const titleAndCrumb = (
23
+ <div className="min-w-0">
24
+ <h1 className="flex items-center gap-2 text-xl font-bold tracking-tight">
25
+ <Icon className="size-5 text-primary" />
26
+ {title}
27
+ </h1>
28
+ {/* Breadcrumb (relocated from the app header): Section › Page — no leading icon. */}
29
+ <nav aria-label="Breadcrumb" className="mt-1 flex items-center gap-2 text-sm text-muted-foreground">
30
+ {section ? (
31
+ <>
32
+ <span>{section}</span>
33
+ <ChevronRight className="size-3" />
34
+ <span className="font-medium text-foreground">{title}</span>
35
+ </>
36
+ ) : (
37
+ <span className="font-medium text-foreground">{title}</span>
38
+ )}
39
+ </nav>
40
+ </div>
41
+ );
42
+
43
+ // Desktop: title+breadcrumb (left) and actions (right) on ONE row, vertically centred.
44
+ // Mobile (≤640px): stack — title, then breadcrumb, then the actions BELOW (full-width),
45
+ // achieved by the flex-col order (actions come after the title/crumb block).
46
+ return (
47
+ <div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
48
+ {titleAndCrumb}
49
+ {actions && (
50
+ <div className="flex shrink-0 items-center gap-2 max-sm:w-full">{actions}</div>
51
+ )}
52
+ </div>
53
+ );
54
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * CollapsibleGroup — a lightweight controlled collapsible for the Assign-access dialog's
3
+ * "permissions by object" groups (the design's accordion, built with our tokens since the
4
+ * component library has no accordion). Header bar (caret + title + an "X of Y" count badge)
5
+ * toggles the body. Purely presentational + controlled; the parent owns `open`.
6
+ */
7
+ import { ChevronRight } from 'lucide-react';
8
+ import { cn } from '@/lib/utils';
9
+ import { Badge } from '@/components/ui/badge';
10
+
11
+ export default function CollapsibleGroup({ title, count, total, open, onToggle, right, children }) {
12
+ return (
13
+ <div className="rounded-md border border-border">
14
+ <button
15
+ type="button"
16
+ onClick={onToggle}
17
+ className="flex w-full items-center gap-2 rounded-t-md bg-muted/60 px-3 py-2 text-left text-sm font-medium text-foreground hover:bg-muted"
18
+ aria-expanded={open}
19
+ >
20
+ <ChevronRight
21
+ className={cn('size-4 shrink-0 text-muted-foreground transition-transform', open && 'rotate-90')}
22
+ />
23
+ <span className="flex-1">{title}</span>
24
+ {typeof count === 'number' && typeof total === 'number' && (
25
+ <Badge variant="secondary" className="rounded-full font-normal">
26
+ {count} of {total}
27
+ </Badge>
28
+ )}
29
+ {right}
30
+ </button>
31
+ {open && <div className="border-t border-border p-3">{children}</div>}
32
+ </div>
33
+ );
34
+ }