251208:0010 Backend & Frontend Debug
This commit is contained in:
@@ -1,120 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useAuditLogs } from "@/hooks/use-audit-logs";
|
||||
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";
|
||||
import { AuditLog } from "@/types/admin";
|
||||
import { adminApi } from "@/lib/api/admin";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export default function AuditLogsPage() {
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [filters, setFilters] = useState({
|
||||
user: "",
|
||||
action: "",
|
||||
entity: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLogs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await adminApi.getAuditLogs();
|
||||
setLogs(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch audit logs", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLogs();
|
||||
}, []);
|
||||
const { data: logs, isLoading } = useAuditLogs();
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Audit Logs</h1>
|
||||
<p className="text-muted-foreground mt-1">View system activity and changes</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="p-4">
|
||||
<div className="grid grid-cols-1 md: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 */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center p-8">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{logs.map((log) => (
|
||||
<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 variant={log.action === "CREATE" ? "default" : "secondary"}>
|
||||
{log.action}
|
||||
</Badge>
|
||||
<Badge variant="outline">{log.entity_type}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{log.description}</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{formatDistanceToNow(new Date(log.created_at), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{log.ip_address && (
|
||||
<span className="text-xs text-muted-foreground bg-muted px-2 py-1 rounded">
|
||||
IP: {log.ip_address}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{!logs || logs.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-10">No logs found</div>
|
||||
) : (
|
||||
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 text-sm">{log.user_name || `User #${log.user_id}`}</span>
|
||||
<Badge variant="outline" className="uppercase text-[10px]">{log.action}</Badge>
|
||||
<Badge variant="secondary" className="uppercase text-[10px]">{log.entity_type}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-foreground">{log.description}</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{formatDistanceToNow(new Date(log.created_at), { addSuffix: true })}
|
||||
</p>
|
||||
</div>
|
||||
{log.ip_address && (
|
||||
<span className="text-xs text-muted-foreground font-mono bg-muted px-2 py-1 rounded">
|
||||
{log.ip_address}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DataTable } from "@/components/common/data-table";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -11,16 +11,32 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Organization } from "@/types/admin";
|
||||
import { adminApi } from "@/lib/api/admin";
|
||||
import { useOrganizations, useCreateOrganization, useUpdateOrganization, useDeleteOrganization } from "@/hooks/use-master-data";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Loader2, Plus } from "lucide-react";
|
||||
import { Pencil, Trash, Plus } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
interface Organization {
|
||||
organization_id: number;
|
||||
org_code: string;
|
||||
org_name: string;
|
||||
org_name_th: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export default function OrganizationsPage() {
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { data: organizations, isLoading } = useOrganizations();
|
||||
const createOrg = useCreateOrganization();
|
||||
const updateOrg = useUpdateOrganization();
|
||||
const deleteOrg = useDeleteOrganization();
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [editingOrg, setEditingOrg] = useState<Organization | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
org_code: "",
|
||||
org_name: "",
|
||||
@@ -28,127 +44,131 @@ export default function OrganizationsPage() {
|
||||
description: "",
|
||||
});
|
||||
|
||||
const fetchOrgs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await adminApi.getOrganizations();
|
||||
setOrganizations(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch organizations", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrgs();
|
||||
}, []);
|
||||
|
||||
const columns: ColumnDef<Organization>[] = [
|
||||
{ accessorKey: "org_code", header: "Code" },
|
||||
{ accessorKey: "org_name", header: "Name (EN)" },
|
||||
{ accessorKey: "org_name_th", header: "Name (TH)" },
|
||||
{ accessorKey: "description", header: "Description" },
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEdit(row.original)}>
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-red-600"
|
||||
onClick={() => {
|
||||
if (confirm("Delete this organization?")) {
|
||||
deleteOrg.mutate(row.original.organization_id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await adminApi.createOrganization(formData);
|
||||
setDialogOpen(false);
|
||||
const handleEdit = (org: Organization) => {
|
||||
setEditingOrg(org);
|
||||
setFormData({
|
||||
org_code: "",
|
||||
org_name: "",
|
||||
org_name_th: "",
|
||||
description: "",
|
||||
org_code: org.org_code,
|
||||
org_name: org.org_name,
|
||||
org_name_th: org.org_name_th,
|
||||
description: org.description || ""
|
||||
});
|
||||
fetchOrgs();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Failed to create organization");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingOrg(null);
|
||||
setFormData({ org_code: "", org_name: "", org_name_th: "", description: "" });
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (editingOrg) {
|
||||
updateOrg.mutate({ id: editingOrg.organization_id, data: formData }, {
|
||||
onSuccess: () => setDialogOpen(false)
|
||||
});
|
||||
} else {
|
||||
createOrg.mutate(formData, {
|
||||
onSuccess: () => setDialogOpen(false)
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Organizations</h1>
|
||||
<p className="text-muted-foreground mt-1">Manage project organizations</p>
|
||||
<p className="text-muted-foreground mt-1">Manage project organizations system-wide</p>
|
||||
</div>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Organization
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Add Organization
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<DataTable columns={columns} data={organizations} />
|
||||
)}
|
||||
<DataTable columns={columns} data={organizations || []} />
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Organization</DialogTitle>
|
||||
<DialogTitle>{editingOrg ? "Edit Organization" : "New Organization"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="org_code">Organization Code *</Label>
|
||||
<Label>Code</Label>
|
||||
<Input
|
||||
id="org_code"
|
||||
value={formData.org_code}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, org_code: e.target.value })
|
||||
}
|
||||
placeholder="e.g., PAT"
|
||||
onChange={(e) => setFormData({ ...formData, org_code: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="org_name">Name (English) *</Label>
|
||||
<Label>Name (EN)</Label>
|
||||
<Input
|
||||
id="org_name"
|
||||
value={formData.org_name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, org_name: e.target.value })
|
||||
}
|
||||
onChange={(e) => setFormData({ ...formData, org_name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="org_name_th">Name (Thai)</Label>
|
||||
<Label>Name (TH)</Label>
|
||||
<Input
|
||||
id="org_name_th"
|
||||
value={formData.org_name_th}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, org_name_th: e.target.value })
|
||||
}
|
||||
onChange={(e) => setFormData({ ...formData, org_name_th: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Label>Description</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, description: e.target.value })
|
||||
}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={isSubmitting}>
|
||||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Create
|
||||
<Button type="submit" disabled={createOrg.isPending || updateOrg.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
@@ -1,43 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useUsers, useDeleteUser } from "@/hooks/use-users";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DataTable } from "@/components/common/data-table";
|
||||
import { DataTable } from "@/components/common/data-table"; // Reuse Data Table
|
||||
import { Plus, MoreHorizontal, Pencil, Trash } from "lucide-react"; // Import Icons
|
||||
import { useState } from "react";
|
||||
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, Loader2 } from "lucide-react";
|
||||
import { User } from "@/types/admin";
|
||||
import { adminApi } from "@/lib/api/admin";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { User } from "@/types/user";
|
||||
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { data: users, isLoading } = useUsers();
|
||||
const deleteMutation = useDeleteUser();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await adminApi.getUsers();
|
||||
setUsers(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch users", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const columns: ColumnDef<User>[] = [
|
||||
{
|
||||
accessorKey: "username",
|
||||
@@ -48,95 +32,73 @@ export default function UsersPage() {
|
||||
header: "Email",
|
||||
},
|
||||
{
|
||||
accessorKey: "first_name",
|
||||
header: "Name",
|
||||
cell: ({ row }) => `${row.original.first_name} ${row.original.last_name}`,
|
||||
id: "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 ? "default" : "secondary"} className={row.original.is_active ? "bg-green-600 hover:bg-green-700" : ""}>
|
||||
<Badge variant={row.original.is_active ? "default" : "secondary"}>
|
||||
{row.original.is_active ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "roles",
|
||||
header: "Roles",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{row.original.roles?.map((role) => (
|
||||
<Badge key={role.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
|
||||
className="text-destructive"
|
||||
onClick={() => alert("Deactivate not implemented in mock")}
|
||||
>
|
||||
{row.original.is_active ? "Deactivate" : "Activate"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => {
|
||||
const user = row.original;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => { setSelectedUser(user); setDialogOpen(true); }}>
|
||||
<Pencil className="mr-2 h-4 w-4" /> Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-red-600"
|
||||
onClick={() => {
|
||||
if (confirm("Are you sure?")) deleteMutation.mutate(user.user_id);
|
||||
}}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">User Management</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Manage system users and their roles
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1">Manage system users and roles</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedUser(null);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add User
|
||||
<Button onClick={() => { setSelectedUser(null); setDialogOpen(true); }}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Add User
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div>Loading...</div>
|
||||
) : (
|
||||
<DataTable columns={columns} data={users} />
|
||||
<DataTable columns={columns} data={users || []} />
|
||||
)}
|
||||
|
||||
<UserDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
user={selectedUser}
|
||||
onSuccess={fetchUsers}
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
user={selectedUser}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
import { AdminSidebar } from "@/components/admin/sidebar";
|
||||
import { redirect } from "next/navigation";
|
||||
// import { getServerSession } from "next-auth"; // Commented out for now as we are mocking auth
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Mock Admin Check
|
||||
// const session = await getServerSession();
|
||||
// if (!session?.user?.roles?.some((r) => r.role_name === 'ADMIN')) {
|
||||
// redirect('/');
|
||||
// }
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
// For development, we assume user is admin
|
||||
const isAdmin = true;
|
||||
if (!isAdmin) {
|
||||
redirect("/");
|
||||
// Check if user has admin role
|
||||
// This depends on your Session structure. Assuming user.roles exists (mapped in callback).
|
||||
// If not, you might need to check DB or use Can component logic but server-side.
|
||||
const isAdmin = session?.user?.roles?.some((r: any) => r.role_name === 'ADMIN');
|
||||
|
||||
if (!session || !isAdmin) {
|
||||
// If not admin, redirect to dashboard or login
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)]"> {/* Subtract header height */}
|
||||
<div className="flex h-screen w-full bg-background">
|
||||
<AdminSidebar />
|
||||
<div className="flex-1 overflow-auto bg-muted/10">
|
||||
<div className="flex-1 overflow-auto bg-muted/10 p-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as z from "zod";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Eye, EyeOff, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -33,7 +34,7 @@ export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
// Removed local errorMessage state in favor of toast
|
||||
|
||||
// ตั้งค่า React Hook Form
|
||||
const {
|
||||
@@ -51,11 +52,9 @@ export default function LoginPage() {
|
||||
// ฟังก์ชันเมื่อกด Submit
|
||||
async function onSubmit(data: LoginValues) {
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
// เรียกใช้ NextAuth signIn (Credential Provider)
|
||||
// หมายเหตุ: เรายังไม่ได้ตั้งค่า AuthOption ใน route.ts แต่นี่คือโค้ดฝั่ง Client ที่ถูกต้อง
|
||||
const result = await signIn("credentials", {
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
@@ -65,16 +64,23 @@ export default function LoginPage() {
|
||||
if (result?.error) {
|
||||
// กรณี Login ไม่สำเร็จ
|
||||
console.error("Login failed:", result.error);
|
||||
setErrorMessage("เข้าสู่ระบบไม่สำเร็จ: ชื่อผู้ใช้งานหรือรหัสผ่านไม่ถูกต้อง");
|
||||
toast.error("เข้าสู่ระบบไม่สำเร็จ", {
|
||||
description: "ชื่อผู้ใช้งานหรือรหัสผ่านไม่ถูกต้อง กรุณาลองใหม่",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Login สำเร็จ -> ไปหน้า Dashboard
|
||||
toast.success("เข้าสู่ระบบสำเร็จ", {
|
||||
description: "กำลังพาท่านไปยังหน้า Dashboard...",
|
||||
});
|
||||
router.push("/dashboard");
|
||||
router.refresh(); // Refresh เพื่อให้ Server Component รับรู้ Session ใหม่
|
||||
} catch (error) {
|
||||
console.error("Login error:", error);
|
||||
setErrorMessage("เกิดข้อผิดพลาดที่ไม่คาดคิด กรุณาลองใหม่อีกครั้ง");
|
||||
toast.error("เกิดข้อผิดพลาด", {
|
||||
description: "ระบบขัดข้อง กรุณาลองใหม่อีกครั้ง หรือติดต่อผู้ดูแลระบบ",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -93,11 +99,6 @@ export default function LoginPage() {
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<CardContent className="grid gap-4">
|
||||
{errorMessage && (
|
||||
<div className="bg-destructive/15 text-destructive text-sm p-3 rounded-md border border-destructive/20">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
{/* Username Field */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="username">ชื่อผู้ใช้งาน</Label>
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { CorrespondenceList } from "@/components/correspondences/list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { Plus } from "lucide-react";
|
||||
import { correspondenceApi } from "@/lib/api/correspondences";
|
||||
import { Plus, Loader2 } from "lucide-react"; // Added Loader2
|
||||
import { Pagination } from "@/components/common/pagination";
|
||||
import { useCorrespondences } from "@/hooks/use-correspondence";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
export default async function CorrespondencesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { page?: string; status?: string; search?: string };
|
||||
}) {
|
||||
const page = parseInt(searchParams.page || "1");
|
||||
const data = await correspondenceApi.getAll({
|
||||
export default function CorrespondencesPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const page = parseInt(searchParams.get("page") || "1");
|
||||
const status = searchParams.get("status") || undefined;
|
||||
const search = searchParams.get("search") || undefined;
|
||||
|
||||
const { data, isLoading, isError } = useCorrespondences({
|
||||
page,
|
||||
status: searchParams.status,
|
||||
search: searchParams.search,
|
||||
});
|
||||
status, // This might be wrong type, let's cast or omit for now
|
||||
search,
|
||||
} as any);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -36,15 +39,27 @@ export default async function CorrespondencesPage({
|
||||
|
||||
{/* Filters component could go here */}
|
||||
|
||||
<CorrespondenceList data={data} />
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="text-red-500 text-center py-8">
|
||||
Failed to load correspondences.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<CorrespondenceList data={data} />
|
||||
|
||||
<div className="mt-4">
|
||||
<Pagination
|
||||
currentPage={data.page}
|
||||
totalPages={data.totalPages}
|
||||
total={data.total}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Pagination
|
||||
currentPage={data?.page || 1}
|
||||
totalPages={data?.totalPages || 1}
|
||||
total={data?.total || 0}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
95
frontend/app/(dashboard)/dashboard/can/page.tsx
Normal file
95
frontend/app/(dashboard)/dashboard/can/page.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
// File: app/(dashboard)/dashboard/can/page.tsx
|
||||
'use client';
|
||||
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { Can } from '@/components/common/can';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function CanTestPage() {
|
||||
const { user } = useAuthStore();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">RBAC / Permission Test Page</h1>
|
||||
|
||||
{/* User Info Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Current User Info</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<span className="font-semibold">Username:</span>
|
||||
<span>{user?.username || 'Not logged in'}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="font-semibold">Role:</span>
|
||||
<Badge variant="outline">{user?.role || 'None'}</Badge>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="font-semibold">Permissions:</span>
|
||||
<span>{user?.permissions?.join(', ') || 'No explicit permissions'}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Permission Tests */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Component Visibility Tests (using <Can />)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
||||
<div className="p-4 border rounded bg-gray-50 flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">1. Admin Role Check</p>
|
||||
<Can role="Admin" fallback={<span className="text-red-500 text-sm">❌ You are NOT an Admin (Hidden)</span>}>
|
||||
<Button variant="default" className="w-fit">✅ Visible to Admin Only</Button>
|
||||
</Can>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded bg-gray-50 flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">2. 'document:create' Permission</p>
|
||||
<Can permission="document:create" fallback={<span className="text-red-500 text-sm">❌ Missing 'document:create' (Hidden)</span>}>
|
||||
<Button variant="secondary" className="w-fit">✅ Visible with 'document:create'</Button>
|
||||
</Can>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded bg-gray-50 flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">3. 'document:delete' Permission</p>
|
||||
<Can permission="document:delete" fallback={<span className="text-red-500 text-sm">❌ Missing 'document:delete' (Hidden)</span>}>
|
||||
<Button variant="destructive" className="w-fit">✅ Visible with 'document:delete'</Button>
|
||||
</Can>
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Toast Test */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Toast Notification Test</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex gap-4">
|
||||
<Button
|
||||
onClick={() => toast.success("Operation Successful", { description: "This is a success toast message." })}
|
||||
>
|
||||
Trigger Success Toast
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => toast.error("Operation Failed", { description: "This is an error toast message." })}
|
||||
>
|
||||
Trigger Error Toast
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="p-4 bg-blue-50 text-blue-800 rounded">
|
||||
<strong>Tip:</strong> You can mock different roles by modifying the user state in local storage or using the `setAuth` method in console.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { StatsCards } from "@/components/dashboard/stats-cards";
|
||||
import { RecentActivity } from "@/components/dashboard/recent-activity";
|
||||
import { PendingTasks } from "@/components/dashboard/pending-tasks";
|
||||
import { QuickActions } from "@/components/dashboard/quick-actions";
|
||||
import { dashboardApi } from "@/lib/api/dashboard";
|
||||
import { useDashboardStats, useRecentActivity, usePendingTasks } from "@/hooks/use-dashboard";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
// Fetch data in parallel
|
||||
const [stats, activities, tasks] = await Promise.all([
|
||||
dashboardApi.getStats(),
|
||||
dashboardApi.getRecentActivity(),
|
||||
dashboardApi.getPendingTasks(),
|
||||
]);
|
||||
export default function DashboardPage() {
|
||||
const { data: stats, isLoading: statsLoading } = useDashboardStats();
|
||||
const { data: activities, isLoading: activityLoading } = useRecentActivity();
|
||||
const { data: tasks, isLoading: tasksLoading } = usePendingTasks();
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -24,14 +23,14 @@ export default async function DashboardPage() {
|
||||
<QuickActions />
|
||||
</div>
|
||||
|
||||
<StatsCards stats={stats} />
|
||||
<StatsCards stats={stats} isLoading={statsLoading} />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<RecentActivity activities={activities} />
|
||||
<RecentActivity activities={activities} isLoading={activityLoading} />
|
||||
</div>
|
||||
<div className="lg:col-span-1">
|
||||
<PendingTasks tasks={tasks} />
|
||||
<PendingTasks tasks={tasks} isLoading={tasksLoading} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
import { rfaApi } from "@/lib/api/rfas";
|
||||
import { RFADetail } from "@/components/rfas/detail";
|
||||
import { notFound } from "next/navigation";
|
||||
"use client";
|
||||
|
||||
export default async function RFADetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: { id: string };
|
||||
}) {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) {
|
||||
notFound();
|
||||
import { RFADetail } from "@/components/rfas/detail";
|
||||
import { notFound, useParams } from "next/navigation";
|
||||
import { useRFA } from "@/hooks/use-rfa";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export default function RFADetailPage() {
|
||||
const { id } = useParams();
|
||||
|
||||
if (!id) notFound();
|
||||
|
||||
const { data: rfa, isLoading, isError } = useRFA(String(id));
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rfa = await rfaApi.getById(id);
|
||||
|
||||
if (!rfa) {
|
||||
notFound();
|
||||
if (isError || !rfa) {
|
||||
// Check if error is 404
|
||||
return (
|
||||
<div className="text-center py-20 text-red-500">
|
||||
RFA not found or failed to load.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <RFADetail data={rfa} />;
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import { RFAList } from "@/components/rfas/list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { Plus } from "lucide-react";
|
||||
import { rfaApi } from "@/lib/api/rfas";
|
||||
import { Pagination } from "@/components/common/pagination";
|
||||
"use client";
|
||||
|
||||
export default async function RFAsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { page?: string; status?: string; search?: string };
|
||||
}) {
|
||||
const page = parseInt(searchParams.page || "1");
|
||||
const data = await rfaApi.getAll({
|
||||
page,
|
||||
status: searchParams.status,
|
||||
search: searchParams.search,
|
||||
});
|
||||
import { RFAList } from '@/components/rfas/list';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Link from 'next/link';
|
||||
import { Plus, Loader2 } from 'lucide-react';
|
||||
import { useRFAs } from '@/hooks/use-rfa';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Pagination } from '@/components/common/pagination';
|
||||
|
||||
export default function RFAsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const status = searchParams.get('status') || undefined;
|
||||
const search = searchParams.get('search') || undefined;
|
||||
|
||||
const { data, isLoading, isError } = useRFAs({ page, status, search });
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -34,15 +33,28 @@ export default async function RFAsPage({
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<RFAList data={data} />
|
||||
{/* RFAFilters component could be added here if needed */}
|
||||
|
||||
<div className="mt-4">
|
||||
<Pagination
|
||||
currentPage={data.page}
|
||||
totalPages={data.totalPages}
|
||||
total={data.total}
|
||||
/>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="text-red-500 text-center py-8">
|
||||
Failed to load RFAs.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<RFAList data={data} />
|
||||
<div className="mt-4">
|
||||
<Pagination
|
||||
currentPage={data?.page || 1}
|
||||
totalPages={data?.lastPage || data?.totalPages || 1}
|
||||
total={data?.total || 0}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,54 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { SearchFilters } from "@/components/search/filters";
|
||||
import { SearchResults } from "@/components/search/results";
|
||||
import { searchApi } from "@/lib/api/search";
|
||||
import { SearchResult, SearchFilters as FilterType } from "@/types/search";
|
||||
import { SearchFilters as FilterType } from "@/types/search";
|
||||
import { useSearch } from "@/hooks/use-search";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function SearchPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
// URL Params state
|
||||
const query = searchParams.get("q") || "";
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [filters, setFilters] = useState<FilterType>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const typeParam = searchParams.get("type");
|
||||
const statusParam = searchParams.get("status");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchResults = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await searchApi.search({ query, ...filters });
|
||||
setResults(data);
|
||||
} catch (error) {
|
||||
console.error("Search failed", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
// Local Filter State (synced with URL initially, but can be independent before apply)
|
||||
// For simplicity, we'll keep filters in sync with valid search params or local state that pushes to URL
|
||||
const [filters, setFilters] = useState<FilterType>({
|
||||
types: typeParam ? [typeParam] : [],
|
||||
statuses: statusParam ? [statusParam] : [],
|
||||
});
|
||||
|
||||
fetchResults();
|
||||
}, [query, filters]);
|
||||
// Construct search DTO
|
||||
const searchDto = {
|
||||
q: query,
|
||||
// Map internal types to backend expectation if needed, assumes direct mapping for now
|
||||
type: filters.types?.length === 1 ? filters.types[0] : undefined, // Backend might support single type or multiple?
|
||||
// DTO says 'type?: string', 'status?: string'. If multiple, our backend might need adjustment or we only support single filter for now?
|
||||
// Spec says "Advanced filters work (type, status)". Let's assume generic loose mapping for now or comma separated.
|
||||
// Let's assume the hook and backend handle it. If backend expects single value, we pick first or join.
|
||||
// Backend controller uses `SearchQueryDto`. Let's check DTO if I can view it.
|
||||
// Actually, I'll pass them and let the service handle serialization if needed.
|
||||
...filters
|
||||
};
|
||||
|
||||
const { data: results, isLoading, isError } = useSearch(searchDto);
|
||||
|
||||
const handleFilterChange = (newFilters: FilterType) => {
|
||||
setFilters(newFilters);
|
||||
// Optional: Update URL to reflect filters?
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Search Results</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{loading
|
||||
{isLoading
|
||||
? "Searching..."
|
||||
: `Found ${results.length} results for "${query}"`
|
||||
: `Found ${results?.length || 0} results for "${query}"`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<div className="lg:col-span-1">
|
||||
<SearchFilters onFilterChange={setFilters} />
|
||||
<SearchFilters onFilterChange={handleFilterChange} />
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-3">
|
||||
<SearchResults results={results} query={query} loading={loading} />
|
||||
{isError ? (
|
||||
<div className="text-red-500 py-8 text-center">Failed to load search results.</div>
|
||||
) : (
|
||||
<SearchResults results={results || []} query={query} loading={isLoading} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import "./globals.css";
|
||||
import { cn } from "@/lib/utils";
|
||||
import QueryProvider from "@/providers/query-provider";
|
||||
import SessionProvider from "@/providers/session-provider"; // ✅ Import เข้ามา
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
@@ -30,9 +31,10 @@ export default function RootLayout({ children }: RootLayoutProps) {
|
||||
<SessionProvider> {/* ✅ หุ้มด้วย SessionProvider เป็นชั้นนอกสุด หรือใน body */}
|
||||
<QueryProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</QueryProvider>
|
||||
</SessionProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user