feat: enhance table management page with improved filtering and UI updates
- Implemented Korean prioritization in table filtering, allowing for better sorting of table names based on Korean characters. - Updated the UI to a more compact design with a top bar for better accessibility and user experience. - Added new button styles and functionalities for creating and duplicating tables, enhancing the overall management capabilities. - Improved the column detail panel with clearer labeling and enhanced interaction for selecting data types and reference tables. These changes aim to streamline the table management process and improve usability within the ERP system.
This commit is contained in:
parent
a391918e58
commit
43aafb36c1
|
|
@ -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