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.
- package/dist/add-frontend.js +107 -0
- package/dist/add-frontend.js.map +1 -0
- package/dist/index.js +132 -33
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/template/CLAUDE.md +51 -0
- package/template/README.md +43 -44
- package/template/_gitignore +6 -0
- package/template/frontend/FRONTEND.md +92 -0
- package/template/frontend/README.md +16 -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 +45 -0
- package/template/server/eslint.config.js +95 -0
- package/template/server/package.json +49 -0
- package/template/server/scripts/dev.mjs +51 -0
- package/template/server/scripts/sync-astram-guide.mjs +63 -0
- package/template/_env.example +0 -33
- package/template/package.json +0 -37
- /package/template/{src → server/src}/app.module.ts +0 -0
- /package/template/{src → server/src}/features/_gitkeep +0 -0
- /package/template/{src → server/src}/health/demo.controller.ts +0 -0
- /package/template/{src → server/src}/health/health.controller.ts +0 -0
- /package/template/{src → server/src}/main.ts +0 -0
- /package/template/{test → server/test}/enforcement.e2e.spec.ts +0 -0
- /package/template/{tsconfig.build.json → server/tsconfig.build.json} +0 -0
- /package/template/{tsconfig.json → server/tsconfig.json} +0 -0
- /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
|
+
}
|