summaryrefslogtreecommitdiff
path: root/lib/menu-list/table
diff options
context:
space:
mode:
Diffstat (limited to 'lib/menu-list/table')
-rw-r--r--lib/menu-list/table/initialize-button.tsx42
-rw-r--r--lib/menu-list/table/manager-select.tsx192
-rw-r--r--lib/menu-list/table/menu-list-table.tsx280
3 files changed, 514 insertions, 0 deletions
diff --git a/lib/menu-list/table/initialize-button.tsx b/lib/menu-list/table/initialize-button.tsx
new file mode 100644
index 00000000..1b8a2458
--- /dev/null
+++ b/lib/menu-list/table/initialize-button.tsx
@@ -0,0 +1,42 @@
+// app/evcp/menu-list/components/initialize-button.tsx
+
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { RefreshCw } from "lucide-react";
+import { toast } from "sonner";
+import { initializeMenuAssignments } from "../servcie";
+
+export function InitializeButton() {
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleInitialize = async () => {
+ setIsLoading(true);
+ try {
+ const result = await initializeMenuAssignments();
+
+ if (result.success) {
+ toast.success(result.message);
+ } else {
+ toast.error(result.message);
+ }
+ } catch (error) {
+ toast.error("메뉴 초기화 중 오류가 발생했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+ <Button
+ onClick={handleInitialize}
+ disabled={isLoading}
+ variant="outline"
+ size="sm"
+ >
+ <RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
+ 메뉴 초기화
+ </Button>
+ );
+} \ No newline at end of file
diff --git a/lib/menu-list/table/manager-select.tsx b/lib/menu-list/table/manager-select.tsx
new file mode 100644
index 00000000..a4bcccd7
--- /dev/null
+++ b/lib/menu-list/table/manager-select.tsx
@@ -0,0 +1,192 @@
+"use client";
+
+import { useState } from "react";
+import { Check, ChevronsUpDown, Search, X } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { toast } from "sonner";
+import { updateMenuManager } from "../servcie";
+
+interface User {
+ id: number;
+ name: string;
+ email: string;
+ domain: string;
+}
+
+interface ManagerSelectProps {
+ menuPath: string;
+ currentManagerId?: number | null;
+ users: User[];
+ placeholder: string;
+ otherManagerId?: number | null; // 다른 담당자 ID (중복 선택 방지용)
+ type: "manager1" | "manager2";
+}
+
+export function ManagerSelect({
+ menuPath,
+ currentManagerId,
+ users,
+ placeholder,
+ otherManagerId,
+ type
+}: ManagerSelectProps) {
+ const [open, setOpen] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+
+ // 선택 가능한 사용자 필터링 (다른 담당자로 이미 선택된 사용자 제외)
+ const availableUsers = users.filter(user =>
+ user.id !== otherManagerId || user.id === currentManagerId
+ );
+
+ // 현재 선택된 사용자 찾기
+ const selectedUser = availableUsers.find(user => user.id === currentManagerId);
+
+ const handleSelect = async (userId: number | null) => {
+ setIsLoading(true);
+ setOpen(false);
+
+ try {
+ // 현재 담당자 정보 구성
+ const updateData = type === "manager1"
+ ? { manager1Id: userId, manager2Id: otherManagerId }
+ : { manager1Id: otherManagerId, manager2Id: userId };
+
+ const result = await updateMenuManager(
+ menuPath,
+ updateData.manager1Id,
+ updateData.manager2Id
+ );
+
+ if (result.success) {
+ toast.success(result.message);
+ } else {
+ toast.error(result.message);
+ }
+ } catch (error) {
+ toast.error("담당자 업데이트 중 오류가 발생했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleClear = () => {
+ handleSelect(null);
+ };
+
+ return (
+ <Popover open={open} onOpenChange={setOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={open}
+ className="w-full justify-between text-left font-normal"
+ disabled={isLoading}
+ >
+ {selectedUser ? (
+ <div className="flex items-center justify-between w-full">
+ <div className="flex flex-col min-w-0 flex-1">
+ <span className="font-medium truncate">{selectedUser.name}</span>
+ <span className="text-xs text-muted-foreground truncate">
+ {selectedUser.email}
+ </span>
+ </div>
+ {!isLoading && (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-auto p-1 hover:bg-transparent"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleClear();
+ }}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ )}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">{placeholder}</span>
+ )}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+
+ <PopoverContent className="w-[400px] p-0" align="start">
+ <Command>
+ <CommandInput
+ placeholder="사용자 검색..."
+ className="h-9"
+ />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {/* 담당자 없음 옵션 */}
+ <CommandItem
+ value="none"
+ onSelect={() => handleSelect(null)}
+ className="flex items-center gap-2"
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ !selectedUser ? "opacity-100" : "opacity-0"
+ )}
+ />
+ <div className="flex flex-col">
+ <span className="font-medium">담당자 없음</span>
+ <span className="text-xs text-muted-foreground">
+ 담당자를 지정하지 않습니다
+ </span>
+ </div>
+ </CommandItem>
+
+ {/* 사용자 목록 */}
+ {availableUsers.map((user) => (
+ <CommandItem
+ key={user.id}
+ value={`${user.name} ${user.email}`} // 검색을 위해 이름과 이메일 모두 포함
+ onSelect={() => handleSelect(user.id)}
+ className="flex items-center gap-2"
+ disabled={user.id === otherManagerId && user.id !== currentManagerId}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ selectedUser?.id === user.id ? "opacity-100" : "opacity-0"
+ )}
+ />
+ <div className="flex flex-col min-w-0 flex-1">
+ <span className="font-medium truncate">{user.name}</span>
+ <span className="text-xs text-muted-foreground truncate">
+ {user.email}
+ </span>
+ </div>
+ {user.id === otherManagerId && user.id !== currentManagerId && (
+ <span className="text-xs text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded">
+ 다른 담당자로 선택됨
+ </span>
+ )}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ );
+} \ No newline at end of file
diff --git a/lib/menu-list/table/menu-list-table.tsx b/lib/menu-list/table/menu-list-table.tsx
new file mode 100644
index 00000000..097be082
--- /dev/null
+++ b/lib/menu-list/table/menu-list-table.tsx
@@ -0,0 +1,280 @@
+// app/evcp/menu-list/components/menu-list-table.tsx
+
+"use client";
+
+import { useState, useMemo } from "react";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Input } from "@/components/ui/input";
+import { Badge } from "@/components/ui/badge";
+import { Switch } from "@/components/ui/switch";
+import { Button } from "@/components/ui/button";
+import { Search, Filter, ExternalLink } from "lucide-react";
+import { toast } from "sonner";
+import { ManagerSelect } from "./manager-select";
+import { toggleMenuActive } from "../servcie";
+
+interface MenuAssignment {
+ id: number;
+ menuPath: string;
+ menuTitle: string;
+ menuDescription?: string | null;
+ menuGroup?: string | null;
+ sectionTitle: string;
+ domain: string;
+ isActive: boolean;
+ createdAt: Date;
+ updatedAt: Date;
+ manager1Id?: number | null;
+ manager2Id?: number | null;
+ manager1Name?: string | null;
+ manager1Email?: string | null;
+ manager2Name?: string | null;
+ manager2Email?: string | null;
+}
+
+interface User {
+ id: number;
+ name: string;
+ email: string;
+ domain: string;
+}
+
+interface MenuListTableProps {
+ initialMenus: MenuAssignment[];
+ initialUsers: User[];
+}
+
+export function MenuListTable({ initialMenus, initialUsers }: MenuListTableProps) {
+ const [searchQuery, setSearchQuery] = useState("");
+ const [domainFilter, setDomainFilter] = useState<string>("all");
+ const [sectionFilter, setSectionFilter] = useState<string>("all");
+ const [statusFilter, setStatusFilter] = useState<string>("all");
+
+ // 필터링된 메뉴 데이터
+ const filteredMenus = useMemo(() => {
+ return initialMenus.filter((menu) => {
+ const matchesSearch =
+ menu.menuTitle.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ menu.menuPath.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ menu.sectionTitle.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ (menu.menuDescription?.toLowerCase().includes(searchQuery.toLowerCase()) ?? false);
+
+ const matchesDomain = domainFilter === "all" || menu.domain === domainFilter;
+ const matchesSection = sectionFilter === "all" || menu.sectionTitle === sectionFilter;
+ const matchesStatus = statusFilter === "all" ||
+ (statusFilter === "active" && menu.isActive) ||
+ (statusFilter === "inactive" && !menu.isActive);
+
+ return matchesSearch && matchesDomain && matchesSection && matchesStatus;
+ });
+ }, [initialMenus, searchQuery, domainFilter, sectionFilter, statusFilter]);
+
+ // 섹션 리스트 추출
+ const sections = useMemo(() => {
+ const sectionSet = new Set(initialMenus.map(menu => menu.sectionTitle));
+ return Array.from(sectionSet).sort();
+ }, [initialMenus]);
+
+ // 도메인별 사용자 필터링
+ const getFilteredUsers = (domain: string) => {
+ return initialUsers.filter(user => user.domain === domain);
+ };
+
+ // 메뉴 활성화/비활성화 토글
+ const handleToggleActive = async (menuPath: string, isActive: boolean) => {
+ try {
+ const result = await toggleMenuActive(menuPath, isActive);
+ if (result.success) {
+ toast.success(result.message);
+ } else {
+ toast.error(result.message);
+ }
+ } catch (error) {
+ toast.error("메뉴 상태 변경 중 오류가 발생했습니다.");
+ }
+ };
+
+ return (
+ <div className="space-y-4">
+ {/* 필터 영역 */}
+ <div className="flex flex-col sm:flex-row gap-4">
+ <div className="flex-1">
+ <div className="relative">
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="메뉴명, 경로, 설명으로 검색..."
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ className="pl-8"
+ />
+ </div>
+ </div>
+
+ <div className="flex gap-2">
+ <Select value={domainFilter} onValueChange={setDomainFilter}>
+ <SelectTrigger className="w-[120px]">
+ <SelectValue placeholder="도메인" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="all">전체 도메인</SelectItem>
+ <SelectItem value="evcp">EVCP</SelectItem>
+ <SelectItem value="partners">Partners</SelectItem>
+ </SelectContent>
+ </Select>
+
+ <Select value={sectionFilter} onValueChange={setSectionFilter}>
+ <SelectTrigger className="w-[150px]">
+ <SelectValue placeholder="섹션" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="all">전체 섹션</SelectItem>
+ {sections.map((section) => (
+ <SelectItem key={section} value={section}>
+ {section}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+
+ <Select value={statusFilter} onValueChange={setStatusFilter}>
+ <SelectTrigger className="w-[100px]">
+ <SelectValue placeholder="상태" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="all">전체</SelectItem>
+ <SelectItem value="active">활성</SelectItem>
+ <SelectItem value="inactive">비활성</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ {/* 결과 요약 */}
+ <div className="flex items-center justify-between text-sm text-muted-foreground">
+ <span>
+ 총 {filteredMenus.length}개의 메뉴
+ {searchQuery && ` (${initialMenus.length}개 중 검색 결과)`}
+ </span>
+ </div>
+
+ {/* 테이블 */}
+ <div className="rounded-md border">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[50px]">상태</TableHead>
+ <TableHead>메뉴 정보</TableHead>
+ <TableHead className="w-[100px]">도메인</TableHead>
+ <TableHead className="w-[200px]">담당자 1</TableHead>
+ <TableHead className="w-[200px]">담당자 2</TableHead>
+ {/* <TableHead className="w-[80px]">동작</TableHead> */}
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {filteredMenus.length === 0 ? (
+ <TableRow>
+ <TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
+ 조건에 맞는 메뉴가 없습니다.
+ </TableCell>
+ </TableRow>
+ ) : (
+ filteredMenus.map((menu) => {
+ const domainUsers = getFilteredUsers(menu.domain);
+
+ return (
+ <TableRow key={menu.id}>
+ <TableCell>
+ <Switch
+ checked={menu.isActive}
+ onCheckedChange={(checked) => handleToggleActive(menu.menuPath, checked)}
+ />
+ </TableCell>
+
+ <TableCell>
+ <div className="space-y-1">
+ <div className="flex items-center gap-2">
+ <span className="font-medium">{menu.menuTitle}</span>
+ <Badge variant="outline" className="text-xs">
+ {menu.sectionTitle}
+ </Badge>
+ {menu.menuGroup && (
+ <Badge variant="secondary" className="text-xs">
+ {menu.menuGroup}
+ </Badge>
+ )}
+ </div>
+ <div className="text-xs text-muted-foreground font-mono">
+ {menu.menuPath}
+ </div>
+ {menu.menuDescription && (
+ <div className="text-xs text-muted-foreground">
+ {menu.menuDescription}
+ </div>
+ )}
+ </div>
+ </TableCell>
+
+ <TableCell>
+ <Badge
+ variant={menu.domain === "evcp" ? "default" : "secondary"}
+ className="text-xs"
+ >
+ {menu.domain.toUpperCase()}
+ </Badge>
+ </TableCell>
+
+ <TableCell>
+ <ManagerSelect
+ menuPath={menu.menuPath}
+ currentManagerId={menu.manager1Id}
+ users={domainUsers}
+ placeholder="담당자 1 선택"
+ otherManagerId={menu.manager2Id}
+ type="manager1"
+ />
+ </TableCell>
+
+ <TableCell>
+ <ManagerSelect
+ menuPath={menu.menuPath}
+ currentManagerId={menu.manager2Id}
+ users={domainUsers}
+ placeholder="담당자 2 선택"
+ otherManagerId={menu.manager1Id}
+ type="manager2"
+ />
+ </TableCell>
+
+ {/* <TableCell>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => window.open(menu.menuPath, '_blank')}
+ >
+ <ExternalLink className="h-3 w-3" />
+ </Button>
+ </TableCell> */}
+ </TableRow>
+ );
+ })
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ </div>
+ );
+} \ No newline at end of file