251205:0000 Just start debug backend/frontend

This commit is contained in:
2025-12-05 00:32:02 +07:00
parent dc8b80c5f9
commit 2865bebdb1
88 changed files with 6751 additions and 1016 deletions

View File

@@ -0,0 +1,114 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Search, FileText, Clipboard, Image } from "lucide-react";
import { Input } from "@/components/ui/input";
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { searchApi } from "@/lib/api/search";
import { SearchResult } from "@/types/search";
import { useDebounce } from "@/hooks/use-debounce"; // We need to create this hook or implement debounce inline
// Simple debounce hook implementation inline for now if not exists
function useDebounceValue<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
export function GlobalSearch() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
const debouncedQuery = useDebounceValue(query, 300);
useEffect(() => {
if (debouncedQuery.length > 2) {
searchApi.suggest(debouncedQuery).then(setSuggestions);
setOpen(true);
} else {
setSuggestions([]);
if (debouncedQuery.length === 0) setOpen(false);
}
}, [debouncedQuery]);
const handleSearch = () => {
if (query.trim()) {
router.push(`/search?q=${encodeURIComponent(query)}`);
setOpen(false);
}
};
const getIcon = (type: string) => {
switch (type) {
case "correspondence": return <FileText className="mr-2 h-4 w-4" />;
case "rfa": return <Clipboard className="mr-2 h-4 w-4" />;
case "drawing": return <Image className="mr-2 h-4 w-4" />;
default: return <Search className="mr-2 h-4 w-4" />;
}
};
return (
<div className="relative w-full max-w-sm">
<Popover open={open && suggestions.length > 0} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Search documents..."
className="pl-8 w-full bg-background"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
onFocus={() => {
if (suggestions.length > 0) setOpen(true);
}}
/>
</div>
</PopoverTrigger>
<PopoverContent className="p-0 w-[var(--radix-popover-trigger-width)]" align="start">
<Command>
<CommandList>
<CommandGroup heading="Suggestions">
{suggestions.map((item) => (
<CommandItem
key={`${item.type}-${item.id}`}
onSelect={() => {
setQuery(item.title);
router.push(`/${item.type}s/${item.id}`);
setOpen(false);
}}
>
{getIcon(item.type)}
<span>{item.title}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
import { UserMenu } from "./user-menu";
import { Button } from "@/components/ui/button";
import { GlobalSearch } from "./global-search";
import { NotificationsDropdown } from "./notifications-dropdown";
export function Header() {
return (
<header className="h-16 border-b bg-white flex items-center justify-between px-6 sticky top-0 z-10">
<div className="flex items-center gap-4 flex-1">
<h2 className="text-lg font-semibold text-gray-800">LCBP3-DMS</h2>
<div className="ml-4 w-full max-w-md">
<GlobalSearch />
</div>
</div>
<div className="flex items-center gap-4">
<NotificationsDropdown />
<UserMenu />
</div>
</header>
);
}

View File

@@ -0,0 +1,139 @@
"use client";
import { useState, useEffect } from "react";
import { Bell, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import { notificationApi } from "@/lib/api/notifications";
import { Notification } from "@/types/notification";
import { formatDistanceToNow } from "date-fns";
import Link from "next/link";
import { useRouter } from "next/navigation";
export function NotificationsDropdown() {
const router = useRouter();
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
// Fetch notifications
const fetchNotifications = async () => {
try {
const data = await notificationApi.getUnread();
setNotifications(data.items);
setUnreadCount(data.unreadCount);
} catch (error) {
console.error("Failed to fetch notifications", error);
}
};
fetchNotifications();
}, []);
const handleMarkAsRead = async (id: number, e: React.MouseEvent) => {
e.stopPropagation();
await notificationApi.markAsRead(id);
setNotifications((prev) =>
prev.map((n) => (n.notification_id === id ? { ...n, is_read: true } : n))
);
setUnreadCount((prev) => Math.max(0, prev - 1));
};
const handleNotificationClick = async (notification: Notification) => {
if (!notification.is_read) {
await notificationApi.markAsRead(notification.notification_id);
setUnreadCount((prev) => Math.max(0, prev - 1));
}
setIsOpen(false);
if (notification.link) {
router.push(notification.link);
}
};
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5 text-gray-600" />
{unreadCount > 0 && (
<Badge
variant="destructive"
className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-[10px] rounded-full"
>
{unreadCount}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<DropdownMenuLabel className="flex justify-between items-center">
<span>Notifications</span>
{unreadCount > 0 && (
<span className="text-xs font-normal text-muted-foreground">
{unreadCount} unread
</span>
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{notifications.length === 0 ? (
<div className="p-8 text-center text-sm text-muted-foreground">
No notifications
</div>
) : (
<div className="max-h-[400px] overflow-y-auto">
{notifications.map((notification) => (
<DropdownMenuItem
key={notification.notification_id}
className={`flex flex-col items-start p-3 cursor-pointer ${
!notification.is_read ? "bg-muted/30" : ""
}`}
onClick={() => handleNotificationClick(notification)}
>
<div className="flex w-full justify-between items-start gap-2">
<div className="font-medium text-sm line-clamp-1">
{notification.title}
</div>
{!notification.is_read && (
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-primary"
onClick={(e) => handleMarkAsRead(notification.notification_id, e)}
title="Mark as read"
>
<Check className="h-3 w-3" />
</Button>
)}
</div>
<div className="text-xs text-muted-foreground mt-1 line-clamp-2">
{notification.message}
</div>
<div className="text-[10px] text-muted-foreground mt-2 w-full text-right">
{formatDistanceToNow(new Date(notification.created_at), {
addSuffix: true,
})}
</div>
</DropdownMenuItem>
))}
</div>
)}
<DropdownMenuSeparator />
<DropdownMenuItem className="text-center justify-center text-xs text-muted-foreground cursor-pointer">
View All Notifications
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,126 +1,140 @@
// File: components/layout/sidebar.tsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { useUIStore } from "@/lib/stores/ui-store";
import { sidebarMenuItems, adminMenuItems } from "@/config/menu";
import {
LayoutDashboard,
FileText,
FileCheck,
PenTool,
Search,
Settings,
Shield,
Menu,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { ChevronLeft, Menu, X } from "lucide-react";
import { useEffect } from "react"; // ✅ Import useEffect
import { useState } from "react";
import { Can } from "@/components/common/can";
export function Sidebar() {
interface SidebarProps {
className?: string;
}
export function Sidebar({ className }: SidebarProps) {
const pathname = usePathname();
const { isSidebarOpen, toggleSidebar, closeSidebar } = useUIStore();
const [collapsed, setCollapsed] = useState(false);
// ✅ เพิ่ม Logic นี้: ปิด Sidebar อัตโนมัติเมื่อหน้าจอเล็กกว่า 768px (Mobile)
useEffect(() => {
const handleResize = () => {
if (window.innerWidth < 768 && isSidebarOpen) {
closeSidebar();
}
};
// ติดตั้ง Listener
window.addEventListener("resize", handleResize);
// ล้าง Listener เมื่อ Component ถูกทำลาย
return () => window.removeEventListener("resize", handleResize);
}, [isSidebarOpen, closeSidebar]);
const navItems = [
{
title: "Dashboard",
href: "/dashboard",
icon: LayoutDashboard,
permission: null, // Everyone can see
},
{
title: "Correspondences",
href: "/correspondences",
icon: FileText,
permission: null,
},
{
title: "RFAs",
href: "/rfas",
icon: FileCheck,
permission: null,
},
{
title: "Drawings",
href: "/drawings",
icon: PenTool,
permission: null,
},
{
title: "Search",
href: "/search",
icon: Search,
permission: null,
},
{
title: "Admin Panel",
href: "/admin",
icon: Shield,
permission: "admin", // Only admins
},
];
return (
<>
{/* Mobile Overlay */}
<div
className={cn(
"fixed inset-0 z-40 bg-background/80 backdrop-blur-sm transition-all duration-100 md:hidden",
isSidebarOpen ? "opacity-100" : "opacity-0 pointer-events-none"
<div
className={cn(
"flex flex-col h-screen border-r bg-card transition-all duration-300",
collapsed ? "w-16" : "w-64",
className
)}
>
<div className="h-16 flex items-center justify-between px-4 border-b">
{!collapsed && (
<span className="text-lg font-bold text-primary truncate">
LCBP3 DMS
</span>
)}
onClick={closeSidebar}
/>
<Button
variant="ghost"
size="icon"
onClick={() => setCollapsed(!collapsed)}
className={cn("ml-auto", collapsed && "mx-auto")}
>
<Menu className="h-5 w-5" />
</Button>
</div>
{/* Sidebar Container */}
<aside
className={cn(
"fixed top-0 left-0 z-50 h-screen border-r bg-card transition-all duration-300 ease-in-out flex flex-col",
// Mobile Width
"w-[240px]",
isSidebarOpen ? "translate-x-0" : "-translate-x-full",
<div className="flex-1 overflow-y-auto py-4">
<nav className="grid gap-1 px-2">
{navItems.map((item, index) => {
const isActive = pathname.startsWith(item.href);
// Desktop Styles
"md:translate-x-0",
isSidebarOpen ? "md:w-[240px]" : "md:w-[70px]"
)}
>
<div className={cn(
"flex h-14 items-center border-b px-3 lg:h-[60px]",
"justify-between md:justify-center",
isSidebarOpen && "md:justify-between"
)}>
<div className={cn(
"flex items-center gap-2 font-bold text-primary truncate transition-all duration-300",
!isSidebarOpen && "md:w-0 md:opacity-0 md:hidden"
)}>
<Link href="/dashboard">LCBP3 DMS</Link>
</div>
{/* Desktop Toggle */}
<Button
variant="ghost"
size="icon"
onClick={toggleSidebar}
className="hidden md:flex h-8 w-8"
>
{isSidebarOpen ? <ChevronLeft className="h-4 w-4" /> : <Menu className="h-4 w-4" />}
</Button>
{/* Mobile Close Button */}
<Button
variant="ghost"
size="icon"
onClick={closeSidebar} // ปุ่มนี้จะทำงานได้ถูกต้อง
className="md:hidden h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-y-auto overflow-x-hidden py-4">
<nav className="grid gap-1 px-2">
{sidebarMenuItems.map((item, index) => {
const Icon = item.icon;
const isActive = pathname.startsWith(item.href);
const LinkComponent = (
<Link
key={index}
href={item.href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground",
isActive ? "bg-accent text-accent-foreground" : "text-muted-foreground",
collapsed && "justify-center px-2"
)}
title={collapsed ? item.title : undefined}
>
<item.icon className="h-5 w-5" />
{!collapsed && <span>{item.title}</span>}
</Link>
);
if (item.permission) {
return (
<Link
key={index}
href={item.href}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground",
isActive ? "bg-accent text-accent-foreground" : "text-muted-foreground",
!isSidebarOpen && "md:justify-center md:px-2"
)}
title={!isSidebarOpen ? item.title : undefined}
onClick={() => {
if (window.innerWidth < 768) closeSidebar();
}}
>
<Icon className="h-4 w-4 shrink-0" />
<span className={cn(
"truncate transition-all duration-300",
!isSidebarOpen && "md:w-0 md:opacity-0 md:hidden"
)}>
{item.title}
</span>
</Link>
<Can key={index} permission={item.permission}>
{LinkComponent}
</Can>
);
})}
</nav>
</div>
</aside>
</>
}
return LinkComponent;
})}
</nav>
</div>
<div className="p-4 border-t">
<Link
href="/settings"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground",
collapsed && "justify-center px-2"
)}
title="Settings"
>
<Settings className="h-5 w-5" />
{!collapsed && <span>Settings</span>}
</Link>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,81 @@
"use client";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { signOut, useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { LogOut, Settings, User } from "lucide-react";
export function UserMenu() {
const router = useRouter();
const { data: session } = useSession();
const user = session?.user;
if (!user) return null;
// Generate initials from name or username
const getInitials = (name: string) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
const initials = user.name ? getInitials(user.name) : "U";
const handleLogout = async () => {
await signOut({ redirect: false });
router.push("/login");
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
<Avatar className="h-10 w-10">
<AvatarFallback className="bg-primary/10 text-primary">
{initials}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{user.name}</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email}
</p>
<p className="text-xs leading-none text-muted-foreground mt-1">
Role: {user.role}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push("/profile")}>
<User className="mr-2 h-4 w-4" />
<span>Profile</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push("/settings")}>
<Settings className="mr-2 h-4 w-4" />
<span>Settings</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="text-red-600 focus:text-red-600">
<LogOut className="mr-2 h-4 w-4" />
<span>Log out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}