2025-09-10 18:36:28 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useState, useEffect } from "react";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
|
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
|
|
|
import { LayoutFormModal } from "@/components/admin/LayoutFormModal";
|
|
|
|
|
import {
|
|
|
|
|
DropdownMenu,
|
|
|
|
|
DropdownMenuContent,
|
|
|
|
|
DropdownMenuItem,
|
|
|
|
|
DropdownMenuTrigger,
|
|
|
|
|
} from "@/components/ui/dropdown-menu";
|
|
|
|
|
import {
|
|
|
|
|
AlertDialog,
|
|
|
|
|
AlertDialogAction,
|
|
|
|
|
AlertDialogCancel,
|
|
|
|
|
AlertDialogContent,
|
|
|
|
|
AlertDialogDescription,
|
|
|
|
|
AlertDialogFooter,
|
|
|
|
|
AlertDialogHeader,
|
|
|
|
|
AlertDialogTitle,
|
|
|
|
|
} from "@/components/ui/alert-dialog";
|
|
|
|
|
import {
|
|
|
|
|
Plus,
|
|
|
|
|
Search,
|
|
|
|
|
MoreHorizontal,
|
|
|
|
|
Edit,
|
|
|
|
|
Copy,
|
|
|
|
|
Trash2,
|
|
|
|
|
Eye,
|
|
|
|
|
Grid,
|
|
|
|
|
Layout,
|
|
|
|
|
LayoutDashboard,
|
|
|
|
|
Table,
|
|
|
|
|
Navigation,
|
|
|
|
|
FileText,
|
|
|
|
|
Building,
|
|
|
|
|
} from "lucide-react";
|
|
|
|
|
import { LayoutStandard, LAYOUT_CATEGORIES, LayoutCategory } from "@/types/layout";
|
|
|
|
|
import { layoutApi } from "@/lib/api/layout";
|
|
|
|
|
import { toast } from "sonner";
|
|
|
|
|
|
|
|
|
|
// 코드 레벨 레이아웃 타입
|
|
|
|
|
interface CodeLayout {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
nameEng?: string;
|
|
|
|
|
description?: string;
|
|
|
|
|
category: string;
|
|
|
|
|
type: "code";
|
|
|
|
|
isActive: boolean;
|
|
|
|
|
tags: string[];
|
|
|
|
|
metadata?: any;
|
|
|
|
|
zones: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 카테고리 아이콘 매핑
|
|
|
|
|
const CATEGORY_ICONS = {
|
|
|
|
|
basic: Grid,
|
|
|
|
|
form: FileText,
|
|
|
|
|
table: Table,
|
|
|
|
|
dashboard: LayoutDashboard,
|
|
|
|
|
navigation: Navigation,
|
|
|
|
|
content: Layout,
|
|
|
|
|
business: Building,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 카테고리 이름 매핑
|
|
|
|
|
const CATEGORY_NAMES = {
|
|
|
|
|
basic: "기본",
|
|
|
|
|
form: "폼",
|
|
|
|
|
table: "테이블",
|
|
|
|
|
dashboard: "대시보드",
|
|
|
|
|
navigation: "네비게이션",
|
|
|
|
|
content: "컨텐츠",
|
|
|
|
|
business: "업무용",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default function LayoutManagementPage() {
|
|
|
|
|
const [layouts, setLayouts] = useState<LayoutStandard[]>([]);
|
|
|
|
|
const [codeLayouts, setCodeLayouts] = useState<CodeLayout[]>([]);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [codeLoading, setCodeLoading] = useState(true);
|
|
|
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
|
|
|
const [selectedCategory, setSelectedCategory] = useState<string>("all");
|
|
|
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
|
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
|
|
|
const [total, setTotal] = useState(0);
|
|
|
|
|
const [activeTab, setActiveTab] = useState("db");
|
|
|
|
|
|
|
|
|
|
// 모달 상태
|
|
|
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
|
|
|
const [layoutToDelete, setLayoutToDelete] = useState<LayoutStandard | null>(null);
|
|
|
|
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
// 카테고리별 개수
|
|
|
|
|
const [categoryCounts, setCategoryCounts] = useState<Record<string, number>>({});
|
|
|
|
|
|
|
|
|
|
// 레이아웃 목록 로드
|
|
|
|
|
const loadLayouts = async () => {
|
|
|
|
|
try {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
const params = {
|
|
|
|
|
page: currentPage,
|
|
|
|
|
size: 20,
|
|
|
|
|
searchTerm: searchTerm || undefined,
|
|
|
|
|
category: selectedCategory !== "all" ? (selectedCategory as LayoutCategory) : undefined,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const response = await layoutApi.getLayouts(params);
|
|
|
|
|
setLayouts(response.data);
|
|
|
|
|
setTotalPages(response.totalPages);
|
|
|
|
|
setTotal(response.total);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("레이아웃 목록 조회 실패:", error);
|
|
|
|
|
toast.error("레이아웃 목록을 불러오는데 실패했습니다.");
|
|
|
|
|
setLayouts([]);
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 카테고리별 개수 로드
|
|
|
|
|
const loadCategoryCounts = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const counts = await layoutApi.getLayoutCountsByCategory();
|
|
|
|
|
setCategoryCounts(counts);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("카테고리 개수 조회 실패:", error);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 코드 레벨 레이아웃 로드
|
|
|
|
|
const loadCodeLayouts = async () => {
|
|
|
|
|
try {
|
|
|
|
|
setCodeLoading(true);
|
|
|
|
|
const response = await fetch("/api/admin/layouts/list");
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
|
|
|
|
|
if (result.success) {
|
|
|
|
|
setCodeLayouts(result.data.codeLayouts);
|
|
|
|
|
} else {
|
|
|
|
|
toast.error("코드 레이아웃 목록을 불러오는데 실패했습니다.");
|
|
|
|
|
setCodeLayouts([]);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("코드 레이아웃 조회 실패:", error);
|
|
|
|
|
toast.error("코드 레이아웃 목록을 불러오는데 실패했습니다.");
|
|
|
|
|
setCodeLayouts([]);
|
|
|
|
|
} finally {
|
|
|
|
|
setCodeLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
loadLayouts();
|
|
|
|
|
}, [currentPage, selectedCategory]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
loadCategoryCounts();
|
|
|
|
|
loadCodeLayouts();
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 검색
|
|
|
|
|
const handleSearch = () => {
|
|
|
|
|
setCurrentPage(1);
|
|
|
|
|
loadLayouts();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 엔터키 검색
|
|
|
|
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
|
|
|
|
if (e.key === "Enter") {
|
|
|
|
|
handleSearch();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 레이아웃 삭제
|
|
|
|
|
const handleDelete = async (layout: LayoutStandard) => {
|
|
|
|
|
setLayoutToDelete(layout);
|
|
|
|
|
setDeleteDialogOpen(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const confirmDelete = async () => {
|
|
|
|
|
if (!layoutToDelete) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await layoutApi.deleteLayout(layoutToDelete.layoutCode);
|
|
|
|
|
toast.success("레이아웃이 삭제되었습니다.");
|
|
|
|
|
loadLayouts();
|
|
|
|
|
loadCategoryCounts();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("레이아웃 삭제 실패:", error);
|
|
|
|
|
toast.error("레이아웃 삭제에 실패했습니다.");
|
|
|
|
|
} finally {
|
|
|
|
|
setDeleteDialogOpen(false);
|
|
|
|
|
setLayoutToDelete(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 레이아웃 복제
|
|
|
|
|
const handleDuplicate = async (layout: LayoutStandard) => {
|
|
|
|
|
try {
|
|
|
|
|
const newName = `${layout.layoutName} (복사)`;
|
|
|
|
|
await layoutApi.duplicateLayout(layout.layoutCode, { newName });
|
|
|
|
|
toast.success("레이아웃이 복제되었습니다.");
|
|
|
|
|
loadLayouts();
|
|
|
|
|
loadCategoryCounts();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("레이아웃 복제 실패:", error);
|
|
|
|
|
toast.error("레이아웃 복제에 실패했습니다.");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 페이지네이션
|
|
|
|
|
const handlePageChange = (page: number) => {
|
|
|
|
|
setCurrentPage(page);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
2025-09-24 18:07:36 +09:00
|
|
|
<div className="min-h-screen bg-gray-50">
|
|
|
|
|
<div className="container mx-auto p-6 space-y-6">
|
|
|
|
|
{/* 페이지 제목 */}
|
2025-09-25 09:29:56 +09:00
|
|
|
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
2025-09-24 18:07:36 +09:00
|
|
|
<div>
|
|
|
|
|
<h1 className="text-3xl font-bold text-gray-900">레이아웃 관리</h1>
|
|
|
|
|
<p className="mt-2 text-gray-600">화면 레이아웃을 생성하고 관리합니다</p>
|
|
|
|
|
</div>
|
2025-09-25 09:29:56 +09:00
|
|
|
<Button className="flex items-center gap-2 shadow-sm" onClick={() => setCreateModalOpen(true)}>
|
|
|
|
|
<Plus className="h-4 w-4" />새 레이아웃
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2025-09-10 18:36:28 +09:00
|
|
|
|
|
|
|
|
{/* 검색 및 필터 */}
|
2025-09-25 09:29:56 +09:00
|
|
|
<Card className="mb-6 shadow-sm">
|
2025-09-10 18:36:28 +09:00
|
|
|
<CardContent className="pt-6">
|
|
|
|
|
<div className="flex items-center gap-4">
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="레이아웃 이름 또는 설명으로 검색..."
|
|
|
|
|
value={searchTerm}
|
|
|
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
|
|
|
onKeyPress={handleKeyPress}
|
|
|
|
|
className="pl-10"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<Button onClick={handleSearch}>검색</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{/* 카테고리 탭 */}
|
|
|
|
|
<Tabs value={selectedCategory} onValueChange={setSelectedCategory} className="mb-6">
|
|
|
|
|
<TabsList className="grid w-full grid-cols-8">
|
|
|
|
|
<TabsTrigger value="all" className="flex items-center gap-2">
|
|
|
|
|
전체 ({total})
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
{Object.entries(LAYOUT_CATEGORIES).map(([key, value]) => {
|
|
|
|
|
const Icon = CATEGORY_ICONS[value as keyof typeof CATEGORY_ICONS];
|
|
|
|
|
const count = categoryCounts[value] || 0;
|
|
|
|
|
return (
|
|
|
|
|
<TabsTrigger key={key} value={value} className="flex items-center gap-2">
|
|
|
|
|
<Icon className="h-4 w-4" />
|
|
|
|
|
{CATEGORY_NAMES[value as keyof typeof CATEGORY_NAMES]} ({count})
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</TabsList>
|
|
|
|
|
|
|
|
|
|
<div className="mt-6">
|
|
|
|
|
{loading ? (
|
|
|
|
|
<div className="py-8 text-center">로딩 중...</div>
|
|
|
|
|
) : layouts.length === 0 ? (
|
|
|
|
|
<div className="py-8 text-center text-gray-500">레이아웃이 없습니다.</div>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
{/* 레이아웃 그리드 */}
|
|
|
|
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
|
|
|
{layouts.map((layout) => {
|
|
|
|
|
const CategoryIcon = CATEGORY_ICONS[layout.category as keyof typeof CATEGORY_ICONS];
|
|
|
|
|
return (
|
2025-09-25 09:29:56 +09:00
|
|
|
<Card key={layout.layoutCode} className="shadow-sm transition-shadow hover:shadow-lg">
|
2025-09-10 18:36:28 +09:00
|
|
|
<CardHeader className="pb-3">
|
|
|
|
|
<div className="flex items-start justify-between">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<CategoryIcon className="h-5 w-5 text-gray-600" />
|
|
|
|
|
<Badge variant="secondary">
|
|
|
|
|
{CATEGORY_NAMES[layout.category as keyof typeof CATEGORY_NAMES]}
|
|
|
|
|
</Badge>
|
|
|
|
|
</div>
|
|
|
|
|
<DropdownMenu>
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<Button variant="ghost" size="sm">
|
|
|
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent align="end">
|
|
|
|
|
<DropdownMenuItem>
|
|
|
|
|
<Eye className="mr-2 h-4 w-4" />
|
|
|
|
|
미리보기
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
<DropdownMenuItem>
|
|
|
|
|
<Edit className="mr-2 h-4 w-4" />
|
|
|
|
|
편집
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
<DropdownMenuItem onClick={() => handleDuplicate(layout)}>
|
|
|
|
|
<Copy className="mr-2 h-4 w-4" />
|
|
|
|
|
복제
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
<DropdownMenuItem onClick={() => handleDelete(layout)} className="text-red-600">
|
|
|
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
|
|
|
삭제
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
</div>
|
|
|
|
|
<CardTitle className="text-lg">{layout.layoutName}</CardTitle>
|
|
|
|
|
{layout.description && (
|
|
|
|
|
<p className="line-clamp-2 text-sm text-gray-600">{layout.description}</p>
|
|
|
|
|
)}
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div className="flex items-center justify-between text-sm">
|
|
|
|
|
<span className="text-gray-500">타입:</span>
|
|
|
|
|
<Badge variant="outline">{layout.layoutType}</Badge>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center justify-between text-sm">
|
|
|
|
|
<span className="text-gray-500">존 개수:</span>
|
|
|
|
|
<span>{layout.zonesConfig.length}개</span>
|
|
|
|
|
</div>
|
|
|
|
|
{layout.isPublic === "Y" && (
|
|
|
|
|
<Badge variant="default" className="w-full justify-center">
|
|
|
|
|
공개 레이아웃
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 페이지네이션 */}
|
|
|
|
|
{totalPages > 1 && (
|
|
|
|
|
<div className="mt-8 flex justify-center">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => handlePageChange(currentPage - 1)}
|
|
|
|
|
disabled={currentPage === 1}
|
|
|
|
|
>
|
|
|
|
|
이전
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
|
|
|
|
<Button
|
|
|
|
|
key={page}
|
|
|
|
|
variant={currentPage === page ? "default" : "outline"}
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => handlePageChange(page)}
|
|
|
|
|
>
|
|
|
|
|
{page}
|
|
|
|
|
</Button>
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => handlePageChange(currentPage + 1)}
|
|
|
|
|
disabled={currentPage === totalPages}
|
|
|
|
|
>
|
|
|
|
|
다음
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</Tabs>
|
|
|
|
|
|
|
|
|
|
{/* 삭제 확인 다이얼로그 */}
|
|
|
|
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
|
|
|
<AlertDialogContent>
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
<AlertDialogTitle>레이아웃 삭제</AlertDialogTitle>
|
|
|
|
|
<AlertDialogDescription>
|
|
|
|
|
정말로 "{layoutToDelete?.layoutName}" 레이아웃을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
<AlertDialogFooter>
|
|
|
|
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
|
|
|
|
<AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">
|
|
|
|
|
삭제
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
</AlertDialog>
|
|
|
|
|
|
|
|
|
|
{/* 새 레이아웃 생성 모달 */}
|
|
|
|
|
<LayoutFormModal
|
|
|
|
|
open={createModalOpen}
|
|
|
|
|
onOpenChange={setCreateModalOpen}
|
|
|
|
|
onSuccess={() => {
|
|
|
|
|
loadLayouts();
|
|
|
|
|
loadCategoryCounts();
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2025-09-24 18:07:36 +09:00
|
|
|
</div>
|
2025-09-10 18:36:28 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|