feat: add role user modules and shared table updates
This commit is contained in:
508
src/app/(modules)/roles/page.tsx
Normal file
508
src/app/(modules)/roles/page.tsx
Normal file
@@ -0,0 +1,508 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Edit, Loader2, Plus, RotateCcw, ShieldCheck } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import { PoweredBy } from '@/components/powered-by';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from '@/components/ui/sheet';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { permissionService, roleService } from '@/services/api';
|
||||||
|
import { useAppStore } from '@/store/app.store';
|
||||||
|
import type { PermissionTreeItem, PermissionTreeNode, Role } from '@/types';
|
||||||
|
|
||||||
|
type StatusFilter = 'all' | 'active' | 'inactive';
|
||||||
|
|
||||||
|
interface RoleFormState {
|
||||||
|
id?: number;
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
description: string;
|
||||||
|
permission_ids: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyRoleForm: RoleFormState = {
|
||||||
|
name: '',
|
||||||
|
display_name: '',
|
||||||
|
description: '',
|
||||||
|
permission_ids: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapPermissionTree(nodes: PermissionTreeNode[]): PermissionTreeItem[] {
|
||||||
|
return nodes.map((node) => ({
|
||||||
|
id: node.id,
|
||||||
|
key: node.slug || node.name,
|
||||||
|
label: node.display_name || node.name,
|
||||||
|
description: node.description,
|
||||||
|
type: node.type,
|
||||||
|
isGrantedByDefault: Boolean(node.is_granted_by_default),
|
||||||
|
children: mapPermissionTree(node.children || []),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectNodeIds(node: PermissionTreeItem): number[] {
|
||||||
|
return [node.id, ...node.children.flatMap(collectNodeIds)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDefaultIds(nodes: PermissionTreeItem[]): number[] {
|
||||||
|
return nodes.flatMap((node) => [
|
||||||
|
...(node.isGrantedByDefault ? [node.id] : []),
|
||||||
|
...collectDefaultIds(node.children),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectIdsByKeys(nodes: PermissionTreeItem[], keys: string[]): number[] {
|
||||||
|
const selectedKeys = new Set(keys);
|
||||||
|
return nodes.flatMap((node) => [
|
||||||
|
...(selectedKeys.has(node.key) ? [node.id] : []),
|
||||||
|
...collectIdsByKeys(node.children, keys),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PermissionCheckbox({
|
||||||
|
checked,
|
||||||
|
indeterminate,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
indeterminate: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current) {
|
||||||
|
ref.current.indeterminate = indeterminate;
|
||||||
|
}
|
||||||
|
}, [indeterminate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(event) => onChange(event.target.checked)}
|
||||||
|
className="size-4 rounded border-border accent-primary"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PermissionTreeNodeRow({
|
||||||
|
node,
|
||||||
|
selectedIds,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
node: PermissionTreeItem;
|
||||||
|
selectedIds: Set<number>;
|
||||||
|
onToggle: (node: PermissionTreeItem, checked: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const nodeIds = collectNodeIds(node);
|
||||||
|
const checkedCount = nodeIds.filter((id) => selectedIds.has(id)).length;
|
||||||
|
const checked = checkedCount === nodeIds.length;
|
||||||
|
const indeterminate = checkedCount > 0 && !checked;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="space-y-2">
|
||||||
|
<div className="flex items-start gap-3 rounded-md border border-border/60 bg-background px-3 py-2">
|
||||||
|
<PermissionCheckbox
|
||||||
|
checked={checked}
|
||||||
|
indeterminate={indeterminate}
|
||||||
|
onChange={(isChecked) => onToggle(node, isChecked)}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">{node.label}</span>
|
||||||
|
<Badge variant="outline" className="text-[10px] capitalize">
|
||||||
|
{node.type}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{node.description ? (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{node.description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{node.children.length > 0 ? (
|
||||||
|
<ul className="ml-5 space-y-2 border-l border-border/70 pl-3">
|
||||||
|
{node.children.map((child) => (
|
||||||
|
<PermissionTreeNodeRow
|
||||||
|
key={child.id}
|
||||||
|
node={child}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
onToggle={onToggle}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PermissionTree({
|
||||||
|
items,
|
||||||
|
selectedIds,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
items: PermissionTreeItem[];
|
||||||
|
selectedIds: number[];
|
||||||
|
onChange: (ids: number[]) => void;
|
||||||
|
}) {
|
||||||
|
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
||||||
|
|
||||||
|
const handleToggle = (node: PermissionTreeItem, checked: boolean) => {
|
||||||
|
const next = new Set(selectedSet);
|
||||||
|
collectNodeIds(node).forEach((id) => {
|
||||||
|
if (checked) {
|
||||||
|
next.add(id);
|
||||||
|
} else {
|
||||||
|
next.delete(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onChange(Array.from(next));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||||
|
No permissions available.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{items.map((node) => (
|
||||||
|
<PermissionTreeNodeRow
|
||||||
|
key={node.id}
|
||||||
|
node={node}
|
||||||
|
selectedIds={selectedSet}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RolesPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const organizationId = useAppStore((state) => state.user?.organization_id ?? null);
|
||||||
|
const [skip, setSkip] = useState(0);
|
||||||
|
const [limit] = useState(10);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||||
|
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||||
|
const [form, setForm] = useState<RoleFormState>(emptyRoleForm);
|
||||||
|
|
||||||
|
const rolesQuery = useQuery({
|
||||||
|
queryKey: ['roles', { skip, limit, searchTerm, statusFilter }],
|
||||||
|
queryFn: () =>
|
||||||
|
roleService.getRoles({
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
search_term: searchTerm || undefined,
|
||||||
|
effective_status:
|
||||||
|
statusFilter === 'all' ? undefined : statusFilter === 'active' ? true : false,
|
||||||
|
sort_by: 'created_at',
|
||||||
|
sort_order: 'desc',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const permissionsQuery = useQuery({
|
||||||
|
queryKey: ['permissions', 'tree'],
|
||||||
|
queryFn: permissionService.getPermissions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const permissionTree = useMemo(
|
||||||
|
() => mapPermissionTree(permissionsQuery.data?.tree || []),
|
||||||
|
[permissionsQuery.data?.tree],
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
roleService.saveRole({
|
||||||
|
id: form.id,
|
||||||
|
name: form.name.trim(),
|
||||||
|
display_name: form.display_name.trim(),
|
||||||
|
description: form.description.trim(),
|
||||||
|
organization_id: organizationId,
|
||||||
|
permission_ids: form.permission_ids,
|
||||||
|
is_default: false,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(form.id ? 'Role updated' : 'Role created');
|
||||||
|
setIsSheetOpen(false);
|
||||||
|
setForm(emptyRoleForm);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['roles'] });
|
||||||
|
},
|
||||||
|
onError: () => toast.error(form.id ? 'Failed to update role' : 'Failed to create role'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusMutation = useMutation({
|
||||||
|
mutationFn: (role: Role) => roleService.updateRoleStatus(role.id, !role.effective_status),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Role status updated');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['roles'] });
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to update role status'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setForm({ ...emptyRoleForm, permission_ids: collectDefaultIds(permissionTree) });
|
||||||
|
setIsSheetOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (role: Role) => {
|
||||||
|
setForm({
|
||||||
|
id: role.id,
|
||||||
|
name: role.name || '',
|
||||||
|
display_name: role.display_name || '',
|
||||||
|
description: role.description || '',
|
||||||
|
permission_ids: collectIdsByKeys(permissionTree, role.permissions || []),
|
||||||
|
});
|
||||||
|
setIsSheetOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!form.name.trim() || !form.display_name.trim()) {
|
||||||
|
toast.error('Role name and display name are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.permission_ids.length === 0) {
|
||||||
|
toast.error('Select at least one permission');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const total = rolesQuery.data?.total ?? 0;
|
||||||
|
const roles = rolesQuery.data?.items ?? [];
|
||||||
|
const columns: ColumnDef<Role>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'display_name',
|
||||||
|
header: 'Display Name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'description',
|
||||||
|
header: 'Description',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="block max-w-sm truncate text-muted-foreground">
|
||||||
|
{row.original.description || '-'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'user_count',
|
||||||
|
header: 'Users',
|
||||||
|
cell: ({ row }) => row.original.user_count ?? 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'effective_status',
|
||||||
|
header: 'Status',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant={row.original.effective_status ? 'default' : 'secondary'}>
|
||||||
|
{row.original.effective_status ? 'Active' : 'Inactive'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: () => <div className="text-right">Actions</div>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const role = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => openEdit(role)}>
|
||||||
|
<Edit className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={statusMutation.isPending}
|
||||||
|
onClick={() => statusMutation.mutate(role)}
|
||||||
|
>
|
||||||
|
<RotateCcw className="mr-2 size-4" />
|
||||||
|
{role.effective_status ? 'Deactivate' : 'Activate'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const toolbar = (
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<Input
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSearchTerm(event.target.value);
|
||||||
|
setSkip(0);
|
||||||
|
}}
|
||||||
|
placeholder="Search roles"
|
||||||
|
className="md:max-w-sm"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setStatusFilter(value as StatusFilter);
|
||||||
|
setSkip(0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="md:w-44">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All status</SelectItem>
|
||||||
|
<SelectItem value="active">Active</SelectItem>
|
||||||
|
<SelectItem value="inactive">Inactive</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<main className="relative z-10 space-y-5">
|
||||||
|
<PageHeader
|
||||||
|
title="Role Management"
|
||||||
|
description="Manage roles and permission access"
|
||||||
|
icon={ShieldCheck}
|
||||||
|
actions={
|
||||||
|
<Button onClick={openCreate}>
|
||||||
|
<Plus className="mr-2 size-4" />
|
||||||
|
Add Role
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
title="Roles"
|
||||||
|
columns={columns}
|
||||||
|
data={roles}
|
||||||
|
isLoading={rolesQuery.isLoading}
|
||||||
|
toolbar={toolbar}
|
||||||
|
emptyTitle="No roles found."
|
||||||
|
pagination={{
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
totalItems: total,
|
||||||
|
onPageChange: setSkip,
|
||||||
|
onLimitChange: () => undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PoweredBy />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
||||||
|
<SheetContent className="w-full overflow-y-auto sm:max-w-2xl">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{form.id ? 'Edit Role' : 'Create Role'}</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
Assign the role details and permission access for this organization.
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-1 flex-col gap-5 px-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="role-name">Role Name</Label>
|
||||||
|
<Input
|
||||||
|
id="role-name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="role-display-name">Display Name</Label>
|
||||||
|
<Input
|
||||||
|
id="role-display-name"
|
||||||
|
value={form.display_name}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((prev) => ({ ...prev, display_name: event.target.value }))
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="role-description">Description</Label>
|
||||||
|
<textarea
|
||||||
|
id="role-description"
|
||||||
|
value={form.description}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((prev) => ({ ...prev, description: event.target.value }))
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<Label>Permissions</Label>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{form.permission_ids.length} selected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{permissionsQuery.isLoading ? (
|
||||||
|
<div className="rounded-md border p-6 text-sm text-muted-foreground">
|
||||||
|
Loading permissions...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PermissionTree
|
||||||
|
items={permissionTree}
|
||||||
|
selectedIds={form.permission_ids}
|
||||||
|
onChange={(ids) => setForm((prev) => ({ ...prev, permission_ids: ids }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SheetFooter className="px-0">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsSheetOpen(false)}
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={saveMutation.isPending}>
|
||||||
|
{saveMutation.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
||||||
|
{form.id ? 'Update Role' : 'Create Role'}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</form>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
415
src/app/(modules)/users/page.tsx
Normal file
415
src/app/(modules)/users/page.tsx
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FormEvent, useMemo, useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Edit, Loader2, Plus, RotateCcw, Users } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import { PoweredBy } from '@/components/powered-by';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from '@/components/ui/sheet';
|
||||||
|
import { roleService, userService } from '@/services/api';
|
||||||
|
import type { AdministrationUser, UserStatus } from '@/types';
|
||||||
|
|
||||||
|
type StatusFilter = 'all' | UserStatus;
|
||||||
|
|
||||||
|
interface UserFormState {
|
||||||
|
id?: number;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
email: string;
|
||||||
|
phone_number: string;
|
||||||
|
role_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyUserForm: UserFormState = {
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
email: '',
|
||||||
|
phone_number: '',
|
||||||
|
role_id: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(value?: string | null) {
|
||||||
|
if (!value) return '-';
|
||||||
|
return new Intl.DateTimeFormat('en-IN', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextStatus(status: UserStatus): 'active' | 'inactive' {
|
||||||
|
return status === 'active' ? 'inactive' : 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [skip, setSkip] = useState(0);
|
||||||
|
const [limit] = useState(10);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||||
|
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||||
|
const [form, setForm] = useState<UserFormState>(emptyUserForm);
|
||||||
|
|
||||||
|
const usersQuery = useQuery({
|
||||||
|
queryKey: ['users', { skip, limit, searchTerm, statusFilter }],
|
||||||
|
queryFn: () =>
|
||||||
|
userService.getUsers({
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
search_term: searchTerm || undefined,
|
||||||
|
effective_status: statusFilter === 'all' ? undefined : statusFilter,
|
||||||
|
sort_by: 'created_at',
|
||||||
|
sort_order: 'desc',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rolesQuery = useQuery({
|
||||||
|
queryKey: ['roles', 'lookup'],
|
||||||
|
queryFn: () =>
|
||||||
|
roleService.getRoles({
|
||||||
|
skip: 0,
|
||||||
|
limit: 100,
|
||||||
|
effective_status: true,
|
||||||
|
sort_by: 'display_name',
|
||||||
|
sort_order: 'asc',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const roleOptions = useMemo(() => rolesQuery.data?.items || [], [rolesQuery.data?.items]);
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
userService.saveUser({
|
||||||
|
id: form.id,
|
||||||
|
email: form.email.trim(),
|
||||||
|
first_name: form.first_name.trim(),
|
||||||
|
last_name: form.last_name.trim(),
|
||||||
|
phone_number: form.phone_number.trim() || undefined,
|
||||||
|
roles: [Number(form.role_id)],
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(form.id ? 'User updated' : 'User created');
|
||||||
|
setIsSheetOpen(false);
|
||||||
|
setForm(emptyUserForm);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
},
|
||||||
|
onError: () => toast.error(form.id ? 'Failed to update user' : 'Failed to create user'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusMutation = useMutation({
|
||||||
|
mutationFn: (user: AdministrationUser) =>
|
||||||
|
userService.updateUserStatus(user.id, getNextStatus(user.effective_status)),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('User status updated');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to update user status'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setForm(emptyUserForm);
|
||||||
|
setIsSheetOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (user: AdministrationUser) => {
|
||||||
|
const roleId = user.role_ids?.[0] ?? user.roles?.[0]?.id;
|
||||||
|
setForm({
|
||||||
|
id: user.id,
|
||||||
|
first_name: user.first_name || '',
|
||||||
|
last_name: user.last_name || '',
|
||||||
|
email: user.email || '',
|
||||||
|
phone_number: user.phone_number || '',
|
||||||
|
role_id: roleId ? String(roleId) : '',
|
||||||
|
});
|
||||||
|
setIsSheetOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!form.first_name.trim() || !form.last_name.trim() || !form.email.trim()) {
|
||||||
|
toast.error('First name, last name and email are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.role_id) {
|
||||||
|
toast.error('Select a role');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const users = usersQuery.data?.items ?? [];
|
||||||
|
const total = usersQuery.data?.total ?? 0;
|
||||||
|
const columns: ColumnDef<AdministrationUser>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'first_name',
|
||||||
|
header: 'Name',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-medium">
|
||||||
|
{[row.original.first_name, row.original.last_name].filter(Boolean).join(' ') ||
|
||||||
|
row.original.username}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'email',
|
||||||
|
header: 'Email',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'phone_number',
|
||||||
|
header: 'Phone',
|
||||||
|
cell: ({ row }) => row.original.phone_number || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'roles',
|
||||||
|
header: 'Roles',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{row.original.roles?.length ? (
|
||||||
|
row.original.roles.map((role) => (
|
||||||
|
<Badge key={role.id ?? role.name} variant="outline">
|
||||||
|
{role.display_name || role.name}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'created_at',
|
||||||
|
header: 'Created',
|
||||||
|
cell: ({ row }) => formatDate(row.original.created_at),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'effective_status',
|
||||||
|
header: 'Status',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant={row.original.effective_status === 'active' ? 'default' : 'secondary'}>
|
||||||
|
{row.original.effective_status}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: () => <div className="text-right">Actions</div>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const user = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => openEdit(user)}>
|
||||||
|
<Edit className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={statusMutation.isPending || user.effective_status === 'pending'}
|
||||||
|
onClick={() => statusMutation.mutate(user)}
|
||||||
|
>
|
||||||
|
<RotateCcw className="mr-2 size-4" />
|
||||||
|
{user.effective_status === 'active' ? 'Deactivate' : 'Activate'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const toolbar = (
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<Input
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSearchTerm(event.target.value);
|
||||||
|
setSkip(0);
|
||||||
|
}}
|
||||||
|
placeholder="Search users"
|
||||||
|
className="md:max-w-sm"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setStatusFilter(value as StatusFilter);
|
||||||
|
setSkip(0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="md:w-44">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All status</SelectItem>
|
||||||
|
<SelectItem value="active">Active</SelectItem>
|
||||||
|
<SelectItem value="inactive">Inactive</SelectItem>
|
||||||
|
<SelectItem value="pending">Pending</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<main className="relative z-10 space-y-5">
|
||||||
|
<PageHeader
|
||||||
|
title="User Management"
|
||||||
|
description="Manage users and role assignment"
|
||||||
|
icon={Users}
|
||||||
|
actions={
|
||||||
|
<Button onClick={openCreate}>
|
||||||
|
<Plus className="mr-2 size-4" />
|
||||||
|
Add User
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
|
<div className="rounded-lg border bg-card p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Total</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold">{total}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-card p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Active</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold">{usersQuery.data?.active_count ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-card p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Inactive</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold">{usersQuery.data?.inactive_count ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-card p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Pending</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold">{usersQuery.data?.pending_count ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
title="Users"
|
||||||
|
columns={columns}
|
||||||
|
data={users}
|
||||||
|
isLoading={usersQuery.isLoading}
|
||||||
|
toolbar={toolbar}
|
||||||
|
emptyTitle="No users found."
|
||||||
|
pagination={{
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
totalItems: total,
|
||||||
|
onPageChange: setSkip,
|
||||||
|
onLimitChange: () => undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PoweredBy />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
||||||
|
<SheetContent className="w-full overflow-y-auto sm:max-w-xl">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{form.id ? 'Edit User' : 'Create User'}</SheetTitle>
|
||||||
|
<SheetDescription>Assign user details and a role.</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-1 flex-col gap-5 px-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="first-name">First Name</Label>
|
||||||
|
<Input
|
||||||
|
id="first-name"
|
||||||
|
value={form.first_name}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((prev) => ({ ...prev, first_name: event.target.value }))
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="last-name">Last Name</Label>
|
||||||
|
<Input
|
||||||
|
id="last-name"
|
||||||
|
value={form.last_name}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((prev) => ({ ...prev, last_name: event.target.value }))
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(event) => setForm((prev) => ({ ...prev, email: event.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="phone-number">Phone Number</Label>
|
||||||
|
<Input
|
||||||
|
id="phone-number"
|
||||||
|
value={form.phone_number}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((prev) => ({ ...prev, phone_number: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Role</Label>
|
||||||
|
<Select
|
||||||
|
value={form.role_id}
|
||||||
|
onValueChange={(value) => setForm((prev) => ({ ...prev, role_id: value }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={rolesQuery.isLoading ? 'Loading roles...' : 'Select role'} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{roleOptions.map((role) => (
|
||||||
|
<SelectItem key={role.id} value={String(role.id)}>
|
||||||
|
{role.display_name || role.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SheetFooter className="px-0">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsSheetOpen(false)}
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={saveMutation.isPending}>
|
||||||
|
{saveMutation.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
||||||
|
{form.id ? 'Update User' : 'Create User'}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</form>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { LayoutDashboard, Plus, Layers, Package, Milestone } from 'lucide-react';
|
import { LayoutDashboard, Plus, Layers, Package, Milestone, ShieldCheck, Users } from 'lucide-react';
|
||||||
import { NavUser } from '@/components/nav-user';
|
import { NavUser } from '@/components/nav-user';
|
||||||
import { ROUTES } from '@/utils/routes';
|
import { ROUTES } from '@/utils/routes';
|
||||||
import {
|
import {
|
||||||
@@ -47,6 +47,16 @@ const data = {
|
|||||||
url: ROUTES.CHAINAGE,
|
url: ROUTES.CHAINAGE,
|
||||||
icon: Milestone,
|
icon: Milestone,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Roles',
|
||||||
|
url: ROUTES.ROLES,
|
||||||
|
icon: ShieldCheck,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Users',
|
||||||
|
url: ROUTES.USERS,
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,70 +1,38 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { Table } from '@tanstack/react-table';
|
import { Table } from '@tanstack/react-table';
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
|
||||||
|
|
||||||
export function TableFooter<TData>({ table }: { table: Table<TData> }) {
|
export function TableFooter<TData>({
|
||||||
|
table,
|
||||||
|
totalItems,
|
||||||
|
}: {
|
||||||
|
table: Table<TData>;
|
||||||
|
totalItems: number;
|
||||||
|
}) {
|
||||||
|
const rows = table.getRowModel().rows.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border/40 bg-muted/5">
|
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
|
||||||
<div className="flex items-center gap-6">
|
<span>
|
||||||
<div className="flex items-center space-x-2">
|
Showing {rows} of {totalItems}
|
||||||
<p className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider">
|
</span>
|
||||||
Rows per page
|
<div className="flex gap-2">
|
||||||
</p>
|
|
||||||
<Select
|
|
||||||
value={`${table.getState().pagination.pageSize}`}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
table.setPageSize(Number(value));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-[70px] bg-transparent border-border/40 text-xs font-semibold">
|
|
||||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent side="top" className="min-w-[70px]">
|
|
||||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
|
||||||
<SelectItem key={pageSize} value={`${pageSize}`} className="text-xs">
|
|
||||||
{pageSize}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-8">
|
|
||||||
<div className="flex items-center text-[11px] font-bold text-muted-foreground uppercase tracking-widest gap-1">
|
|
||||||
<span className="text-foreground">Page {table.getState().pagination.pageIndex + 1}</span>
|
|
||||||
<span className="opacity-40">/</span>
|
|
||||||
<span>{table.getPageCount() || 1}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-8 w-8 p-0 border-border/40 bg-transparent hover:bg-muted/50 transition-colors"
|
size="sm"
|
||||||
onClick={() => table.previousPage()}
|
onClick={() => table.previousPage()}
|
||||||
disabled={!table.getCanPreviousPage()}
|
disabled={!table.getCanPreviousPage()}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Go to previous page</span>
|
Previous
|
||||||
<ChevronLeft className="h-4 w-4 opacity-70" />
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-8 w-8 p-0 border-border/40 bg-transparent hover:bg-muted/50 transition-colors"
|
size="sm"
|
||||||
onClick={() => table.nextPage()}
|
onClick={() => table.nextPage()}
|
||||||
disabled={!table.getCanNextPage()}
|
disabled={!table.getCanNextPage()}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Go to next page</span>
|
Next
|
||||||
<ChevronRight className="h-4 w-4 opacity-70" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,45 +1,40 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import SearchBar from './SearchBar';
|
import { Plus } from 'lucide-react';
|
||||||
import { FolderPlus, Settings2, SlidersHorizontal } from 'lucide-react';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
|
|
||||||
interface TopHeaderProps {
|
interface TopHeaderProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
itemCount?: number;
|
itemCount?: number;
|
||||||
onAddNew?: () => void;
|
onAddNew?: () => void;
|
||||||
addButtonText?: string;
|
addButtonText?: string;
|
||||||
|
toolbar?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TopHeader = ({ title, itemCount, onAddNew, addButtonText = 'Add New' }: TopHeaderProps) => {
|
const TopHeader = ({
|
||||||
|
title,
|
||||||
|
itemCount,
|
||||||
|
onAddNew,
|
||||||
|
addButtonText = 'Add New',
|
||||||
|
toolbar,
|
||||||
|
}: TopHeaderProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1 p-3 w-full">
|
<div className="space-y-4">
|
||||||
{/* Connected Summary Block */}
|
{(title || onAddNew) && (
|
||||||
<div className="w-full bg-muted/10 border border-border/50 rounded-lg p-4 flex items-center">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="flex items-center gap-2 text-muted-foreground font-semibold tracking-tight">
|
<div>
|
||||||
<span className="text-base text-foreground/80">Total {title || 'Items'} :</span>
|
{title ? <h2 className="text-base font-semibold">{title}</h2> : null}
|
||||||
<span className="text-primary font-bold text-lg">{itemCount || 0}</span>
|
<p className="text-sm text-muted-foreground">Total {itemCount ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
{onAddNew ? (
|
||||||
|
<Button onClick={onAddNew}>
|
||||||
|
<Plus className="mr-2 size-4" />
|
||||||
|
{addButtonText}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
{toolbar ? <div>{toolbar}</div> : null}
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
{/* Search Bar Hidden for now as per requirement */}
|
|
||||||
{/* <div className="flex w-full max-w-sm">
|
|
||||||
<SearchBar />
|
|
||||||
</div> */}
|
|
||||||
|
|
||||||
{/* Add New Button moved to PageHeader */}
|
|
||||||
{/* {onAddNew && (
|
|
||||||
<Button
|
|
||||||
onClick={onAddNew}
|
|
||||||
className="h-11 px-6 rounded-xl bg-primary hover:bg-primary/90 text-primary-foreground font-bold text-sm shadow-md shadow-primary/20 transition-all hover:-translate-y-0.5"
|
|
||||||
>
|
|
||||||
<FolderPlus className="mr-2 h-5 w-5" />
|
|
||||||
{addButtonText}
|
|
||||||
</Button>
|
|
||||||
)} */}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,21 +2,20 @@
|
|||||||
import { flexRender } from '@tanstack/react-table';
|
import { flexRender } from '@tanstack/react-table';
|
||||||
import { TableHead, TableHeader as ShadTableHeader, TableRow } from '@/components/ui/table';
|
import { TableHead, TableHeader as ShadTableHeader, TableRow } from '@/components/ui/table';
|
||||||
import { Table } from '@tanstack/react-table';
|
import { Table } from '@tanstack/react-table';
|
||||||
import { ChevronDown, ChevronsUpDown } from 'lucide-react';
|
|
||||||
|
|
||||||
const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
|
const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
|
||||||
return (
|
return (
|
||||||
<ShadTableHeader>
|
<ShadTableHeader>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id} className="hover:bg-transparent border-b border-border/30">
|
<TableRow key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => {
|
{headerGroup.headers.map((header) => {
|
||||||
return (
|
return (
|
||||||
<TableHead
|
<TableHead
|
||||||
key={header.id}
|
key={header.id}
|
||||||
className="h-14 px-6 text-muted-foreground border-b border-border/30 font-medium text-sm"
|
className="h-10 px-2 text-foreground"
|
||||||
>
|
>
|
||||||
{header.isPlaceholder ? null : (
|
{header.isPlaceholder ? null : (
|
||||||
<div className="flex items-center gap-2 group cursor-pointer select-none">
|
<div className="flex items-center gap-2">
|
||||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,12 +13,11 @@ import {
|
|||||||
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table';
|
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Edit3, MoreHorizontal, Trash2 } from 'lucide-react';
|
import { Edit3, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
import TopHeader from './Header';
|
import TopHeader from './Header';
|
||||||
import TableHeader from './TableHeader';
|
import TableHeader from './TableHeader';
|
||||||
import { TableFooter } from './Footer';
|
import { TableFooter } from './Footer';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
export interface DataTableProps<TData, TValue> {
|
export interface DataTableProps<TData, TValue> {
|
||||||
columns: ColumnDef<TData, TValue>[];
|
columns: ColumnDef<TData, TValue>[];
|
||||||
@@ -29,6 +28,9 @@ export interface DataTableProps<TData, TValue> {
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onEdit?: (item: TData) => void;
|
onEdit?: (item: TData) => void;
|
||||||
onDelete?: (item: TData) => void;
|
onDelete?: (item: TData) => void;
|
||||||
|
toolbar?: React.ReactNode;
|
||||||
|
emptyTitle?: string;
|
||||||
|
emptyDescription?: string;
|
||||||
pagination?: {
|
pagination?: {
|
||||||
skip: number;
|
skip: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
@@ -47,6 +49,9 @@ export function DataTable<TData, TValue>({
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
toolbar,
|
||||||
|
emptyTitle = 'No results found.',
|
||||||
|
emptyDescription = 'Try adjusting your filters or search terms.',
|
||||||
pagination,
|
pagination,
|
||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
const [rowSelection, setRowSelection] = React.useState({});
|
const [rowSelection, setRowSelection] = React.useState({});
|
||||||
@@ -58,32 +63,34 @@ export function DataTable<TData, TValue>({
|
|||||||
if (onEdit || onDelete) {
|
if (onEdit || onDelete) {
|
||||||
cols.push({
|
cols.push({
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
header: () => <div className="text-right px-4">Action</div>,
|
header: () => <div className="text-right">Actions</div>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const item = row.original;
|
const item = row.original;
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end gap-3 px-4">
|
<div className="flex justify-end gap-2">
|
||||||
{onEdit && (
|
{onEdit && (
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onEdit(item);
|
onEdit(item);
|
||||||
}}
|
}}
|
||||||
className="flex items-center justify-center h-8 w-8 rounded-md text-primary hover:bg-foreground/10 transition-all duration-200"
|
|
||||||
>
|
>
|
||||||
<Edit3 className="h-4.5 w-4.5" />
|
<Edit3 className="size-4" />
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onDelete && (
|
{onDelete && (
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDelete(item);
|
onDelete(item);
|
||||||
}}
|
}}
|
||||||
className="flex items-center justify-center h-8 w-8 rounded-md text-red-500 hover:bg-foreground/10 transition-all duration-200"
|
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4.5 w-4.5" />
|
<Trash2 className="size-4 text-destructive" />
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -126,31 +133,28 @@ export function DataTable<TData, TValue>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border/50 bg-muted/10 p-1 space-y-6">
|
<section className="space-y-4 rounded-lg border bg-card p-4">
|
||||||
<div className="bg-background/40 dark:bg-background/60 backdrop-blur-xl rounded-xl border border-border/50 overflow-hidden transition-all duration-300">
|
|
||||||
<TopHeader
|
<TopHeader
|
||||||
title={title}
|
title={title}
|
||||||
itemCount={data.length}
|
itemCount={pagination?.totalItems ?? data.length}
|
||||||
onAddNew={onAddNew}
|
onAddNew={onAddNew}
|
||||||
addButtonText={addButtonText}
|
addButtonText={addButtonText}
|
||||||
|
toolbar={toolbar}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="px-6 py-2">
|
<div>
|
||||||
<Table
|
<Table
|
||||||
containerClassName="max-h-[calc(100vh-300px)] overflow-y-auto scrollbar-thin"
|
containerClassName="max-h-[calc(100vh-300px)] overflow-y-auto"
|
||||||
className="border-separate border-spacing-0"
|
className="border-separate border-spacing-0"
|
||||||
>
|
>
|
||||||
<TableHeader table={table} />
|
<TableHeader table={table} />
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
Array.from({ length: 5 }).map((_, idx) => (
|
Array.from({ length: 5 }).map((_, idx) => (
|
||||||
<TableRow
|
<TableRow key={idx}>
|
||||||
key={idx}
|
|
||||||
className="border-b border-border/30 last:border-0 hover:bg-muted/5"
|
|
||||||
>
|
|
||||||
{columns.map((_, colIdx) => (
|
{columns.map((_, colIdx) => (
|
||||||
<TableCell key={colIdx} className="px-6 py-6 border-b border-border/30">
|
<TableCell key={colIdx}>
|
||||||
<Skeleton className="h-4 w-full max-w-[140px] opacity-20" />
|
<Skeleton className="h-4 w-full max-w-[140px]" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -160,13 +164,9 @@ export function DataTable<TData, TValue>({
|
|||||||
<TableRow
|
<TableRow
|
||||||
key={row.id}
|
key={row.id}
|
||||||
data-state={row.getIsSelected() && 'selected'}
|
data-state={row.getIsSelected() && 'selected'}
|
||||||
className="group hover:bg-muted/10 transition-colors"
|
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell
|
<TableCell key={cell.id}>
|
||||||
key={cell.id}
|
|
||||||
className="px-4 py-3 border-b border-border/50 align-middle text-muted-foreground text-sm font-medium group-hover:text-foreground"
|
|
||||||
>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
@@ -174,12 +174,10 @@ export function DataTable<TData, TValue>({
|
|||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={columns.length} className="h-48 text-center">
|
<TableCell colSpan={columns.length} className="h-28 text-center">
|
||||||
<div className="flex flex-col items-center justify-center text-muted-foreground gap-1">
|
<div className="flex flex-col items-center justify-center text-muted-foreground gap-1">
|
||||||
<p className="font-bold text-sm tracking-tight">No results found.</p>
|
<p className="text-sm font-medium">{emptyTitle}</p>
|
||||||
<p className="text-xs opacity-60 font-medium">
|
<p className="text-xs">{emptyDescription}</p>
|
||||||
Try adjusting your filters or search terms.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -187,8 +185,7 @@ export function DataTable<TData, TValue>({
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
<TableFooter table={table} />
|
<TableFooter table={table} totalItems={pagination?.totalItems ?? data.length} />
|
||||||
</div>
|
</section>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export function AuthGuard({ children }: { children: ReactNode }) {
|
|||||||
if (!isInitialized) return;
|
if (!isInitialized) return;
|
||||||
|
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
console.debug('[auth] guard:redirect-login');
|
|
||||||
router.replace(ROUTES.LOGIN);
|
router.replace(ROUTES.LOGIN);
|
||||||
}
|
}
|
||||||
}, [accessToken, isInitialized, router]);
|
}, [accessToken, isInitialized, router]);
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export function GuestGuard({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isInitialized && accessToken && user) {
|
if (isInitialized && accessToken && user) {
|
||||||
console.debug('[auth] guest-guard:redirect-dashboard');
|
|
||||||
router.replace(ROUTES.DASHBOARD);
|
router.replace(ROUTES.DASHBOARD);
|
||||||
}
|
}
|
||||||
}, [accessToken, isInitialized, router, user]);
|
}, [accessToken, isInitialized, router, user]);
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export const useLoginForm = () => {
|
|||||||
|
|
||||||
const onSubmit = async (data: LoginPayload) => {
|
const onSubmit = async (data: LoginPayload) => {
|
||||||
try {
|
try {
|
||||||
console.debug('[auth] login:start');
|
|
||||||
const loginResponse = await authService.login({
|
const loginResponse = await authService.login({
|
||||||
email: data.email,
|
email: data.email,
|
||||||
password: data.password,
|
password: data.password,
|
||||||
@@ -40,9 +39,7 @@ export const useLoginForm = () => {
|
|||||||
|
|
||||||
setAccessToken(accessToken);
|
setAccessToken(accessToken);
|
||||||
setLoading();
|
setLoading();
|
||||||
console.debug('[auth] login:token-stored');
|
|
||||||
await initializeAuthenticatedApp();
|
await initializeAuthenticatedApp();
|
||||||
console.debug('[auth] login:initialized, redirecting', ROUTES.DASHBOARD);
|
|
||||||
|
|
||||||
toast.success('Login successful');
|
toast.success('Login successful');
|
||||||
router.push(ROUTES.DASHBOARD);
|
router.push(ROUTES.DASHBOARD);
|
||||||
|
|||||||
@@ -20,23 +20,16 @@ export default function AppInitializer({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const initialize = async () => {
|
const initialize = async () => {
|
||||||
if (!accessToken || user) {
|
if (!accessToken || user) {
|
||||||
console.debug('[auth] app-init:skip', {
|
|
||||||
hasToken: !!accessToken,
|
|
||||||
hasUser: !!user,
|
|
||||||
});
|
|
||||||
setLoaded();
|
setLoaded();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading();
|
setLoading();
|
||||||
console.debug('[auth] app-init:start');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await initializeAuthenticatedApp();
|
await initializeAuthenticatedApp();
|
||||||
console.debug('[auth] app-init:success');
|
|
||||||
} catch {
|
} catch {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
console.debug('[auth] app-init:failed, logging out');
|
|
||||||
logout();
|
logout();
|
||||||
clearApp();
|
clearApp();
|
||||||
setLoadError();
|
setLoadError();
|
||||||
|
|||||||
@@ -6,3 +6,6 @@ export * from './video.service';
|
|||||||
export * from './detection.service';
|
export * from './detection.service';
|
||||||
export * from './session.service';
|
export * from './session.service';
|
||||||
export { projectDataService } from './project.service';
|
export { projectDataService } from './project.service';
|
||||||
|
export * from './permission.service';
|
||||||
|
export * from './role.service';
|
||||||
|
export * from './user.service';
|
||||||
|
|||||||
9
src/services/api/permission.service.ts
Normal file
9
src/services/api/permission.service.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import axiosClient from '../axios/axios';
|
||||||
|
import type { PermissionResponse } from '@/types';
|
||||||
|
|
||||||
|
export const permissionService = {
|
||||||
|
getPermissions: async (): Promise<PermissionResponse> => {
|
||||||
|
const response = await axiosClient.get<PermissionResponse>('api/permissions/my-permissions');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
42
src/services/api/role.service.ts
Normal file
42
src/services/api/role.service.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import axiosClient from '../axios/axios';
|
||||||
|
import type { Role, RoleListParams, RoleListResponse, RoleRequest } from '@/types';
|
||||||
|
|
||||||
|
const ROLES_ENDPOINT = 'api/roles';
|
||||||
|
|
||||||
|
export const roleService = {
|
||||||
|
getRoles: async (params?: RoleListParams): Promise<RoleListResponse> => {
|
||||||
|
const response = await axiosClient.get<RoleListResponse>(ROLES_ENDPOINT, {
|
||||||
|
params: {
|
||||||
|
skip: params?.skip ?? 0,
|
||||||
|
limit: params?.limit ?? 10,
|
||||||
|
search_term: params?.search_term,
|
||||||
|
name: params?.name,
|
||||||
|
display_name: params?.display_name,
|
||||||
|
description: params?.description,
|
||||||
|
effective_status: params?.effective_status,
|
||||||
|
sort_by: params?.sort_by,
|
||||||
|
sort_order: params?.sort_order,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getRoleById: async (id: number): Promise<Role> => {
|
||||||
|
const response = await axiosClient.get<Role>(`${ROLES_ENDPOINT}/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
saveRole: async (payload: RoleRequest): Promise<Role> => {
|
||||||
|
const response = payload.id
|
||||||
|
? await axiosClient.put<Role>(ROLES_ENDPOINT, payload)
|
||||||
|
: await axiosClient.post<Role>(ROLES_ENDPOINT, payload);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateRoleStatus: async (id: number, isActive: boolean): Promise<Role> => {
|
||||||
|
const response = await axiosClient.patch<Role>(`${ROLES_ENDPOINT}/${id}/status`, {
|
||||||
|
is_active: isActive,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
41
src/services/api/user.service.ts
Normal file
41
src/services/api/user.service.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import axiosClient from '../axios/axios';
|
||||||
|
import type { AdministrationUser, UserListParams, UserListResponse, UserRequest } from '@/types';
|
||||||
|
|
||||||
|
const USERS_ENDPOINT = 'api/users';
|
||||||
|
|
||||||
|
export const userService = {
|
||||||
|
getUsers: async (params?: UserListParams): Promise<UserListResponse> => {
|
||||||
|
const response = await axiosClient.get<UserListResponse>(USERS_ENDPOINT, {
|
||||||
|
params: {
|
||||||
|
skip: params?.skip ?? 0,
|
||||||
|
limit: params?.limit ?? 10,
|
||||||
|
search_term: params?.search_term,
|
||||||
|
userName: params?.userName,
|
||||||
|
first_name: params?.first_name,
|
||||||
|
phone_number: params?.phone_number,
|
||||||
|
roles: params?.roles,
|
||||||
|
effective_status: params?.effective_status,
|
||||||
|
sort_by: params?.sort_by,
|
||||||
|
sort_order: params?.sort_order,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
saveUser: async (payload: UserRequest): Promise<AdministrationUser> => {
|
||||||
|
const response = payload.id
|
||||||
|
? await axiosClient.put<AdministrationUser>(USERS_ENDPOINT, payload)
|
||||||
|
: await axiosClient.post<AdministrationUser>(USERS_ENDPOINT, payload);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateUserStatus: async (
|
||||||
|
id: number,
|
||||||
|
status: 'active' | 'inactive',
|
||||||
|
): Promise<AdministrationUser> => {
|
||||||
|
const response = await axiosClient.patch<AdministrationUser>(`${USERS_ENDPOINT}/${id}/status`, {
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { PermissionTreeNode } from './permission';
|
||||||
|
|
||||||
export type AuthUser = {
|
export type AuthUser = {
|
||||||
id?: string | number;
|
id?: string | number;
|
||||||
username?: string;
|
username?: string;
|
||||||
@@ -49,7 +51,7 @@ export type MeResponse = {
|
|||||||
|
|
||||||
export type PermissionResponse = {
|
export type PermissionResponse = {
|
||||||
granted_permissions?: string[];
|
granted_permissions?: string[];
|
||||||
tree?: unknown[];
|
tree?: PermissionTreeNode[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LoginPayload = {
|
export type LoginPayload = {
|
||||||
|
|||||||
@@ -7,3 +7,6 @@ export * from './detection';
|
|||||||
export * from './analysis';
|
export * from './analysis';
|
||||||
export * from './session';
|
export * from './session';
|
||||||
export * from './auth.type';
|
export * from './auth.type';
|
||||||
|
export * from './permission';
|
||||||
|
export * from './role';
|
||||||
|
export * from './user';
|
||||||
|
|||||||
23
src/types/permission.ts
Normal file
23
src/types/permission.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export type PermissionNodeType = 'module' | 'resource' | 'action';
|
||||||
|
|
||||||
|
export interface PermissionTreeNode {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
type: PermissionNodeType;
|
||||||
|
display_name: string;
|
||||||
|
description: string;
|
||||||
|
parent_id: number | null;
|
||||||
|
is_granted_by_default?: boolean;
|
||||||
|
children: PermissionTreeNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionTreeItem {
|
||||||
|
id: number;
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
type: PermissionNodeType;
|
||||||
|
isGrantedByDefault: boolean;
|
||||||
|
children: PermissionTreeItem[];
|
||||||
|
}
|
||||||
46
src/types/role.ts
Normal file
46
src/types/role.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { PaginationParams } from './common';
|
||||||
|
|
||||||
|
export interface Role {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
description: string;
|
||||||
|
is_active: boolean;
|
||||||
|
is_system_role: boolean;
|
||||||
|
is_static: boolean;
|
||||||
|
is_default: boolean;
|
||||||
|
root_tenant_id: number | null;
|
||||||
|
organization_id: number | null;
|
||||||
|
organization_name: string | null;
|
||||||
|
organization_is_active: boolean;
|
||||||
|
effective_status: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
permissions?: string[];
|
||||||
|
user_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoleRequest {
|
||||||
|
id?: number;
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
description: string;
|
||||||
|
organization_id: number | null;
|
||||||
|
permission_ids: number[];
|
||||||
|
is_default: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoleListParams extends PaginationParams {
|
||||||
|
search_term?: string;
|
||||||
|
name?: string;
|
||||||
|
display_name?: string;
|
||||||
|
description?: string;
|
||||||
|
effective_status?: boolean;
|
||||||
|
sort_by?: string;
|
||||||
|
sort_order?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoleListResponse {
|
||||||
|
items: Role[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
61
src/types/user.ts
Normal file
61
src/types/user.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import type { PaginationParams } from './common';
|
||||||
|
|
||||||
|
export type UserStatus = 'active' | 'inactive' | 'pending';
|
||||||
|
|
||||||
|
export interface UserRole {
|
||||||
|
id?: number;
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
is_active: boolean;
|
||||||
|
effective_status: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdministrationUser {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
phone_number: string;
|
||||||
|
username: string;
|
||||||
|
root_tenant_id: number;
|
||||||
|
root_tenant_name: string;
|
||||||
|
organization_id: number;
|
||||||
|
organization_name: string;
|
||||||
|
status: UserStatus;
|
||||||
|
previous_status: UserStatus | null;
|
||||||
|
effective_status: UserStatus;
|
||||||
|
email_verified: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string | null;
|
||||||
|
roles: UserRole[];
|
||||||
|
role_ids?: number[];
|
||||||
|
profile_photo?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserRequest {
|
||||||
|
id?: number;
|
||||||
|
email: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
phone_number?: string;
|
||||||
|
roles: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserListParams extends PaginationParams {
|
||||||
|
search_term?: string;
|
||||||
|
userName?: string;
|
||||||
|
first_name?: string;
|
||||||
|
phone_number?: string;
|
||||||
|
roles?: string;
|
||||||
|
effective_status?: string;
|
||||||
|
sort_by?: string;
|
||||||
|
sort_order?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserListResponse {
|
||||||
|
items: AdministrationUser[];
|
||||||
|
total: number;
|
||||||
|
active_count: number;
|
||||||
|
inactive_count: number;
|
||||||
|
pending_count: number;
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ export const ROUTES = {
|
|||||||
PROJECT: '/project',
|
PROJECT: '/project',
|
||||||
PACKAGE: '/package',
|
PACKAGE: '/package',
|
||||||
CHAINAGE: '/segment',
|
CHAINAGE: '/segment',
|
||||||
|
ROLES: '/roles',
|
||||||
|
USERS: '/users',
|
||||||
ACCOUNT: '/account',
|
ACCOUNT: '/account',
|
||||||
UPLOAD: '/upload',
|
UPLOAD: '/upload',
|
||||||
RESULTS: '/results',
|
RESULTS: '/results',
|
||||||
|
|||||||
Reference in New Issue
Block a user