create-astram-app 0.1.3 → 0.3.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 (88) 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 +191 -0
  16. package/template/frontend/src/auth/AuthContext.jsx +343 -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/cross-tenant/GrantForm.jsx +337 -0
  64. package/template/frontend/src/pages/cross-tenant/GrantsPage.jsx +237 -0
  65. package/template/frontend/src/pages/cross-tenant/InboxPage.jsx +179 -0
  66. package/template/frontend/src/pages/roles/RoleForm.jsx +99 -0
  67. package/template/frontend/src/pages/roles/RolesPage.jsx +207 -0
  68. package/template/frontend/src/pages/users/AssignmentsEditor.jsx +193 -0
  69. package/template/frontend/src/pages/users/MembershipCard.jsx +303 -0
  70. package/template/frontend/src/pages/users/PostingRow.jsx +264 -0
  71. package/template/frontend/src/pages/users/UserForm.jsx +167 -0
  72. package/template/frontend/src/pages/users/UsersPage.jsx +232 -0
  73. package/template/frontend/src/platform/httpClient.js +125 -0
  74. package/template/frontend/src/platform/returnPathStore.js +43 -0
  75. package/template/frontend/src/platform/sessionStore.js +29 -0
  76. package/template/frontend/src/platform/shieldTransport.js +172 -0
  77. package/template/frontend/src/platform/shieldTransport.test.mjs +245 -0
  78. package/template/frontend/src/shell/AppShell.jsx +237 -0
  79. package/template/frontend/src/shell/Sidebar.jsx +257 -0
  80. package/template/frontend/src/shell/nav.js +103 -0
  81. package/template/frontend/src/theme/ThemeProvider.jsx +14 -0
  82. package/template/frontend/src/theme/ThemeToggle.jsx +44 -0
  83. package/template/frontend/tsconfig.json +22 -0
  84. package/template/frontend/vite.config.js +30 -0
  85. package/template/server/_env.example +27 -15
  86. package/template/server/package.json +7 -4
  87. package/template/server/src/app.module.ts +28 -2
  88. package/template/server/src/health/demo.controller.ts +23 -12
