Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management

This commit is contained in:
kjs
2025-10-22 14:54:50 +09:00
35 changed files with 4794 additions and 1218 deletions

View File

@@ -1,20 +1,14 @@
"use client";
import React, { useState, useEffect } from "react";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { dashboardApi } from "@/lib/api/dashboard";
import { Dashboard } from "@/lib/api/dashboard";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
AlertDialog,
AlertDialogAction,
@@ -25,8 +19,9 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Plus, Search, MoreVertical, Edit, Trash2, Copy, CheckCircle2 } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { Pagination, PaginationInfo } from "@/components/common/Pagination";
import { Plus, Search, Edit, Trash2, Copy, LayoutDashboard, MoreHorizontal } from "lucide-react";
/**
* 대시보드 관리 페이지
@@ -35,27 +30,38 @@ import { Plus, Search, MoreVertical, Edit, Trash2, Copy, CheckCircle2 } from "lu
*/
export default function DashboardListPage() {
const router = useRouter();
const { toast } = useToast();
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [error, setError] = useState<string | null>(null);
// 페이지네이션 상태
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [totalCount, setTotalCount] = useState(0);
// 모달 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null);
const [successDialogOpen, setSuccessDialogOpen] = useState(false);
const [successMessage, setSuccessMessage] = useState("");
// 대시보드 목록 로드
const loadDashboards = async () => {
try {
setLoading(true);
setError(null);
const result = await dashboardApi.getMyDashboards({ search: searchTerm });
const result = await dashboardApi.getMyDashboards({
search: searchTerm,
page: currentPage,
limit: pageSize,
});
setDashboards(result.dashboards);
setTotalCount(result.pagination.total);
} catch (err) {
console.error("Failed to load dashboards:", err);
setError("대시보드 목록을 불러오는데 실패했습니다.");
toast({
title: "오류",
description: "대시보드 목록을 불러오는데 실패했습니다.",
variant: "destructive",
});
} finally {
setLoading(false);
}
@@ -63,7 +69,29 @@ export default function DashboardListPage() {
useEffect(() => {
loadDashboards();
}, [searchTerm]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchTerm, currentPage, pageSize]);
// 페이지네이션 정보 계산
const paginationInfo: PaginationInfo = {
currentPage,
totalPages: Math.ceil(totalCount / pageSize),
totalItems: totalCount,
itemsPerPage: pageSize,
startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1,
endItem: Math.min(currentPage * pageSize, totalCount),
};
// 페이지 변경 핸들러
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
// 페이지 크기 변경 핸들러
const handlePageSizeChange = (size: number) => {
setPageSize(size);
setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로
};
// 대시보드 삭제 확인 모달 열기
const handleDeleteClick = (id: string, title: string) => {
@@ -79,37 +107,48 @@ export default function DashboardListPage() {
await dashboardApi.deleteDashboard(deleteTarget.id);
setDeleteDialogOpen(false);
setDeleteTarget(null);
setSuccessMessage("대시보드가 삭제되었습니다.");
setSuccessDialogOpen(true);
toast({
title: "성공",
description: "대시보드가 삭제되었습니다.",
});
loadDashboards();
} catch (err) {
console.error("Failed to delete dashboard:", err);
setDeleteDialogOpen(false);
setError("대시보드 삭제에 실패했습니다.");
toast({
title: "오류",
description: "대시보드 삭제에 실패했습니다.",
variant: "destructive",
});
}
};
// 대시보드 복사
const handleCopy = async (dashboard: Dashboard) => {
try {
// 전체 대시보드 정보(요소 포함)를 가져오기
const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
const newDashboard = await dashboardApi.createDashboard({
await dashboardApi.createDashboard({
title: `${fullDashboard.title} (복사본)`,
description: fullDashboard.description,
elements: fullDashboard.elements || [],
isPublic: false,
tags: fullDashboard.tags,
category: fullDashboard.category,
settings: (fullDashboard as any).settings, // 해상도와 배경색 설정도 복사
settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string },
});
toast({
title: "성공",
description: "대시보드가 복사되었습니다.",
});
setSuccessMessage("대시보드가 복사되었습니다.");
setSuccessDialogOpen(true);
loadDashboards();
} catch (err) {
console.error("Failed to copy dashboard:", err);
setError("대시보드 복사에 실패했습니다.");
toast({
title: "오류",
description: "대시보드 복사에 실패했습니다.",
variant: "destructive",
});
}
};
@@ -119,35 +158,33 @@ export default function DashboardListPage() {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
};
if (loading) {
return (
<div className="flex h-full items-center justify-center rounded-lg border bg-card shadow-sm">
<div className="bg-card flex h-full items-center justify-center rounded-lg border shadow-sm">
<div className="text-center">
<div className="text-sm font-medium"> ...</div>
<div className="mt-2 text-xs text-muted-foreground"> </div>
<div className="text-muted-foreground mt-2 text-xs"> </div>
</div>
</div>
);
}
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> </p>
<p className="text-muted-foreground text-sm"> </p>
</div>
{/* 검색 및 액션 */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="relative w-full sm:w-[300px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="대시보드 검색..."
value={searchTerm}
@@ -156,40 +193,39 @@ export default function DashboardListPage() {
/>
</div>
<Button onClick={() => router.push("/admin/dashboard/new")} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" />
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 에러 메시지 */}
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-destructive"> </p>
<p className="text-destructive text-sm font-semibold"> </p>
<button
onClick={() => setError(null)}
className="text-destructive transition-colors hover:text-destructive/80"
className="text-destructive hover:text-destructive/80 transition-colors"
aria-label="에러 메시지 닫기"
>
</button>
</div>
<p className="mt-1.5 text-sm text-destructive/80">{error}</p>
<p className="text-destructive/80 mt-1.5 text-sm">{error}</p>
</div>
)}
{/* 대시보드 목록 */}
{dashboards.length === 0 ? (
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-sm text-muted-foreground"> </p>
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
) : (
<div className="rounded-lg border bg-card shadow-sm">
<div className="bg-card rounded-lg border shadow-sm">
<Table>
<TableHeader>
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
@@ -199,13 +235,17 @@ export default function DashboardListPage() {
</TableHeader>
<TableBody>
{dashboards.map((dashboard) => (
<TableRow key={dashboard.id} className="border-b transition-colors hover:bg-muted/50">
<TableRow key={dashboard.id} className="hover:bg-muted/50 border-b transition-colors">
<TableCell className="h-16 text-sm font-medium">{dashboard.title}</TableCell>
<TableCell className="h-16 max-w-md truncate text-sm text-muted-foreground">
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
{dashboard.description || "-"}
</TableCell>
<TableCell className="h-16 text-sm text-muted-foreground">{formatDate(dashboard.createdAt)}</TableCell>
<TableCell className="h-16 text-sm text-muted-foreground">{formatDate(dashboard.updatedAt)}</TableCell>
<TableCell className="text-muted-foreground h-16 text-sm">
{formatDate(dashboard.createdAt)}
</TableCell>
<TableCell className="text-muted-foreground h-16 text-sm">
{formatDate(dashboard.updatedAt)}
</TableCell>
<TableCell className="h-16 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -227,7 +267,7 @@ export default function DashboardListPage() {
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
className="gap-2 text-sm text-destructive focus:text-destructive"
className="text-destructive focus:text-destructive gap-2 text-sm"
>
<Trash2 className="h-4 w-4" />
@@ -241,6 +281,17 @@ export default function DashboardListPage() {
</Table>
</div>
)}
{/* 페이지네이션 */}
{!loading && dashboards.length > 0 && (
<Pagination
paginationInfo={paginationInfo}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
showPageSizeSelector={true}
pageSizeOptions={[10, 20, 50, 100]}
/>
)}
</div>
{/* 삭제 확인 모달 */}
@@ -250,15 +301,14 @@ export default function DashboardListPage() {
<AlertDialogTitle className="text-base sm:text-lg"> </AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm">
&quot;{deleteTarget?.title}&quot; ?
<br />
.
<br /> .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="gap-2 sm:gap-0">
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"></AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
className="h-8 flex-1 bg-destructive text-xs hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm"
className="bg-destructive hover:bg-destructive/90 h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</AlertDialogAction>
@@ -270,8 +320,8 @@ export default function DashboardListPage() {
<Dialog open={successDialogOpen} onOpenChange={setSuccessDialogOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-md">
<DialogHeader>
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<CheckCircle2 className="h-6 w-6 text-primary" />
<div className="bg-primary/10 mx-auto flex h-12 w-12 items-center justify-center rounded-full">
<CheckCircle2 className="text-primary h-6 w-6" />
</div>
<DialogTitle className="text-center text-base sm:text-lg"></DialogTitle>
<DialogDescription className="text-center text-xs sm:text-sm">{successMessage}</DialogDescription>

View File

@@ -18,6 +18,7 @@ import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
import { CreateTableModal } from "@/components/admin/CreateTableModal";
import { AddColumnModal } from "@/components/admin/AddColumnModal";
import { DDLLogViewer } from "@/components/admin/DDLLogViewer";
import { TableLogViewer } from "@/components/admin/TableLogViewer";
import { ScrollToTop } from "@/components/common/ScrollToTop";
interface TableInfo {
@@ -74,6 +75,10 @@ export default function TableManagementPage() {
const [addColumnModalOpen, setAddColumnModalOpen] = useState(false);
const [ddlLogViewerOpen, setDdlLogViewerOpen] = useState(false);
// 로그 뷰어 상태
const [logViewerOpen, setLogViewerOpen] = useState(false);
const [logViewerTableName, setLogViewerTableName] = useState<string>("");
// 최고 관리자 여부 확인 (회사코드가 "*"인 경우)
const isSuperAdmin = user?.companyCode === "*";
@@ -539,7 +544,7 @@ export default function TableManagementPage() {
}, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]);
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
@@ -548,11 +553,14 @@ export default function TableManagementPage() {
<h1 className="text-3xl font-bold tracking-tight">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_TITLE, "테이블 타입 관리")}
</h1>
<p className="mt-2 text-sm text-muted-foreground">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_DESCRIPTION, "데이터베이스 테이블과 컬럼의 타입을 관리합니다")}
<p className="text-muted-foreground mt-2 text-sm">
{getTextFromUI(
TABLE_MANAGEMENT_KEYS.PAGE_DESCRIPTION,
"데이터베이스 테이블과 컬럼의 타입을 관리합니다",
)}
</p>
{isSuperAdmin && (
<p className="mt-1 text-sm font-medium text-primary">
<p className="text-primary mt-1 text-sm font-medium">
</p>
)}
@@ -571,20 +579,33 @@ export default function TableManagementPage() {
</Button>
{selectedTable && (
<Button onClick={() => setAddColumnModalOpen(true)} variant="outline" className="h-10 gap-2 text-sm font-medium">
<Button
onClick={() => setAddColumnModalOpen(true)}
variant="outline"
className="h-10 gap-2 text-sm font-medium"
>
<Plus className="h-4 w-4" />
</Button>
)}
<Button onClick={() => setDdlLogViewerOpen(true)} variant="outline" className="h-10 gap-2 text-sm font-medium">
<Button
onClick={() => setDdlLogViewerOpen(true)}
variant="outline"
className="h-10 gap-2 text-sm font-medium"
>
<Activity className="h-4 w-4" />
DDL
</Button>
</>
)}
<Button onClick={loadTables} disabled={loading} variant="outline" className="h-10 gap-2 text-sm font-medium">
<Button
onClick={loadTables}
disabled={loading}
variant="outline"
className="h-10 gap-2 text-sm font-medium"
>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
{getTextFromUI(TABLE_MANAGEMENT_KEYS.BUTTON_REFRESH, "새로고침")}
</Button>
@@ -597,13 +618,13 @@ export default function TableManagementPage() {
<div className="w-[20%] border-r pr-6">
<div className="space-y-4">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Database className="h-5 w-5 text-muted-foreground" />
<Database className="text-muted-foreground h-5 w-5" />
{getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_NAME, "테이블 목록")}
</h2>
{/* 검색 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder={getTextFromUI(TABLE_MANAGEMENT_KEYS.SEARCH_PLACEHOLDER, "테이블 검색...")}
value={searchTerm}
@@ -617,12 +638,12 @@ export default function TableManagementPage() {
{loading ? (
<div className="flex items-center justify-center py-8">
<LoadingSpinner />
<span className="ml-2 text-sm text-muted-foreground">
<span className="text-muted-foreground ml-2 text-sm">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_TABLES, "테이블 로딩 중...")}
</span>
</div>
) : tables.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
<div className="text-muted-foreground py-8 text-center text-sm">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_TABLES, "테이블이 없습니다")}
</div>
) : (
@@ -635,19 +656,17 @@ export default function TableManagementPage() {
.map((table) => (
<div
key={table.tableName}
className={`cursor-pointer rounded-lg border bg-card p-4 shadow-sm transition-all ${
selectedTable === table.tableName
? "shadow-md"
: "hover:shadow-md"
className={`bg-card cursor-pointer rounded-lg border p-4 shadow-sm transition-all ${
selectedTable === table.tableName ? "shadow-md" : "hover:shadow-md"
}`}
onClick={() => handleTableSelect(table.tableName)}
>
<h4 className="text-sm font-semibold">{table.displayName || table.tableName}</h4>
<p className="mt-1 text-xs text-muted-foreground">
<p className="text-muted-foreground mt-1 text-xs">
{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")}
</p>
<div className="mt-2 flex items-center justify-between border-t pt-2">
<span className="text-xs text-muted-foreground"></span>
<span className="text-muted-foreground text-xs"></span>
<Badge variant="secondary" className="text-xs">
{table.columnCount}
</Badge>
@@ -663,267 +682,278 @@ export default function TableManagementPage() {
<div className="w-[80%] pl-0">
<div className="flex h-full flex-col space-y-4">
<h2 className="flex items-center gap-2 text-xl font-semibold">
<Settings className="h-5 w-5 text-muted-foreground" />
<Settings className="text-muted-foreground h-5 w-5" />
{selectedTable ? <> - {selectedTable}</> : "테이블 타입 관리"}
</h2>
<div className="flex-1 overflow-hidden">
{!selectedTable ? (
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-sm text-muted-foreground">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")}
</p>
</div>
</div>
) : (
<>
{/* 테이블 라벨 설정 */}
<div className="mb-4 flex items-center gap-4">
<div className="flex-1">
<Input
value={tableLabel}
onChange={(e) => setTableLabel(e.target.value)}
placeholder="테이블 표시명"
className="h-10 text-sm"
/>
</div>
<div className="flex-1">
<Input
value={tableDescription}
onChange={(e) => setTableDescription(e.target.value)}
placeholder="테이블 설명"
className="h-10 text-sm"
/>
{!selectedTable ? (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-muted-foreground text-sm">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")}
</p>
</div>
</div>
{columnsLoading ? (
<div className="flex items-center justify-center py-8">
<LoadingSpinner />
<span className="ml-2 text-sm text-muted-foreground">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")}
</span>
</div>
) : columns.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
</div>
) : (
<div className="space-y-4">
{/* 컬럼 헤더 */}
<div className="flex items-center border-b pb-2 text-sm font-semibold text-foreground">
<div className="w-40 px-4"></div>
<div className="w-48 px-4"></div>
<div className="w-48 px-4"> </div>
<div className="flex-1 px-4" style={{ maxWidth: "calc(100% - 808px)" }}>
</div>
<div className="w-80 px-4"></div>
) : (
<>
{/* 테이블 라벨 설정 */}
<div className="mb-4 flex items-center gap-4">
<div className="flex-1">
<Input
value={tableLabel}
onChange={(e) => setTableLabel(e.target.value)}
placeholder="테이블 표시명"
className="h-10 text-sm"
/>
</div>
<div className="flex-1">
<Input
value={tableDescription}
onChange={(e) => setTableDescription(e.target.value)}
placeholder="테이블 설명"
className="h-10 text-sm"
/>
</div>
</div>
{/* 컬럼 리스트 */}
<div
className="max-h-96 overflow-y-auto rounded-lg border"
onScroll={(e) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
// 스크롤이 끝에 가까워지면 더 많은 데이터 로드
if (scrollHeight - scrollTop <= clientHeight + 100) {
loadMoreColumns();
}
}}
>
{columns.map((column, index) => (
<div
key={column.columnName}
className="flex items-center border-b py-2 transition-colors hover:bg-muted/50"
>
<div className="w-40 px-4">
<div className="font-mono text-sm">{column.columnName}</div>
</div>
<div className="w-48 px-4">
<Input
value={column.displayName || ""}
onChange={(e) => handleLabelChange(column.columnName, e.target.value)}
placeholder={column.columnName}
className="h-8 text-xs"
/>
</div>
<div className="w-48 px-4">
<Select
value={column.inputType || "text"}
onValueChange={(value) => handleInputTypeChange(column.columnName, value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="입력 타입 선택" />
</SelectTrigger>
<SelectContent>
{memoizedInputTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1 px-4" style={{ maxWidth: "calc(100% - 808px)" }}>
{/* 웹 타입이 'code'인 경우 공통코드 선택 */}
{column.inputType === "code" && (
{columnsLoading ? (
<div className="flex items-center justify-center py-8">
<LoadingSpinner />
<span className="text-muted-foreground ml-2 text-sm">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")}
</span>
</div>
) : columns.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
</div>
) : (
<div className="space-y-4">
{/* 컬럼 헤더 */}
<div className="text-foreground flex items-center border-b pb-2 text-sm font-semibold">
<div className="w-40 px-4"></div>
<div className="w-48 px-4"></div>
<div className="w-48 px-4"> </div>
<div className="flex-1 px-4" style={{ maxWidth: "calc(100% - 808px)" }}>
</div>
<div className="w-80 px-4"></div>
</div>
{/* 컬럼 리스트 */}
<div
className="max-h-96 overflow-y-auto rounded-lg border"
onScroll={(e) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
// 스크롤이 끝에 가까워지면 더 많은 데이터 로드
if (scrollHeight - scrollTop <= clientHeight + 100) {
loadMoreColumns();
}
}}
>
{columns.map((column, index) => (
<div
key={column.columnName}
className="hover:bg-muted/50 flex items-center border-b py-2 transition-colors"
>
<div className="w-40 px-4">
<div className="font-mono text-sm">{column.columnName}</div>
</div>
<div className="w-48 px-4">
<Input
value={column.displayName || ""}
onChange={(e) => handleLabelChange(column.columnName, e.target.value)}
placeholder={column.columnName}
className="h-8 text-xs"
/>
</div>
<div className="w-48 px-4">
<Select
value={column.codeCategory || "none"}
onValueChange={(value) => handleDetailSettingsChange(column.columnName, "code", value)}
value={column.inputType || "text"}
onValueChange={(value) => handleInputTypeChange(column.columnName, value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="공통코드 선택" />
<SelectValue placeholder="입력 타입 선택" />
</SelectTrigger>
<SelectContent>
{commonCodeOptions.map((option, index) => (
<SelectItem key={`code-${option.value}-${index}`} value={option.value}>
{memoizedInputTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* 웹 타입이 'entity'인 경우 참조 테이블 선택 */}
{column.inputType === "entity" && (
<div className="space-y-1">
{/* Entity 타입 설정 - 가로 배치 */}
<div className="rounded-lg border border-primary/20 bg-primary/5 p-2">
<div className="mb-2 flex items-center gap-2">
<span className="text-xs font-medium text-primary">Entity </span>
</div>
<div className="grid grid-cols-3 gap-2">
{/* 참조 테이블 */}
<div>
<label className="mb-1 block text-xs text-muted-foreground"> </label>
<Select
value={column.referenceTable || "none"}
onValueChange={(value) =>
handleDetailSettingsChange(column.columnName, "entity", value)
}
>
<SelectTrigger className="h-8 bg-background text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{referenceTableOptions.map((option, index) => (
<SelectItem key={`entity-${option.value}-${index}`} value={option.value}>
<div className="flex flex-col">
<span className="font-medium">{option.label}</span>
<span className="text-xs text-muted-foreground">{option.value}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1 px-4" style={{ maxWidth: "calc(100% - 808px)" }}>
{/* 웹 타입이 'code'인 경우 공통코드 선택 */}
{column.inputType === "code" && (
<Select
value={column.codeCategory || "none"}
onValueChange={(value) =>
handleDetailSettingsChange(column.columnName, "code", value)
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="공통코드 선택" />
</SelectTrigger>
<SelectContent>
{commonCodeOptions.map((option, index) => (
<SelectItem key={`code-${option.value}-${index}`} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* 웹 타입이 'entity'인 경우 참조 테이블 선택 */}
{column.inputType === "entity" && (
<div className="space-y-1">
{/* Entity 타입 설정 - 가로 배치 */}
<div className="border-primary/20 bg-primary/5 rounded-lg border p-2">
<div className="mb-2 flex items-center gap-2">
<span className="text-primary text-xs font-medium">Entity </span>
</div>
{/* 조인 컬럼 */}
{column.referenceTable && column.referenceTable !== "none" && (
<div className="grid grid-cols-3 gap-2">
{/* 참조 테이블 */}
<div>
<label className="mb-1 block text-xs text-muted-foreground"> </label>
<label className="text-muted-foreground mb-1 block text-xs">
</label>
<Select
value={column.referenceColumn || "none"}
value={column.referenceTable || "none"}
onValueChange={(value) =>
handleDetailSettingsChange(
column.columnName,
"entity_reference_column",
value,
)
handleDetailSettingsChange(column.columnName, "entity", value)
}
>
<SelectTrigger className="h-8 bg-background text-xs">
<SelectTrigger className="bg-background h-8 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">-- --</SelectItem>
{referenceTableColumns[column.referenceTable]?.map((refCol, index) => (
{referenceTableOptions.map((option, index) => (
<SelectItem
key={`ref-col-${refCol.columnName}-${index}`}
value={refCol.columnName}
key={`entity-${option.value}-${index}`}
value={option.value}
>
<span className="font-medium">{refCol.columnName}</span>
</SelectItem>
))}
{(!referenceTableColumns[column.referenceTable] ||
referenceTableColumns[column.referenceTable].length === 0) && (
<SelectItem value="loading" disabled>
<div className="flex items-center gap-2">
<div className="h-3 w-3 animate-spin rounded-full border border-primary border-t-transparent"></div>
<div className="flex flex-col">
<span className="font-medium">{option.label}</span>
<span className="text-muted-foreground text-xs">
{option.value}
</span>
</div>
</SelectItem>
)}
))}
</SelectContent>
</Select>
</div>
)}
{/* 조인 컬럼 */}
{column.referenceTable && column.referenceTable !== "none" && (
<div>
<label className="text-muted-foreground mb-1 block text-xs">
</label>
<Select
value={column.referenceColumn || "none"}
onValueChange={(value) =>
handleDetailSettingsChange(
column.columnName,
"entity_reference_column",
value,
)
}
>
<SelectTrigger className="bg-background h-8 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">-- --</SelectItem>
{referenceTableColumns[column.referenceTable]?.map((refCol, index) => (
<SelectItem
key={`ref-col-${refCol.columnName}-${index}`}
value={refCol.columnName}
>
<span className="font-medium">{refCol.columnName}</span>
</SelectItem>
))}
{(!referenceTableColumns[column.referenceTable] ||
referenceTableColumns[column.referenceTable].length === 0) && (
<SelectItem value="loading" disabled>
<div className="flex items-center gap-2">
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
</div>
</SelectItem>
)}
</SelectContent>
</Select>
</div>
)}
</div>
{/* 설정 완료 표시 - 간소화 */}
{column.referenceTable &&
column.referenceTable !== "none" &&
column.referenceColumn &&
column.referenceColumn !== "none" &&
column.displayColumn &&
column.displayColumn !== "none" && (
<div className="bg-primary/10 text-primary mt-1 flex items-center gap-1 rounded px-2 py-1 text-xs">
<span></span>
<span className="truncate">
{column.columnName} {column.referenceTable}.{column.displayColumn}
</span>
</div>
)}
</div>
{/* 설정 완료 표시 - 간소화 */}
{column.referenceTable &&
column.referenceTable !== "none" &&
column.referenceColumn &&
column.referenceColumn !== "none" &&
column.displayColumn &&
column.displayColumn !== "none" && (
<div className="mt-1 flex items-center gap-1 rounded bg-primary/10 px-2 py-1 text-xs text-primary">
<span></span>
<span className="truncate">
{column.columnName} {column.referenceTable}.{column.displayColumn}
</span>
</div>
)}
</div>
</div>
)}
{/* 다른 웹 타입인 경우 빈 공간 */}
{column.inputType !== "code" && column.inputType !== "entity" && (
<div className="flex h-8 items-center text-xs text-muted-foreground">-</div>
)}
)}
{/* 다른 웹 타입인 경우 빈 공간 */}
{column.inputType !== "code" && column.inputType !== "entity" && (
<div className="text-muted-foreground flex h-8 items-center text-xs">-</div>
)}
</div>
<div className="w-80 px-4">
<Input
value={column.description || ""}
onChange={(e) => handleColumnChange(index, "description", e.target.value)}
placeholder="설명"
className="h-8 text-xs"
/>
</div>
</div>
<div className="w-80 px-4">
<Input
value={column.description || ""}
onChange={(e) => handleColumnChange(index, "description", e.target.value)}
placeholder="설명"
className="h-8 text-xs"
/>
</div>
</div>
))}
</div>
{/* 로딩 표시 */}
{columnsLoading && (
<div className="flex items-center justify-center py-4">
<LoadingSpinner />
<span className="ml-2 text-sm text-muted-foreground"> ...</span>
))}
</div>
)}
{/* 페이지 정보 */}
<div className="text-center text-sm text-muted-foreground">
{columns.length} / {totalColumns}
</div>
{/* 로딩 표시 */}
{columnsLoading && (
<div className="flex items-center justify-center py-4">
<LoadingSpinner />
<span className="text-muted-foreground ml-2 text-sm"> ...</span>
</div>
)}
{/* 전체 저장 버튼 */}
<div className="flex justify-end pt-4">
<Button
onClick={saveAllSettings}
disabled={!selectedTable || columns.length === 0}
className="h-10 gap-2 text-sm font-medium"
>
<Settings className="h-4 w-4" />
</Button>
{/* 페이지 정보 */}
<div className="text-muted-foreground text-center text-sm">
{columns.length} / {totalColumns}
</div>
{/* 전체 저장 버튼 */}
<div className="flex justify-end pt-4">
<Button
onClick={saveAllSettings}
disabled={!selectedTable || columns.length === 0}
className="h-10 gap-2 text-sm font-medium"
>
<Settings className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
</>
)}
)}
</>
)}
</div>
</div>
</div>
@@ -967,6 +997,9 @@ export default function TableManagementPage() {
/>
<DDLLogViewer isOpen={ddlLogViewerOpen} onClose={() => setDdlLogViewerOpen(false)} />
{/* 테이블 로그 뷰어 */}
<TableLogViewer tableName={logViewerTableName} open={logViewerOpen} onOpenChange={setLogViewerOpen} />
</>
)}