Files
lcbp3/specs/06-tasks/TASK-FE-010-admin-panel.md
admin 047e1b88ce
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled
Main: revise specs to 1.5.0 (completed)
2025-12-01 01:28:32 +07:00

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

  1. Create admin layout with separate navigation
  2. Build User Management UI (CRUD users, assign roles)
  3. Implement Master Data Management screens
  4. Create System Settings interface
  5. Build Audit Logs viewer
  6. 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

  1. User Management

    • Create new user
    • Assign multiple roles
    • Deactivate/activate user
    • Delete user
  2. Master Data

    • Create organization
    • Edit organization details
    • Delete organization (check for dependencies)
  3. Audit Logs

    • View all logs
    • Filter by user/action/entity
    • Search logs
    • Export logs


Created: 2025-12-01 Status: Ready