@@ -0,0 +1,154 @@
1
+ /**
2
+ * EntityFormModal — the routed-modal overlay for the identity Add/Edit forms. Built on
3
+ * the shadcn Dialog primitive (`dialog.tsx`) so it inherits the app's light/dark
4
+ * theming, focus trap, and Escape-to-close.
5
+ *
6
+ * This is the "routed-modal" pattern: the modal is rendered by an Add/Edit CHILD ROUTE
7
+ * (e.g. /companies/add, /companies/edit/:uuid) under the list's layout, so the URL is
8
+ * bookmarkable and the back button closes it — while looking like a lightweight overlay.
9
+ * The list stays mounted behind it.
10
+ *
11
+ * The `children` are the form fields; the form wrapper submits on Enter (the primary is
12
+ * `type="submit"`), matching native dialog behavior. `busy` disables both buttons so a
13
+ * slow request can't be double-fired or cancelled mid-flight.
14
+ *
15
+ * Props:
16
+ * title — dialog heading
17
+ * description — optional sub-line
18
+ * open — controlled open state (the child route passes `true`)
19
+ * onClose — () => void (Escape / scrim / Cancel → typically navigate(-1))
20
+ * onSubmit — () => void (fired by Enter or the primary button; the parent runs the API)
21
+ * submitLabel — primary button label (default "Save")
22
+ * cancelLabel — ghost cancel label (default "Cancel")
23
+ * busy — request in flight → buttons disabled
24
+ * error — an error string shown above the buttons
25
+ * contentClassName — optional override for the dialog width (default a compact form);
26
+ * wider forms (e.g. the user edit page with its assignments editor)
27
+ * pass a roomier max-width.
28
+ * expandable — show the OCA-style expand/collapse (full-screen) toggle in the header
29
+ * (default true). Full-screen is the natural escape for the roomier
30
+ * assignments modal; small forms keep it but it's harmless.
31
+ * children — the form fields
32
+ *
33
+ * Overflow containment: the DialogContent is a CSS grid, and the form → body chain all
34
+ * carry `min-w-0` so a wide descendant (the assignments posting table) can shrink below
35
+ * its content width — which lets that table's own `overflow-x-auto` wrapper scroll it
36
+ * INSIDE the modal instead of forcing the modal past its `max-w`. The body also scrolls
37
+ * vertically (`overflow-y-auto` + a max-height) so a tall assignments editor never spills.
38
+ *
39
+ * Full-screen: the expand toggle flips a local `expanded` flag; when set, the content
40
+ * overrides its centered/bounded classes to fill the viewport (`inset-0 w-screen h-screen
41
+ * max-w-none rounded-none`, no translate) — the OCA `.dsa-modal--full` treatment.
42
+ */
43
+ import { useState } from 'react';
44
+ import { Loader2, Maximize2, Minimize2 } from 'lucide-react';
45
+ import { cn } from '@/lib/utils';
46
+ import { Button } from '@/components/ui/button';
47
+ import { Alert, AlertDescription } from '@/components/ui/alert';
48
+ import {
49
+ Dialog,
50
+ DialogContent,
51
+ DialogDescription,
52
+ DialogFooter,
53
+ DialogHeader,
54
+ DialogTitle,
55
+ } from '@/components/ui/dialog';
56
+
57
+ export default function EntityFormModal({
58
+ title,
59
+ description,
60
+ open,
61
+ onClose,
62
+ onSubmit,
63
+ submitLabel = 'Save',
64
+ cancelLabel = 'Cancel',
65
+ busy = false,
66
+ error = '',
67
+ contentClassName = 'sm:max-w-md',
68
+ expandable = true,
69
+ children,
70
+ }) {
71
+ const [expanded, setExpanded] = useState(false);
72
+
73
+ return (
74
+ <Dialog
75
+ open={open}
76
+ onOpenChange={(next) => {
77
+ // Close only via the dialog's own affordances, and never mid-request.
78
+ if (!next && !busy) onClose?.();
79
+ }}
80
+ >
81
+ <DialogContent
82
+ showCloseButton={!busy}
83
+ className={cn(
84
+ // `min-w-0` lets the grid's content track shrink below a wide child's
85
+ // intrinsic width (the grid min-width:auto trap), so the inner posting
86
+ // table's overflow-x-auto is what absorbs extra width — the modal never
87
+ // exceeds its max-width. `overflow-hidden` clips any residual bleed.
88
+ 'flex max-h-[calc(100vh-4rem)] min-w-0 flex-col overflow-hidden',
89
+ expanded
90
+ ? // OCA `.dsa-modal--full`: fill the viewport, square corners, no offset.
91
+ 'inset-0 h-screen max-h-screen w-screen max-w-none translate-x-0 translate-y-0 rounded-none sm:max-w-none'
92
+ : contentClassName,
93
+ )}
94
+ >
95
+ {/* A form wrapper so Enter in any field submits (fires onSubmit); the primary
96
+ button is type="submit". `min-w-0` + flex-col so the body can scroll and the
97
+ wide posting table shrinks rather than stretching the modal. */}
98
+ <form
99
+ onSubmit={(e) => {
100
+ e.preventDefault();
101
+ if (!busy) onSubmit?.();
102
+ }}
103
+ className="flex min-w-0 flex-1 flex-col overflow-hidden"
104
+ >
105
+ <DialogHeader>
106
+ <DialogTitle>{title}</DialogTitle>
107
+ {description && <DialogDescription>{description}</DialogDescription>}
108
+ </DialogHeader>
109
+
110
+ {/* Scrollable body: `min-w-0` propagates the shrink to children; `overflow-y-auto`
111
+ keeps a tall assignments editor inside the modal. */}
112
+ <div className="mt-4 mb-6 min-w-0 flex-1 space-y-4 overflow-y-auto">
113
+ {children}
114
+ {error && (
115
+ <Alert variant="destructive">
116
+ <AlertDescription>{error}</AlertDescription>
117
+ </Alert>
118
+ )}
119
+ </div>
120
+
121
+ <DialogFooter>
122
+ <Button
123
+ type="button"
124
+ variant="ghost"
125
+ onClick={onClose}
126
+ disabled={busy}
127
+ className="w-full sm:w-auto"
128
+ >
129
+ {cancelLabel}
130
+ </Button>
131
+ <Button type="submit" disabled={busy} className="w-full sm:w-auto">
132
+ {busy && <Loader2 className="size-4 animate-spin" />}
133
+ {submitLabel}
134
+ </Button>
135
+ </DialogFooter>
136
+ </form>
137
+
138
+ {/* Expand / collapse (full-screen) toggle — sits to the LEFT of the Dialog's own
139
+ close X (which is `absolute top-4 right-4`). OCA's modal expand affordance. */}
140
+ {expandable && (
141
+ <button
142
+ type="button"
143
+ onClick={() => setExpanded((v) => !v)}
144
+ aria-label={expanded ? 'Exit full screen' : 'Expand to full screen'}
145
+ title={expanded ? 'Exit full screen' : 'Expand to full screen'}
146
+ className="ring-offset-background focus:ring-ring absolute top-4 right-11 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
147
+ >
148
+ {expanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
149
+ </button>
150
+ )}
151
+ </DialogContent>
152
+ </Dialog>
153
+ );
154
+ }
@@ -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
+ }