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';
|
||||
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 { ROUTES } from '@/utils/routes';
|
||||
import {
|
||||
@@ -47,6 +47,16 @@ const data = {
|
||||
url: ROUTES.CHAINAGE,
|
||||
icon: Milestone,
|
||||
},
|
||||
{
|
||||
title: 'Roles',
|
||||
url: ROUTES.ROLES,
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
title: 'Users',
|
||||
url: ROUTES.USERS,
|
||||
icon: Users,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,71 +1,39 @@
|
||||
'use client';
|
||||
import { Table } from '@tanstack/react-table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
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 (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border/40 bg-muted/5">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider">
|
||||
Rows per page
|
||||
</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">
|
||||
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
|
||||
<span>
|
||||
Showing {rows} of {totalItems}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
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()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<ChevronLeft className="h-4 w-4 opacity-70" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
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()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<ChevronRight className="h-4 w-4 opacity-70" />
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,45 +1,40 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import SearchBar from './SearchBar';
|
||||
import { FolderPlus, Settings2, SlidersHorizontal } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
interface TopHeaderProps {
|
||||
title?: string;
|
||||
itemCount?: number;
|
||||
onAddNew?: () => void;
|
||||
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 (
|
||||
<div className="flex flex-col gap-1 p-3 w-full">
|
||||
{/* Connected Summary Block */}
|
||||
<div className="w-full bg-muted/10 border border-border/50 rounded-lg p-4 flex items-center">
|
||||
<div className="flex items-center gap-2 text-muted-foreground font-semibold tracking-tight">
|
||||
<span className="text-base text-foreground/80">Total {title || 'Items'} :</span>
|
||||
<span className="text-primary font-bold text-lg">{itemCount || 0}</span>
|
||||
<div className="space-y-4">
|
||||
{(title || onAddNew) && (
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
{title ? <h2 className="text-base font-semibold">{title}</h2> : null}
|
||||
<p className="text-sm text-muted-foreground">Total {itemCount ?? 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
{onAddNew ? (
|
||||
<Button onClick={onAddNew}>
|
||||
<Plus className="mr-2 size-4" />
|
||||
{addButtonText}
|
||||
</Button>
|
||||
)} */}
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{toolbar ? <div>{toolbar}</div> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,21 +2,20 @@
|
||||
import { flexRender } from '@tanstack/react-table';
|
||||
import { TableHead, TableHeader as ShadTableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Table } from '@tanstack/react-table';
|
||||
import { ChevronDown, ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
|
||||
return (
|
||||
<ShadTableHeader>
|
||||
{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) => {
|
||||
return (
|
||||
<TableHead
|
||||
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 : (
|
||||
<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())}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -13,12 +13,11 @@ import {
|
||||
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Edit3, MoreHorizontal, Trash2 } from 'lucide-react';
|
||||
import { Edit3, Trash2 } from 'lucide-react';
|
||||
|
||||
import TopHeader from './Header';
|
||||
import TableHeader from './TableHeader';
|
||||
import { TableFooter } from './Footer';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
@@ -29,6 +28,9 @@ export interface DataTableProps<TData, TValue> {
|
||||
isLoading?: boolean;
|
||||
onEdit?: (item: TData) => void;
|
||||
onDelete?: (item: TData) => void;
|
||||
toolbar?: React.ReactNode;
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
pagination?: {
|
||||
skip: number;
|
||||
limit: number;
|
||||
@@ -47,6 +49,9 @@ export function DataTable<TData, TValue>({
|
||||
isLoading = false,
|
||||
onEdit,
|
||||
onDelete,
|
||||
toolbar,
|
||||
emptyTitle = 'No results found.',
|
||||
emptyDescription = 'Try adjusting your filters or search terms.',
|
||||
pagination,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [rowSelection, setRowSelection] = React.useState({});
|
||||
@@ -58,32 +63,34 @@ export function DataTable<TData, TValue>({
|
||||
if (onEdit || onDelete) {
|
||||
cols.push({
|
||||
id: 'actions',
|
||||
header: () => <div className="text-right px-4">Action</div>,
|
||||
header: () => <div className="text-right">Actions</div>,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
return (
|
||||
<div className="flex justify-end gap-3 px-4">
|
||||
<div className="flex justify-end gap-2">
|
||||
{onEdit && (
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
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" />
|
||||
</button>
|
||||
<Edit3 className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
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" />
|
||||
</button>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -126,31 +133,28 @@ export function DataTable<TData, TValue>({
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/50 bg-muted/10 p-1 space-y-6">
|
||||
<div className="bg-background/40 dark:bg-background/60 backdrop-blur-xl rounded-xl border border-border/50 overflow-hidden transition-all duration-300">
|
||||
<section className="space-y-4 rounded-lg border bg-card p-4">
|
||||
<TopHeader
|
||||
title={title}
|
||||
itemCount={data.length}
|
||||
itemCount={pagination?.totalItems ?? data.length}
|
||||
onAddNew={onAddNew}
|
||||
addButtonText={addButtonText}
|
||||
toolbar={toolbar}
|
||||
/>
|
||||
|
||||
<div className="px-6 py-2">
|
||||
<div>
|
||||
<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"
|
||||
>
|
||||
<TableHeader table={table} />
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
Array.from({ length: 5 }).map((_, idx) => (
|
||||
<TableRow
|
||||
key={idx}
|
||||
className="border-b border-border/30 last:border-0 hover:bg-muted/5"
|
||||
>
|
||||
<TableRow key={idx}>
|
||||
{columns.map((_, colIdx) => (
|
||||
<TableCell key={colIdx} className="px-6 py-6 border-b border-border/30">
|
||||
<Skeleton className="h-4 w-full max-w-[140px] opacity-20" />
|
||||
<TableCell key={colIdx}>
|
||||
<Skeleton className="h-4 w-full max-w-[140px]" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
@@ -160,13 +164,9 @@ export function DataTable<TData, TValue>({
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
className="group hover:bg-muted/10 transition-colors"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
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"
|
||||
>
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
@@ -174,12 +174,10 @@ export function DataTable<TData, TValue>({
|
||||
))
|
||||
) : (
|
||||
<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">
|
||||
<p className="font-bold text-sm tracking-tight">No results found.</p>
|
||||
<p className="text-xs opacity-60 font-medium">
|
||||
Try adjusting your filters or search terms.
|
||||
</p>
|
||||
<p className="text-sm font-medium">{emptyTitle}</p>
|
||||
<p className="text-xs">{emptyDescription}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -187,8 +185,7 @@ export function DataTable<TData, TValue>({
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<TableFooter table={table} />
|
||||
</div>
|
||||
</div>
|
||||
<TableFooter table={table} totalItems={pagination?.totalItems ?? data.length} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ export function AuthGuard({ children }: { children: ReactNode }) {
|
||||
if (!isInitialized) return;
|
||||
|
||||
if (!accessToken) {
|
||||
console.debug('[auth] guard:redirect-login');
|
||||
router.replace(ROUTES.LOGIN);
|
||||
}
|
||||
}, [accessToken, isInitialized, router]);
|
||||
|
||||
@@ -15,7 +15,6 @@ export function GuestGuard({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized && accessToken && user) {
|
||||
console.debug('[auth] guest-guard:redirect-dashboard');
|
||||
router.replace(ROUTES.DASHBOARD);
|
||||
}
|
||||
}, [accessToken, isInitialized, router, user]);
|
||||
|
||||
@@ -26,7 +26,6 @@ export const useLoginForm = () => {
|
||||
|
||||
const onSubmit = async (data: LoginPayload) => {
|
||||
try {
|
||||
console.debug('[auth] login:start');
|
||||
const loginResponse = await authService.login({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
@@ -40,9 +39,7 @@ export const useLoginForm = () => {
|
||||
|
||||
setAccessToken(accessToken);
|
||||
setLoading();
|
||||
console.debug('[auth] login:token-stored');
|
||||
await initializeAuthenticatedApp();
|
||||
console.debug('[auth] login:initialized, redirecting', ROUTES.DASHBOARD);
|
||||
|
||||
toast.success('Login successful');
|
||||
router.push(ROUTES.DASHBOARD);
|
||||
|
||||
@@ -20,23 +20,16 @@ export default function AppInitializer({ children }: { children: ReactNode }) {
|
||||
|
||||
const initialize = async () => {
|
||||
if (!accessToken || user) {
|
||||
console.debug('[auth] app-init:skip', {
|
||||
hasToken: !!accessToken,
|
||||
hasUser: !!user,
|
||||
});
|
||||
setLoaded();
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading();
|
||||
console.debug('[auth] app-init:start');
|
||||
|
||||
try {
|
||||
await initializeAuthenticatedApp();
|
||||
console.debug('[auth] app-init:success');
|
||||
} catch {
|
||||
if (isActive) {
|
||||
console.debug('[auth] app-init:failed, logging out');
|
||||
logout();
|
||||
clearApp();
|
||||
setLoadError();
|
||||
|
||||
@@ -6,3 +6,6 @@ export * from './video.service';
|
||||
export * from './detection.service';
|
||||
export * from './session.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 = {
|
||||
id?: string | number;
|
||||
username?: string;
|
||||
@@ -49,7 +51,7 @@ export type MeResponse = {
|
||||
|
||||
export type PermissionResponse = {
|
||||
granted_permissions?: string[];
|
||||
tree?: unknown[];
|
||||
tree?: PermissionTreeNode[];
|
||||
};
|
||||
|
||||
export type LoginPayload = {
|
||||
|
||||
@@ -7,3 +7,6 @@ export * from './detection';
|
||||
export * from './analysis';
|
||||
export * from './session';
|
||||
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',
|
||||
PACKAGE: '/package',
|
||||
CHAINAGE: '/segment',
|
||||
ROLES: '/roles',
|
||||
USERS: '/users',
|
||||
ACCOUNT: '/account',
|
||||
UPLOAD: '/upload',
|
||||
RESULTS: '/results',
|
||||
|
||||
Reference in New Issue
Block a user