jskim-node #419
|
|
@ -12,7 +12,7 @@ import {
|
||||||
Search,
|
Search,
|
||||||
Database,
|
Database,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Settings,
|
Save,
|
||||||
Plus,
|
Plus,
|
||||||
Activity,
|
Activity,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
|
@ -21,7 +21,6 @@ import {
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
Loader2,
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { ResponsiveSplitPanel } from "@/components/common/ResponsiveSplitPanel";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
@ -969,16 +968,24 @@ export default function TableManagementPage() {
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [selectedTable, columns.length]);
|
}, [selectedTable, columns.length]);
|
||||||
|
|
||||||
// 필터링된 테이블 목록 (메모이제이션)
|
// 필터링 + 한글 우선 정렬 (ㄱ~ㅎ → a~z)
|
||||||
const filteredTables = useMemo(
|
const filteredTables = useMemo(() => {
|
||||||
() =>
|
const filtered = tables.filter(
|
||||||
tables.filter(
|
(table) =>
|
||||||
(table) =>
|
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
table.displayName.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
table.displayName.toLowerCase().includes(searchTerm.toLowerCase()),
|
);
|
||||||
),
|
const isKorean = (str: string) => /^[가-힣ㄱ-ㅎ]/.test(str);
|
||||||
[tables, searchTerm],
|
return filtered.sort((a, b) => {
|
||||||
);
|
const nameA = a.displayName || a.tableName;
|
||||||
|
const nameB = b.displayName || b.tableName;
|
||||||
|
const aKo = isKorean(nameA);
|
||||||
|
const bKo = isKorean(nameB);
|
||||||
|
if (aKo && !bKo) return -1;
|
||||||
|
if (!aKo && bKo) return 1;
|
||||||
|
return nameA.localeCompare(nameB, aKo ? "ko" : "en");
|
||||||
|
});
|
||||||
|
}, [tables, searchTerm]);
|
||||||
|
|
||||||
// 선택된 테이블 정보
|
// 선택된 테이블 정보
|
||||||
const selectedTableInfo = tables.find((table) => table.tableName === selectedTable);
|
const selectedTableInfo = tables.find((table) => table.tableName === selectedTable);
|
||||||
|
|
@ -1292,339 +1299,338 @@ export default function TableManagementPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background flex h-screen flex-col">
|
<div className="bg-background flex h-screen flex-col overflow-hidden">
|
||||||
<div className="flex h-full flex-col space-y-6 overflow-hidden p-6">
|
{/* 컴팩트 탑바 (52px) */}
|
||||||
{/* 페이지 헤더 */}
|
<div className="flex h-[52px] flex-shrink-0 items-center justify-between border-b px-5">
|
||||||
<div className="flex-shrink-0 space-y-2 border-b pb-4">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
|
<Database className="h-4.5 w-4.5 text-muted-foreground" />
|
||||||
<div>
|
<h1 className="text-[15px] font-bold tracking-tight">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_TITLE, "테이블 타입 관리")}
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_TITLE, "테이블 타입 관리")}
|
</h1>
|
||||||
</h1>
|
<Badge variant="secondary" className="text-[10px] font-bold">
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
{tables.length} 테이블
|
||||||
{getTextFromUI(
|
</Badge>
|
||||||
TABLE_MANAGEMENT_KEYS.PAGE_DESCRIPTION,
|
</div>
|
||||||
"데이터베이스 테이블과 컬럼의 타입을 관리합니다",
|
<div className="flex items-center gap-1.5">
|
||||||
)}
|
{isSuperAdmin && (
|
||||||
</p>
|
<>
|
||||||
{isSuperAdmin && (
|
|
||||||
<p className="text-primary mt-1 text-sm font-medium">
|
|
||||||
최고 관리자 권한으로 새 테이블 생성 및 컬럼 추가가 가능합니다
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* DDL 기능 버튼들 (최고 관리자만) */}
|
|
||||||
{isSuperAdmin && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setDuplicateModalMode("create");
|
|
||||||
setDuplicateSourceTable(null);
|
|
||||||
setCreateTableModalOpen(true);
|
|
||||||
}}
|
|
||||||
className="h-10 gap-2 text-sm font-medium"
|
|
||||||
size="default"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />새 테이블 생성
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
if (selectedTableIds.size !== 1) {
|
|
||||||
toast.error("복제할 테이블을 1개만 선택해주세요.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sourceTable = Array.from(selectedTableIds)[0];
|
|
||||||
setDuplicateSourceTable(sourceTable);
|
|
||||||
setDuplicateModalMode("duplicate");
|
|
||||||
setCreateTableModalOpen(true);
|
|
||||||
}}
|
|
||||||
variant="outline"
|
|
||||||
disabled={selectedTableIds.size !== 1}
|
|
||||||
className="h-10 gap-2 text-sm font-medium"
|
|
||||||
>
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
테이블 복제
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{selectedTable && (
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<Activity className="h-4 w-4" />
|
|
||||||
DDL 로그
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={loadTables}
|
onClick={() => {
|
||||||
disabled={loading}
|
setDuplicateModalMode("create");
|
||||||
variant="outline"
|
setDuplicateSourceTable(null);
|
||||||
className="h-10 gap-2 text-sm font-medium"
|
setCreateTableModalOpen(true);
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 text-xs"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.BUTTON_REFRESH, "새로고침")}
|
새 테이블
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedTableIds.size !== 1) {
|
||||||
|
toast.error("복제할 테이블을 1개만 선택해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sourceTable = Array.from(selectedTableIds)[0];
|
||||||
|
setDuplicateSourceTable(sourceTable);
|
||||||
|
setDuplicateModalMode("duplicate");
|
||||||
|
setCreateTableModalOpen(true);
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={selectedTableIds.size !== 1}
|
||||||
|
className="h-8 gap-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
복제
|
||||||
|
</Button>
|
||||||
|
{selectedTable && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setAddColumnModalOpen(true)}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
컬럼 추가
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={() => setDdlLogViewerOpen(true)}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<Activity className="h-3.5 w-3.5" />
|
||||||
|
DDL
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={loadTables}
|
||||||
|
disabled={loading}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3패널 메인 */}
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* 좌측: 테이블 목록 (240px) */}
|
||||||
|
<div className="bg-card flex w-[240px] min-w-[240px] flex-shrink-0 flex-col border-r">
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="flex-shrink-0 p-3 pb-0">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="text-muted-foreground absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder={getTextFromUI(TABLE_MANAGEMENT_KEYS.SEARCH_PLACEHOLDER, "테이블 검색...")}
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="bg-background h-[34px] pl-8 text-xs"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<div className="mt-2 flex items-center justify-between border-b pb-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
filteredTables.length > 0 &&
|
||||||
|
filteredTables.every((table) => selectedTableIds.has(table.tableName))
|
||||||
|
}
|
||||||
|
onCheckedChange={handleSelectAll}
|
||||||
|
aria-label="전체 선택"
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-[10px]">
|
||||||
|
{selectedTableIds.size > 0 ? `${selectedTableIds.size}개` : "전체"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{selectedTableIds.size > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleBulkDeleteClick}
|
||||||
|
className="h-6 gap-1 px-2 text-[10px]"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 리스트 */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-1">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
) : filteredTables.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground py-8 text-center text-xs">
|
||||||
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_TABLES, "테이블이 없습니다")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredTables.map((table, idx) => {
|
||||||
|
const isActive = selectedTable === table.tableName;
|
||||||
|
const prevTable = idx > 0 ? filteredTables[idx - 1] : null;
|
||||||
|
const isKo = /^[가-힣ㄱ-ㅎ]/.test(table.displayName || table.tableName);
|
||||||
|
const prevIsKo = prevTable ? /^[가-힣ㄱ-ㅎ]/.test(prevTable.displayName || prevTable.tableName) : null;
|
||||||
|
const showDivider = idx === 0 || (prevIsKo !== null && isKo !== prevIsKo);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={table.tableName}>
|
||||||
|
{showDivider && (
|
||||||
|
<div className="text-muted-foreground/60 mt-2 mb-1 px-2 text-[9px] font-bold uppercase tracking-widest">
|
||||||
|
{isKo ? "한글" : "ENGLISH"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group relative flex items-center gap-2 rounded-md px-2.5 py-[7px] transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-accent text-foreground"
|
||||||
|
: "text-foreground/80 hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
onClick={() => handleTableSelect(table.tableName)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleTableSelect(table.tableName);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<div className="bg-primary absolute top-1.5 bottom-1.5 left-0 w-[3px] rounded-r" />
|
||||||
|
)}
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedTableIds.has(table.tableName)}
|
||||||
|
onCheckedChange={(checked) => handleTableCheck(table.tableName, checked as boolean)}
|
||||||
|
aria-label={`${table.displayName || table.tableName} 선택`}
|
||||||
|
className="h-3.5 w-3.5 flex-shrink-0"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className={cn(
|
||||||
|
"truncate text-[12px] leading-tight",
|
||||||
|
isActive ? "font-bold" : "font-medium",
|
||||||
|
)}>
|
||||||
|
{table.displayName || table.tableName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground truncate font-mono text-[10px] leading-tight tracking-tight">
|
||||||
|
{table.tableName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={cn(
|
||||||
|
"flex-shrink-0 rounded-full px-1.5 py-0.5 font-mono text-[10px] font-bold leading-none",
|
||||||
|
isActive
|
||||||
|
? "bg-primary/15 text-primary"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}>
|
||||||
|
{table.columnCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 정보 */}
|
||||||
|
<div className="text-muted-foreground flex-shrink-0 border-t px-3 py-2 text-[10px] font-medium">
|
||||||
|
{filteredTables.length} / {tables.length} 테이블
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResponsiveSplitPanel
|
{/* 중앙: 컬럼 그리드 */}
|
||||||
left={
|
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
<div className="flex h-full flex-col space-y-4">
|
{!selectedTable ? (
|
||||||
{/* 검색 */}
|
<div className="flex flex-1 flex-col items-center justify-center gap-2">
|
||||||
<div className="flex-shrink-0">
|
<Database className="text-muted-foreground/40 h-10 w-10" />
|
||||||
<div className="relative">
|
<p className="text-muted-foreground text-sm">
|
||||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 중앙 헤더: 테이블명 + 라벨 입력 + 저장 */}
|
||||||
|
<div className="bg-card flex flex-shrink-0 items-center gap-3 border-b px-5 py-3">
|
||||||
|
<div className="min-w-0 flex-shrink-0">
|
||||||
|
<div className="text-[15px] font-bold tracking-tight">
|
||||||
|
{tableLabel || selectedTable}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground font-mono text-[11px] tracking-tight">
|
||||||
|
{selectedTable}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder={getTextFromUI(TABLE_MANAGEMENT_KEYS.SEARCH_PLACEHOLDER, "테이블 검색...")}
|
value={tableLabel}
|
||||||
value={searchTerm}
|
onChange={(e) => setTableLabel(e.target.value)}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
placeholder="표시명"
|
||||||
className="h-10 pl-10 text-sm"
|
className="h-8 max-w-[160px] text-xs"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={tableDescription}
|
||||||
|
onChange={(e) => setTableDescription(e.target.value)}
|
||||||
|
placeholder="설명"
|
||||||
|
className="h-8 max-w-[200px] text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={saveAllSettings}
|
||||||
|
disabled={!selectedTable || columns.length === 0 || isSaving}
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 text-xs"
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{isSaving ? "저장 중..." : "전체 설정 저장"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 테이블 목록 */}
|
{columnsLoading ? (
|
||||||
<div className="flex-1 space-y-3 overflow-y-auto">
|
<div className="flex flex-1 items-center justify-center">
|
||||||
{/* 전체 선택 및 일괄 삭제 (최고 관리자만) */}
|
<LoadingSpinner />
|
||||||
{isSuperAdmin && (
|
<span className="text-muted-foreground ml-2 text-sm">
|
||||||
<div className="flex items-center justify-between border-b pb-3">
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")}
|
||||||
<div className="flex items-center gap-2">
|
</span>
|
||||||
<Checkbox
|
</div>
|
||||||
checked={
|
) : columns.length === 0 ? (
|
||||||
tables.filter(
|
<div className="text-muted-foreground flex flex-1 items-center justify-center text-sm">
|
||||||
(table) =>
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
|
||||||
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
(table.displayName && table.displayName.toLowerCase().includes(searchTerm.toLowerCase())),
|
|
||||||
).length > 0 &&
|
|
||||||
tables
|
|
||||||
.filter(
|
|
||||||
(table) =>
|
|
||||||
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
(table.displayName &&
|
|
||||||
table.displayName.toLowerCase().includes(searchTerm.toLowerCase())),
|
|
||||||
)
|
|
||||||
.every((table) => selectedTableIds.has(table.tableName))
|
|
||||||
}
|
|
||||||
onCheckedChange={handleSelectAll}
|
|
||||||
aria-label="전체 선택"
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground text-sm">
|
|
||||||
{selectedTableIds.size > 0 && `${selectedTableIds.size}개 선택됨`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{selectedTableIds.size > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleBulkDeleteClick}
|
|
||||||
className="h-8 gap-2 text-xs"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
삭제
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<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_TABLES, "테이블 로딩 중...")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : tables.length === 0 ? (
|
|
||||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_TABLES, "테이블이 없습니다")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
tables
|
|
||||||
.filter(
|
|
||||||
(table) =>
|
|
||||||
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
(table.displayName && table.displayName.toLowerCase().includes(searchTerm.toLowerCase())),
|
|
||||||
)
|
|
||||||
.map((table) => (
|
|
||||||
<div
|
|
||||||
key={table.tableName}
|
|
||||||
className={`bg-card rounded-lg p-4 shadow-sm transition-all ${
|
|
||||||
selectedTable === table.tableName
|
|
||||||
? "bg-muted/30 shadow-md"
|
|
||||||
: "hover:bg-muted/20 hover:shadow-lg"
|
|
||||||
}`}
|
|
||||||
style={
|
|
||||||
selectedTable === table.tableName
|
|
||||||
? { border: "2px solid #000000" }
|
|
||||||
: { border: "2px solid transparent" }
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
{/* 체크박스 (최고 관리자만) */}
|
|
||||||
{isSuperAdmin && (
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedTableIds.has(table.tableName)}
|
|
||||||
onCheckedChange={(checked) => handleTableCheck(table.tableName, checked as boolean)}
|
|
||||||
aria-label={`${table.displayName || table.tableName} 선택`}
|
|
||||||
className="mt-0.5"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 cursor-pointer" onClick={() => handleTableSelect(table.tableName)}>
|
|
||||||
<h4 className="text-sm font-semibold">{table.displayName || table.tableName}</h4>
|
|
||||||
<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-muted-foreground text-xs">컬럼</span>
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{table.columnCount}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
right={
|
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
|
||||||
{!selectedTable ? (
|
|
||||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border">
|
|
||||||
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* 테이블 라벨 설정 + 저장 버튼 (고정 영역) */}
|
<TypeOverviewStrip
|
||||||
<div className="mb-4 flex items-center gap-4">
|
columns={columns}
|
||||||
<div className="flex-1">
|
activeFilter={typeFilter}
|
||||||
<Input
|
onFilterChange={setTypeFilter}
|
||||||
value={tableLabel}
|
/>
|
||||||
onChange={(e) => setTableLabel(e.target.value)}
|
<ColumnGrid
|
||||||
placeholder="테이블 표시명"
|
columns={columns}
|
||||||
className="h-10 text-sm"
|
selectedColumn={selectedColumn}
|
||||||
/>
|
onSelectColumn={setSelectedColumn}
|
||||||
</div>
|
onColumnChange={(columnName, field, value) => {
|
||||||
<div className="flex-1">
|
const idx = columns.findIndex((c) => c.columnName === columnName);
|
||||||
<Input
|
if (idx >= 0) handleColumnChange(idx, field, value);
|
||||||
value={tableDescription}
|
}}
|
||||||
onChange={(e) => setTableDescription(e.target.value)}
|
constraints={constraints}
|
||||||
placeholder="테이블 설명"
|
typeFilter={typeFilter}
|
||||||
className="h-10 text-sm"
|
getColumnIndexState={getColumnIndexState}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
{/* 저장 버튼 (항상 보이도록 상단에 배치) */}
|
|
||||||
<Button
|
|
||||||
onClick={saveAllSettings}
|
|
||||||
disabled={!selectedTable || columns.length === 0 || isSaving}
|
|
||||||
className="h-10 gap-2 text-sm font-medium"
|
|
||||||
>
|
|
||||||
{isSaving ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{isSaving ? "저장 중..." : "전체 설정 저장"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{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="flex flex-1 overflow-hidden">
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
|
||||||
<TypeOverviewStrip
|
|
||||||
columns={columns}
|
|
||||||
activeFilter={typeFilter}
|
|
||||||
onFilterChange={setTypeFilter}
|
|
||||||
/>
|
|
||||||
<ColumnGrid
|
|
||||||
columns={columns}
|
|
||||||
selectedColumn={selectedColumn}
|
|
||||||
onSelectColumn={setSelectedColumn}
|
|
||||||
onColumnChange={(columnName, field, value) => {
|
|
||||||
const idx = columns.findIndex((c) => c.columnName === columnName);
|
|
||||||
if (idx >= 0) handleColumnChange(idx, field, value);
|
|
||||||
}}
|
|
||||||
constraints={constraints}
|
|
||||||
typeFilter={typeFilter}
|
|
||||||
getColumnIndexState={getColumnIndexState}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{selectedColumn && (
|
|
||||||
<div className="w-[360px] flex-shrink-0 overflow-hidden">
|
|
||||||
<ColumnDetailPanel
|
|
||||||
column={columns.find((c) => c.columnName === selectedColumn) ?? null}
|
|
||||||
tables={tables}
|
|
||||||
referenceTableColumns={referenceTableColumns}
|
|
||||||
secondLevelMenus={secondLevelMenus}
|
|
||||||
numberingRules={numberingRules}
|
|
||||||
onColumnChange={(field, value) => {
|
|
||||||
if (!selectedColumn) return;
|
|
||||||
if (field === "inputType") {
|
|
||||||
handleInputTypeChange(selectedColumn, value as string);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (field === "referenceTable" && value) {
|
|
||||||
loadReferenceTableColumns(value as string);
|
|
||||||
}
|
|
||||||
setColumns((prev) =>
|
|
||||||
prev.map((c) =>
|
|
||||||
c.columnName === selectedColumn ? { ...c, [field]: value } : c,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
onClose={() => setSelectedColumn(null)}
|
|
||||||
onLoadReferenceColumns={loadReferenceTableColumns}
|
|
||||||
codeCategoryOptions={commonCodeOptions}
|
|
||||||
referenceTableOptions={referenceTableOptions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
}
|
)}
|
||||||
leftTitle="테이블 목록"
|
</div>
|
||||||
leftWidth={20}
|
|
||||||
minLeftWidth={10}
|
{/* 우측: 상세 패널 (selectedColumn 있을 때만) */}
|
||||||
maxLeftWidth={35}
|
{selectedColumn && (
|
||||||
height="100%"
|
<div className="w-[320px] min-w-[320px] flex-shrink-0 overflow-hidden">
|
||||||
className="flex-1 overflow-hidden"
|
<ColumnDetailPanel
|
||||||
/>
|
column={columns.find((c) => c.columnName === selectedColumn) ?? null}
|
||||||
|
tables={tables}
|
||||||
|
referenceTableColumns={referenceTableColumns}
|
||||||
|
secondLevelMenus={secondLevelMenus}
|
||||||
|
numberingRules={numberingRules}
|
||||||
|
onColumnChange={(field, value) => {
|
||||||
|
if (!selectedColumn) return;
|
||||||
|
if (field === "inputType") {
|
||||||
|
handleInputTypeChange(selectedColumn, value as string);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (field === "referenceTable" && value) {
|
||||||
|
loadReferenceTableColumns(value as string);
|
||||||
|
}
|
||||||
|
setColumns((prev) =>
|
||||||
|
prev.map((c) =>
|
||||||
|
c.columnName === selectedColumn ? { ...c, [field]: value } : c,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onClose={() => setSelectedColumn(null)}
|
||||||
|
onLoadReferenceColumns={loadReferenceTableColumns}
|
||||||
|
codeCategoryOptions={commonCodeOptions}
|
||||||
|
referenceTableOptions={referenceTableOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* DDL 모달 컴포넌트들 */}
|
{/* DDL 모달 컴포넌트들 */}
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
|
|
@ -1817,7 +1823,6 @@ export default function TableManagementPage() {
|
||||||
|
|
||||||
{/* Scroll to Top 버튼 */}
|
{/* Scroll to Top 버튼 */}
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -100,75 +100,152 @@ export function ColumnDetailPanel({
|
||||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||||
{/* [섹션 1] 데이터 타입 선택 */}
|
{/* [섹션 1] 데이터 타입 선택 */}
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div>
|
||||||
<Type className="h-4 w-4 text-muted-foreground" />
|
<p className="text-sm font-semibold">이 필드는 어떤 유형인가요?</p>
|
||||||
<Label className="text-sm font-medium">데이터 타입</Label>
|
<p className="text-xs text-muted-foreground">유형에 따라 입력 방식이 바뀌어요</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
{Object.entries(INPUT_TYPE_COLORS).map(([type, conf]) => (
|
{Object.entries(INPUT_TYPE_COLORS).map(([type, conf]) => {
|
||||||
<button
|
const isSelected = (column.inputType || "text") === type;
|
||||||
key={type}
|
return (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => onColumnChange("inputType", type)}
|
key={type}
|
||||||
className={cn(
|
type="button"
|
||||||
"rounded-lg border p-2 text-left text-xs transition-colors",
|
onClick={() => onColumnChange("inputType", type)}
|
||||||
conf.bgColor,
|
className={cn(
|
||||||
conf.color,
|
"flex flex-col items-center gap-1 rounded-lg border px-1.5 py-2.5 text-center transition-all",
|
||||||
(column.inputType || "text") === type
|
isSelected
|
||||||
? "ring-2 ring-ring"
|
? "border-primary bg-primary/5 ring-1 ring-primary/30"
|
||||||
: "border-border hover:bg-muted/50",
|
: "border-border hover:border-primary/30 hover:bg-accent/50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{conf.label}
|
<span className={cn(
|
||||||
</button>
|
"text-base font-bold leading-none",
|
||||||
))}
|
isSelected ? "text-primary" : conf.color,
|
||||||
|
)}>
|
||||||
|
{conf.iconChar}
|
||||||
|
</span>
|
||||||
|
<span className={cn(
|
||||||
|
"text-[11px] font-semibold leading-tight",
|
||||||
|
isSelected ? "text-primary" : "text-foreground",
|
||||||
|
)}>
|
||||||
|
{conf.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] leading-tight text-muted-foreground">
|
||||||
|
{conf.desc}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* [섹션 2] 타입별 상세 설정 */}
|
{/* [섹션 2] 타입별 상세 설정 */}
|
||||||
{column.inputType === "entity" && (
|
{column.inputType === "entity" && (
|
||||||
<section className="space-y-2">
|
<section className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
||||||
<Label className="text-sm font-medium">엔티티 참조</Label>
|
<Label className="text-sm font-medium">엔티티 참조</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="space-y-1.5">
|
{/* 참조 테이블 */}
|
||||||
<Label className="text-xs text-muted-foreground">참조 테이블</Label>
|
<div className="space-y-1">
|
||||||
<Popover open={entityTableOpen} onOpenChange={setEntityTableOpen}>
|
<Label className="text-[11px] font-medium text-muted-foreground">참조 테이블</Label>
|
||||||
|
<Popover open={entityTableOpen} onOpenChange={setEntityTableOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="h-9 w-full justify-between text-xs"
|
||||||
|
>
|
||||||
|
{column.referenceTable && column.referenceTable !== "none"
|
||||||
|
? refTableOpts.find((o) => o.value === column.referenceTable)?.label ?? column.referenceTable
|
||||||
|
: "테이블 선택..."}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[280px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||||
|
<CommandList className="max-h-[200px]">
|
||||||
|
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{refTableOpts.map((opt) => (
|
||||||
|
<CommandItem
|
||||||
|
key={opt.value}
|
||||||
|
value={`${opt.label} ${opt.value}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onColumnChange("referenceTable", opt.value === "none" ? undefined : opt.value);
|
||||||
|
if (opt.value !== "none") onLoadReferenceColumns?.(opt.value);
|
||||||
|
setEntityTableOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn("mr-2 h-3 w-3", column.referenceTable === opt.value ? "opacity-100" : "opacity-0")}
|
||||||
|
/>
|
||||||
|
{opt.label}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 조인 컬럼 */}
|
||||||
|
{column.referenceTable && column.referenceTable !== "none" && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[11px] font-medium text-muted-foreground">조인 컬럼(값)</Label>
|
||||||
|
<Popover open={entityColumnOpen} onOpenChange={setEntityColumnOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
|
disabled={refColumns.length === 0}
|
||||||
className="h-9 w-full justify-between text-xs"
|
className="h-9 w-full justify-between text-xs"
|
||||||
>
|
>
|
||||||
{column.referenceTable && column.referenceTable !== "none"
|
{column.referenceColumn && column.referenceColumn !== "none"
|
||||||
? refTableOpts.find((o) => o.value === column.referenceTable)?.label ?? column.referenceTable
|
? column.referenceColumn
|
||||||
: "테이블 선택..."}
|
: "컬럼 선택..."}
|
||||||
<ChevronsUpDown className="ml-2 h-3 w-3 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-3 w-3 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[280px] p-0" align="start">
|
<PopoverContent className="w-[280px] p-0" align="start">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||||
<CommandList className="max-h-[200px]">
|
<CommandList className="max-h-[200px]">
|
||||||
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{refTableOpts.map((opt) => (
|
<CommandItem
|
||||||
|
value="none"
|
||||||
|
onSelect={() => {
|
||||||
|
onColumnChange("referenceColumn", undefined);
|
||||||
|
setEntityColumnOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-3 w-3", !column.referenceColumn ? "opacity-100" : "opacity-0")} />
|
||||||
|
선택 안함
|
||||||
|
</CommandItem>
|
||||||
|
{refColumns.map((refCol) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={opt.value}
|
key={refCol.columnName}
|
||||||
value={`${opt.label} ${opt.value}`}
|
value={`${refCol.displayName ?? ""} ${refCol.columnName}`}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
onColumnChange("referenceTable", opt.value === "none" ? undefined : opt.value);
|
onColumnChange("referenceColumn", refCol.columnName);
|
||||||
if (opt.value !== "none") onLoadReferenceColumns?.(opt.value);
|
setEntityColumnOpen(false);
|
||||||
setEntityTableOpen(false);
|
|
||||||
}}
|
}}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
<Check
|
<Check
|
||||||
className={cn("mr-2 h-3 w-3", column.referenceTable === opt.value ? "opacity-100" : "opacity-0")}
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
column.referenceColumn === refCol.columnName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
{opt.label}
|
{refCol.columnName}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|
@ -177,67 +254,20 @@ export function ColumnDetailPanel({
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
{column.referenceTable && column.referenceTable !== "none" && (
|
)}
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs text-muted-foreground">조인 컬럼(값)</Label>
|
{/* 참조 요약 미니맵 */}
|
||||||
<Popover open={entityColumnOpen} onOpenChange={setEntityColumnOpen}>
|
{column.referenceTable && column.referenceTable !== "none" && column.referenceColumn && (
|
||||||
<PopoverTrigger asChild>
|
<div className="flex items-center gap-2 rounded-md bg-violet-50 px-3 py-2">
|
||||||
<Button
|
<span className="font-mono text-[11px] font-semibold text-violet-600">
|
||||||
variant="outline"
|
{column.referenceTable}
|
||||||
role="combobox"
|
</span>
|
||||||
disabled={refColumns.length === 0}
|
<span className="text-muted-foreground text-[10px]">→</span>
|
||||||
className="h-9 w-full justify-between text-xs"
|
<span className="font-mono text-[11px] font-semibold text-violet-600">
|
||||||
>
|
{column.referenceColumn}
|
||||||
{column.referenceColumn && column.referenceColumn !== "none"
|
</span>
|
||||||
? column.referenceColumn
|
</div>
|
||||||
: "컬럼 선택..."}
|
)}
|
||||||
<ChevronsUpDown className="ml-2 h-3 w-3 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-[280px] p-0" align="start">
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
|
||||||
<CommandList className="max-h-[200px]">
|
|
||||||
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
<CommandItem
|
|
||||||
value="none"
|
|
||||||
onSelect={() => {
|
|
||||||
onColumnChange("referenceColumn", undefined);
|
|
||||||
setEntityColumnOpen(false);
|
|
||||||
}}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
<Check className={cn("mr-2 h-3 w-3", !column.referenceColumn ? "opacity-100" : "opacity-0")} />
|
|
||||||
선택 안함
|
|
||||||
</CommandItem>
|
|
||||||
{refColumns.map((refCol) => (
|
|
||||||
<CommandItem
|
|
||||||
key={refCol.columnName}
|
|
||||||
value={`${refCol.displayName ?? ""} ${refCol.columnName}`}
|
|
||||||
onSelect={() => {
|
|
||||||
onColumnChange("referenceColumn", refCol.columnName);
|
|
||||||
setEntityColumnOpen(false);
|
|
||||||
}}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-3 w-3",
|
|
||||||
column.referenceColumn === refCol.columnName ? "opacity-100" : "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{refCol.columnName}
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ export function ColumnGrid({
|
||||||
columns,
|
columns,
|
||||||
selectedColumn,
|
selectedColumn,
|
||||||
onSelectColumn,
|
onSelectColumn,
|
||||||
|
onColumnChange,
|
||||||
constraints,
|
constraints,
|
||||||
typeFilter = null,
|
typeFilter = null,
|
||||||
getColumnIndexState: externalGetIndexState,
|
getColumnIndexState: externalGetIndexState,
|
||||||
|
|
@ -128,8 +129,8 @@ export function ColumnGrid({
|
||||||
isSelected && "border-primary/30 bg-primary/5 shadow-sm",
|
isSelected && "border-primary/30 bg-primary/5 shadow-sm",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* 4px 색상바 */}
|
{/* 4px 색상바 (타입별 진한 색) */}
|
||||||
<div className={cn("h-full min-h-8 w-1 rounded-full", typeConf.bgColor, typeConf.color)} />
|
<div className={cn("h-full min-h-8 w-1 rounded-full", typeConf.barColor)} />
|
||||||
|
|
||||||
{/* 라벨 + 컬럼명 */}
|
{/* 라벨 + 컬럼명 */}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|
@ -180,48 +181,72 @@ export function ColumnGrid({
|
||||||
{typeConf.label}
|
{typeConf.label}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PK / NN / IDX / UQ (읽기 전용) */}
|
{/* PK / NN / IDX / UQ (클릭 토글) */}
|
||||||
<div className="flex flex-wrap items-center justify-center gap-1">
|
<div className="flex flex-wrap items-center justify-center gap-1">
|
||||||
<span
|
<button
|
||||||
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded border px-1.5 py-0.5 text-[10px] font-medium",
|
"rounded border px-1.5 py-0.5 text-[10px] font-bold transition-colors",
|
||||||
idxState.isPk
|
idxState.isPk
|
||||||
? "border-primary/30 bg-primary/10 text-primary"
|
? "border-blue-200 bg-blue-50 text-blue-600"
|
||||||
: "border-border bg-muted/50 text-muted-foreground",
|
: "border-border text-muted-foreground/40 hover:border-blue-200 hover:text-blue-400",
|
||||||
)}
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onColumnChange(column.columnName, "isPrimaryKey" as keyof ColumnTypeInfo, !idxState.isPk);
|
||||||
|
}}
|
||||||
|
title="Primary Key 토글"
|
||||||
>
|
>
|
||||||
PK
|
PK
|
||||||
</span>
|
</button>
|
||||||
<span
|
<button
|
||||||
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded border px-1.5 py-0.5 text-[10px] font-medium",
|
"rounded border px-1.5 py-0.5 text-[10px] font-bold transition-colors",
|
||||||
column.isNullable === "NO"
|
column.isNullable === "NO"
|
||||||
? "border-amber-200 bg-amber-50 text-amber-600 dark:border-amber-900/50 dark:bg-amber-950/40 dark:text-amber-400"
|
? "border-amber-200 bg-amber-50 text-amber-600"
|
||||||
: "border-border bg-muted/50 text-muted-foreground",
|
: "border-border text-muted-foreground/40 hover:border-amber-200 hover:text-amber-400",
|
||||||
)}
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onColumnChange(column.columnName, "isNullable", column.isNullable === "NO" ? "YES" : "NO");
|
||||||
|
}}
|
||||||
|
title="Not Null 토글"
|
||||||
>
|
>
|
||||||
NN
|
NN
|
||||||
</span>
|
</button>
|
||||||
<span
|
<button
|
||||||
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded border px-1.5 py-0.5 text-[10px] font-medium",
|
"rounded border px-1.5 py-0.5 text-[10px] font-bold transition-colors",
|
||||||
idxState.hasIndex
|
idxState.hasIndex
|
||||||
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-900/50 dark:bg-emerald-950/40 dark:text-emerald-400"
|
? "border-emerald-200 bg-emerald-50 text-emerald-600"
|
||||||
: "border-border bg-muted/50 text-muted-foreground",
|
: "border-border text-muted-foreground/40 hover:border-emerald-200 hover:text-emerald-400",
|
||||||
)}
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onColumnChange(column.columnName, "hasIndex" as keyof ColumnTypeInfo, !idxState.hasIndex);
|
||||||
|
}}
|
||||||
|
title="Index 토글"
|
||||||
>
|
>
|
||||||
IDX
|
IDX
|
||||||
</span>
|
</button>
|
||||||
<span
|
<button
|
||||||
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded border px-1.5 py-0.5 text-[10px] font-medium",
|
"rounded border px-1.5 py-0.5 text-[10px] font-bold transition-colors",
|
||||||
column.isUnique === "YES"
|
column.isUnique === "YES"
|
||||||
? "border-violet-200 bg-violet-50 text-violet-600 dark:border-violet-900/50 dark:bg-violet-950/40 dark:text-violet-400"
|
? "border-violet-200 bg-violet-50 text-violet-600"
|
||||||
: "border-border bg-muted/50 text-muted-foreground",
|
: "border-border text-muted-foreground/40 hover:border-violet-200 hover:text-violet-400",
|
||||||
)}
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onColumnChange(column.columnName, "isUnique", column.isUnique === "YES" ? "NO" : "YES");
|
||||||
|
}}
|
||||||
|
title="Unique 토글"
|
||||||
>
|
>
|
||||||
UQ
|
UQ
|
||||||
</span>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
|
|
|
||||||
|
|
@ -47,23 +47,25 @@ export type ColumnGroup = "basic" | "reference" | "meta";
|
||||||
export interface TypeColorConfig {
|
export interface TypeColorConfig {
|
||||||
color: string;
|
color: string;
|
||||||
bgColor: string;
|
bgColor: string;
|
||||||
|
barColor: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon?: string;
|
desc: string;
|
||||||
|
iconChar: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 입력 타입별 색상 맵 - 배경/텍스트/보더는 다크에서 자동 변환 */
|
/** 입력 타입별 색상 맵 - iconChar는 카드 선택용 시각 아이콘 */
|
||||||
export const INPUT_TYPE_COLORS: Record<string, TypeColorConfig> = {
|
export const INPUT_TYPE_COLORS: Record<string, TypeColorConfig> = {
|
||||||
text: { color: "text-slate-600", bgColor: "bg-slate-50", label: "텍스트" },
|
text: { color: "text-slate-600", bgColor: "bg-slate-50", barColor: "bg-slate-400", label: "텍스트", desc: "일반 텍스트 입력", iconChar: "T" },
|
||||||
number: { color: "text-indigo-600", bgColor: "bg-indigo-50", label: "숫자" },
|
number: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-500", label: "숫자", desc: "숫자만 입력", iconChar: "#" },
|
||||||
date: { color: "text-amber-600", bgColor: "bg-amber-50", label: "날짜" },
|
date: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "날짜", desc: "날짜 선택", iconChar: "D" },
|
||||||
code: { color: "text-emerald-600", bgColor: "bg-emerald-50", label: "코드" },
|
code: { color: "text-emerald-600", bgColor: "bg-emerald-50", barColor: "bg-emerald-500", label: "코드", desc: "공통코드 선택", iconChar: "{}" },
|
||||||
entity: { color: "text-violet-600", bgColor: "bg-violet-50", label: "엔티티" },
|
entity: { color: "text-violet-600", bgColor: "bg-violet-50", barColor: "bg-violet-500", label: "테이블 참조", desc: "다른 테이블 연결", iconChar: "⊞" },
|
||||||
select: { color: "text-cyan-600", bgColor: "bg-cyan-50", label: "셀렉트" },
|
select: { color: "text-cyan-600", bgColor: "bg-cyan-50", barColor: "bg-cyan-500", label: "셀렉트", desc: "직접 옵션 선택", iconChar: "☰" },
|
||||||
checkbox: { color: "text-pink-600", bgColor: "bg-pink-50", label: "체크박스" },
|
checkbox: { color: "text-pink-600", bgColor: "bg-pink-50", barColor: "bg-pink-500", label: "체크박스", desc: "예/아니오 선택", iconChar: "☑" },
|
||||||
numbering: { color: "text-orange-600", bgColor: "bg-orange-50", label: "채번" },
|
numbering: { color: "text-orange-600", bgColor: "bg-orange-50", barColor: "bg-orange-500", label: "채번", desc: "자동 번호 생성", iconChar: "≡" },
|
||||||
category: { color: "text-teal-600", bgColor: "bg-teal-50", label: "카테고리" },
|
category: { color: "text-teal-600", bgColor: "bg-teal-50", barColor: "bg-teal-500", label: "카테고리", desc: "등록된 선택지", iconChar: "⊟" },
|
||||||
textarea: { color: "text-indigo-600", bgColor: "bg-indigo-50", label: "텍스트영역" },
|
textarea: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-400", label: "여러 줄", desc: "긴 텍스트 입력", iconChar: "≡" },
|
||||||
radio: { color: "text-rose-600", bgColor: "bg-rose-50", label: "라디오" },
|
radio: { color: "text-rose-600", bgColor: "bg-rose-50", barColor: "bg-rose-500", label: "라디오", desc: "하나만 선택", iconChar: "◉" },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 컬럼 그룹 판별 */
|
/** 컬럼 그룹 판별 */
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue