19 KiB
19 KiB
TASK-FE-010: Admin Panel & Settings UI
ID: TASK-FE-010 Title: Admin Panel for User & Master Data Management Category: Administration Priority: P2 (Medium) Effort: 5-7 days Dependencies: TASK-FE-002, TASK-FE-005, TASK-BE-012, TASK-BE-013 Assigned To: Frontend Developer
📋 Overview
Build comprehensive Admin Panel for managing users, roles, master data (organizations, projects, contracts, disciplines, document types), system settings, and viewing audit logs.
🎯 Objectives
- Create admin layout with separate navigation
- Build User Management UI (CRUD users, assign roles)
- Implement Master Data Management screens
- Create System Settings interface
- Build Audit Logs viewer
- Add bulk operations and data import/export
✅ Acceptance Criteria
- Admin area accessible only to admins
- User management (create/edit/delete/deactivate)
- Role assignment with permission preview
- Master data CRUD (Organizations, Projects, etc.)
- Audit logs searchable and filterable
- System settings editable
- CSV import/export for bulk operations
🔧 Implementation Steps
Step 1: Admin Layout
// File: src/app/(admin)/layout.tsx
import { AdminSidebar } from '@/components/admin/sidebar';
import { redirect } from 'next/navigation';
import { getServerSession } from 'next-auth';
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getServerSession();
// Check if user has admin role
if (!session?.user?.roles?.some((r) => r.role_name === 'ADMIN')) {
redirect('/');
}
return (
<div className="flex h-screen">
<AdminSidebar />
<div className="flex-1 overflow-auto">{children}</div>
</div>
);
}
Step 2: User Management Page
// File: src/app/(admin)/admin/users/page.tsx
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/common/data-table';
import { UserDialog } from '@/components/admin/user-dialog';
import { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { MoreHorizontal, Plus } from 'lucide-react';
export default function UsersPage() {
const [users, setUsers] = useState([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState(null);
const columns: ColumnDef<any>[] = [
{
accessorKey: 'username',
header: 'Username',
},
{
accessorKey: 'email',
header: 'Email',
},
{
accessorKey: 'first_name',
header: 'Name',
cell: ({ row }) => `${row.original.first_name} ${row.original.last_name}`,
},
{
accessorKey: 'is_active',
header: 'Status',
cell: ({ row }) => (
<Badge variant={row.original.is_active ? 'success' : 'secondary'}>
{row.original.is_active ? 'Active' : 'Inactive'}
</Badge>
),
},
{
id: 'roles',
header: 'Roles',
cell: ({ row }) => (
<div className="flex gap-1">
{row.original.roles?.map((role: any) => (
<Badge key={role.user_role_id} variant="outline">
{role.role_name}
</Badge>
))}
</div>
),
},
{
id: 'actions',
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelectedUser(row.original);
setDialogOpen(true);
}}
>
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeactivate(row.original.user_id)}
>
{row.original.is_active ? 'Deactivate' : 'Activate'}
</DropdownMenuItem>
<DropdownMenuItem className="text-red-600">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
];
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">User Management</h1>
<p className="text-gray-600 mt-1">
Manage system users and their roles
</p>
</div>
<Button
onClick={() => {
setSelectedUser(null);
setDialogOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" />
Add User
</Button>
</div>
<DataTable columns={columns} data={users} />
<UserDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
user={selectedUser}
/>
</div>
);
}
Step 3: User Create/Edit Dialog
// File: src/components/admin/user-dialog.tsx
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
const userSchema = z.object({
username: z.string().min(3),
email: z.string().email(),
first_name: z.string().min(1),
last_name: z.string().min(1),
password: z.string().min(6).optional(),
is_active: z.boolean().default(true),
roles: z.array(z.number()),
});
type UserFormData = z.infer<typeof userSchema>;
interface UserDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
user?: any;
}
export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<UserFormData>({
resolver: zodResolver(userSchema),
defaultValues: user || {},
});
const availableRoles = [
{ role_id: 1, role_name: 'ADMIN', description: 'System Administrator' },
{ role_id: 2, role_name: 'USER', description: 'Regular User' },
{ role_id: 3, role_name: 'APPROVER', description: 'Document Approver' },
];
const selectedRoles = watch('roles') || [];
const onSubmit = async (data: UserFormData) => {
// Call API to create/update user
console.log(data);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{user ? 'Edit User' : 'Create New User'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Username *</Label>
<Input {...register('username')} />
{errors.username && (
<p className="text-sm text-red-600 mt-1">
{errors.username.message}
</p>
)}
</div>
<div>
<Label>Email *</Label>
<Input type="email" {...register('email')} />
{errors.email && (
<p className="text-sm text-red-600 mt-1">
{errors.email.message}
</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>First Name *</Label>
<Input {...register('first_name')} />
</div>
<div>
<Label>Last Name *</Label>
<Input {...register('last_name')} />
</div>
</div>
{!user && (
<div>
<Label>Password *</Label>
<Input type="password" {...register('password')} />
{errors.password && (
<p className="text-sm text-red-600 mt-1">
{errors.password.message}
</p>
)}
</div>
)}
<div>
<Label className="mb-3 block">Roles</Label>
<div className="space-y-2">
{availableRoles.map((role) => (
<label
key={role.role_id}
className="flex items-start gap-3 p-3 border rounded hover:bg-gray-50"
>
<Checkbox
checked={selectedRoles.includes(role.role_id)}
onCheckedChange={(checked) => {
const newRoles = checked
? [...selectedRoles, role.role_id]
: selectedRoles.filter((id) => id !== role.role_id);
setValue('roles', newRoles);
}}
/>
<div>
<div className="font-medium">{role.role_name}</div>
<div className="text-sm text-gray-600">
{role.description}
</div>
</div>
</label>
))}
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox {...register('is_active')} defaultChecked />
<Label>Active</Label>
</div>
<div className="flex justify-end gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="submit">
{user ? 'Update User' : 'Create User'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
Step 4: Master Data Management (Organizations)
// File: src/app/(admin)/admin/organizations/page.tsx
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/common/data-table';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
export default function OrganizationsPage() {
const [organizations, setOrganizations] = useState([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [formData, setFormData] = useState({
org_code: '',
org_name: '',
org_name_th: '',
description: '',
});
const columns = [
{ accessorKey: 'org_code', header: 'Code' },
{ accessorKey: 'org_name', header: 'Name (EN)' },
{ accessorKey: 'org_name_th', header: 'Name (TH)' },
{ accessorKey: 'description', header: 'Description' },
];
const handleSubmit = async () => {
// Call API to create organization
console.log(formData);
setDialogOpen(false);
};
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Organizations</h1>
<p className="text-gray-600 mt-1">Manage project organizations</p>
</div>
<Button onClick={() => setDialogOpen(true)}>Add Organization</Button>
</div>
<DataTable columns={columns} data={organizations} />
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Organization</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Organization Code *</Label>
<Input
value={formData.org_code}
onChange={(e) =>
setFormData({ ...formData, org_code: e.target.value })
}
placeholder="e.g., กทท."
/>
</div>
<div>
<Label>Name (English) *</Label>
<Input
value={formData.org_name}
onChange={(e) =>
setFormData({ ...formData, org_name: e.target.value })
}
/>
</div>
<div>
<Label>Name (Thai)</Label>
<Input
value={formData.org_name_th}
onChange={(e) =>
setFormData({ ...formData, org_name_th: e.target.value })
}
/>
</div>
<div>
<Label>Description</Label>
<Input
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSubmit}>Create</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
Step 5: Audit Logs Viewer
// File: src/app/(admin)/admin/audit-logs/page.tsx
'use client';
import { useState } from 'react';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { formatDistanceToNow } from 'date-fns';
export default function AuditLogsPage() {
const [logs, setLogs] = useState([]);
const [filters, setFilters] = useState({
user: '',
action: '',
entity: '',
});
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-3xl font-bold">Audit Logs</h1>
<p className="text-gray-600 mt-1">View system activity and changes</p>
</div>
{/* Filters */}
<Card className="p-4">
<div className="grid grid-cols-4 gap-4">
<div>
<Input placeholder="Search user..." />
</div>
<div>
<Select>
<SelectTrigger>
<SelectValue placeholder="Action" />
</SelectTrigger>
<SelectContent>
<SelectItem value="CREATE">Create</SelectItem>
<SelectItem value="UPDATE">Update</SelectItem>
<SelectItem value="DELETE">Delete</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Select>
<SelectTrigger>
<SelectValue placeholder="Entity Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="correspondence">Correspondence</SelectItem>
<SelectItem value="rfa">RFA</SelectItem>
<SelectItem value="user">User</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</Card>
{/* Logs List */}
<div className="space-y-2">
{logs.map((log: any) => (
<Card key={log.audit_log_id} className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className="font-medium">{log.user_name}</span>
<Badge>{log.action}</Badge>
<Badge variant="outline">{log.entity_type}</Badge>
</div>
<p className="text-sm text-gray-600">{log.description}</p>
<p className="text-xs text-gray-500 mt-2">
{formatDistanceToNow(new Date(log.created_at), {
addSuffix: true,
})}
</p>
</div>
{log.ip_address && (
<span className="text-xs text-gray-500">
IP: {log.ip_address}
</span>
)}
</div>
</Card>
))}
</div>
</div>
);
}
Step 6: Admin Sidebar Navigation
// File: src/components/admin/sidebar.tsx
'use client';
import Link from 'link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import { Users, Building2, Settings, FileText, Activity } from 'lucide-react';
const menuItems = [
{ href: '/admin/users', label: 'Users', icon: Users },
{ href: '/admin/organizations', label: 'Organizations', icon: Building2 },
{ href: '/admin/projects', label: 'Projects', icon: FileText },
{ href: '/admin/settings', label: 'Settings', icon: Settings },
{ href: '/admin/audit-logs', label: 'Audit Logs', icon: Activity },
];
export function AdminSidebar() {
const pathname = usePathname();
return (
<aside className="w-64 border-r bg-gray-50 p-4">
<h2 className="text-lg font-bold mb-6">Admin Panel</h2>
<nav className="space-y-1">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-3 px-3 py-2 rounded-lg transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'hover:bg-gray-100'
)}
>
<Icon className="h-5 w-5" />
<span>{item.label}</span>
</Link>
);
})}
</nav>
</aside>
);
}
📦 Deliverables
- Admin layout with sidebar navigation
- User Management (CRUD, roles assignment)
- Master Data Management screens:
- Organizations
- Projects
- Contracts
- Disciplines
- Document Types
- System Settings interface
- Audit Logs viewer with filters
- CSV import/export functionality
🧪 Testing
Test Cases
-
User Management
- Create new user
- Assign multiple roles
- Deactivate/activate user
- Delete user
-
Master Data
- Create organization
- Edit organization details
- Delete organization (check for dependencies)
-
Audit Logs
- View all logs
- Filter by user/action/entity
- Search logs
- Export logs
🔗 Related Documents
Created: 2025-12-01 Status: Ready