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:
DDD1542 2026-03-16 17:58:37 +09:00
parent a391918e58
commit 43aafb36c1
4 changed files with 527 additions and 465 deletions

View File

@ -12,7 +12,7 @@ import {
Search,
Database,
RefreshCw,
Settings,
Save,
Plus,
Activity,
Trash2,
@ -21,7 +21,6 @@ import {
ChevronsUpDown,
Loader2,
} from "lucide-react";
import { ResponsiveSplitPanel } from "@/components/common/ResponsiveSplitPanel";
import { cn } from "@/lib/utils";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { toast } from "sonner";
@ -969,16 +968,24 @@ export default function TableManagementPage() {
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedTable, columns.length]);
// 필터링된 테이블 목록 (메모이제이션)
const filteredTables = useMemo(
() =>
tables.filter(
(table) =>
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
table.displayName.toLowerCase().includes(searchTerm.toLowerCase()),
),
[tables, searchTerm],
);
// 필터링 + 한글 우선 정렬 (ㄱ~ㅎ → a~z)
const filteredTables = useMemo(() => {
const filtered = tables.filter(
(table) =>
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
table.displayName.toLowerCase().includes(searchTerm.toLowerCase()),
);
const isKorean = (str: string) => /^[가-힣ㄱ-ㅎ]/.test(str);
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);
@ -1292,339 +1299,338 @@ export default function TableManagementPage() {
};
return (
<div className="bg-background flex h-screen flex-col">
<div className="flex h-full flex-col space-y-6 overflow-hidden p-6">
{/* 페이지 헤더 */}
<div className="flex-shrink-0 space-y-2 border-b pb-4">
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_TITLE, "테이블 타입 관리")}
</h1>
<p className="text-muted-foreground mt-2 text-sm">
{getTextFromUI(
TABLE_MANAGEMENT_KEYS.PAGE_DESCRIPTION,
"데이터베이스 테이블과 컬럼의 타입을 관리합니다",
)}
</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>
</>
)}
<div className="bg-background flex h-screen flex-col overflow-hidden">
{/* 컴팩트 탑바 (52px) */}
<div className="flex h-[52px] flex-shrink-0 items-center justify-between border-b px-5">
<div className="flex items-center gap-3">
<Database className="h-4.5 w-4.5 text-muted-foreground" />
<h1 className="text-[15px] font-bold tracking-tight">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_TITLE, "테이블 타입 관리")}
</h1>
<Badge variant="secondary" className="text-[10px] font-bold">
{tables.length}
</Badge>
</div>
<div className="flex items-center gap-1.5">
{isSuperAdmin && (
<>
<Button
onClick={loadTables}
disabled={loading}
variant="outline"
className="h-10 gap-2 text-sm font-medium"
onClick={() => {
setDuplicateModalMode("create");
setDuplicateSourceTable(null);
setCreateTableModalOpen(true);
}}
size="sm"
className="h-8 gap-1.5 text-xs"
>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
{getTextFromUI(TABLE_MANAGEMENT_KEYS.BUTTON_REFRESH, "새로고침")}
<Plus className="h-3.5 w-3.5" />
</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>
{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>
<ResponsiveSplitPanel
left={
<div className="flex h-full flex-col space-y-4">
{/* 검색 */}
<div className="flex-shrink-0">
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
{/* 중앙: 컬럼 그리드 */}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{!selectedTable ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2">
<Database className="text-muted-foreground/40 h-10 w-10" />
<p className="text-muted-foreground text-sm">
{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
placeholder={getTextFromUI(TABLE_MANAGEMENT_KEYS.SEARCH_PLACEHOLDER, "테이블 검색...")}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm"
value={tableLabel}
onChange={(e) => setTableLabel(e.target.value)}
placeholder="표시명"
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>
<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 className="flex-1 space-y-3 overflow-y-auto">
{/* 전체 선택 및 일괄 삭제 (최고 관리자만) */}
{isSuperAdmin && (
<div className="flex items-center justify-between border-b pb-3">
<div className="flex items-center gap-2">
<Checkbox
checked={
tables.filter(
(table) =>
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>
{columnsLoading ? (
<div className="flex flex-1 items-center justify-center">
<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 flex flex-1 items-center justify-center text-sm">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
</div>
) : (
<>
{/* 테이블 라벨 설정 + 저장 버튼 (고정 영역) */}
<div className="mb-4 flex items-center gap-4">
<div className="flex-1">
<Input
value={tableLabel}
onChange={(e) => setTableLabel(e.target.value)}
placeholder="테이블 표시명"
className="h-10 text-sm"
/>
</div>
<div className="flex-1">
<Input
value={tableDescription}
onChange={(e) => setTableDescription(e.target.value)}
placeholder="테이블 설명"
className="h-10 text-sm"
/>
</div>
{/* 저장 버튼 (항상 보이도록 상단에 배치) */}
<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>
)}
<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>
}
leftTitle="테이블 목록"
leftWidth={20}
minLeftWidth={10}
maxLeftWidth={35}
height="100%"
className="flex-1 overflow-hidden"
/>
</>
)}
</div>
{/* 우측: 상세 패널 (selectedColumn 있을 때만) */}
{selectedColumn && (
<div className="w-[320px] min-w-[320px] 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>
{/* DDL 모달 컴포넌트들 */}
{isSuperAdmin && (
@ -1817,7 +1823,6 @@ export default function TableManagementPage() {
{/* Scroll to Top 버튼 */}
<ScrollToTop />
</div>
</div>
);
}

View File

@ -100,75 +100,152 @@ export function ColumnDetailPanel({
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{/* [섹션 1] 데이터 타입 선택 */}
<section className="space-y-2">
<div className="flex items-center gap-2">
<Type className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium"> </Label>
<div>
<p className="text-sm font-semibold"> ?</p>
<p className="text-xs text-muted-foreground"> </p>
</div>
<div className="grid grid-cols-3 gap-2">
{Object.entries(INPUT_TYPE_COLORS).map(([type, conf]) => (
<button
key={type}
type="button"
onClick={() => onColumnChange("inputType", type)}
className={cn(
"rounded-lg border p-2 text-left text-xs transition-colors",
conf.bgColor,
conf.color,
(column.inputType || "text") === type
? "ring-2 ring-ring"
: "border-border hover:bg-muted/50",
)}
>
{conf.label}
</button>
))}
<div className="grid grid-cols-3 gap-1.5">
{Object.entries(INPUT_TYPE_COLORS).map(([type, conf]) => {
const isSelected = (column.inputType || "text") === type;
return (
<button
key={type}
type="button"
onClick={() => onColumnChange("inputType", type)}
className={cn(
"flex flex-col items-center gap-1 rounded-lg border px-1.5 py-2.5 text-center transition-all",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/30"
: "border-border hover:border-primary/30 hover:bg-accent/50",
)}
>
<span className={cn(
"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>
</section>
{/* [섹션 2] 타입별 상세 설정 */}
{column.inputType === "entity" && (
<section className="space-y-2">
<section className="space-y-3">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium"> </Label>
</div>
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"> </Label>
<Popover open={entityTableOpen} onOpenChange={setEntityTableOpen}>
{/* 참조 테이블 */}
<div className="space-y-1">
<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>
<Button
variant="outline"
role="combobox"
disabled={refColumns.length === 0}
className="h-9 w-full justify-between text-xs"
>
{column.referenceTable && column.referenceTable !== "none"
? refTableOpts.find((o) => o.value === column.referenceTable)?.label ?? column.referenceTable
: "테이블 선택..."}
{column.referenceColumn && column.referenceColumn !== "none"
? column.referenceColumn
: "컬럼 선택..."}
<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" />
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-2 text-center text-xs"> .</CommandEmpty>
<CommandEmpty className="py-2 text-center text-xs"> .</CommandEmpty>
<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
key={opt.value}
value={`${opt.label} ${opt.value}`}
key={refCol.columnName}
value={`${refCol.displayName ?? ""} ${refCol.columnName}`}
onSelect={() => {
onColumnChange("referenceTable", opt.value === "none" ? undefined : opt.value);
if (opt.value !== "none") onLoadReferenceColumns?.(opt.value);
setEntityTableOpen(false);
onColumnChange("referenceColumn", refCol.columnName);
setEntityColumnOpen(false);
}}
className="text-xs"
>
<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>
))}
</CommandGroup>
@ -177,67 +254,20 @@ export function ColumnDetailPanel({
</PopoverContent>
</Popover>
</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}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={refColumns.length === 0}
className="h-9 w-full justify-between text-xs"
>
{column.referenceColumn && column.referenceColumn !== "none"
? column.referenceColumn
: "컬럼 선택..."}
<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>
)}
{/* 참조 요약 미니맵 */}
{column.referenceTable && column.referenceTable !== "none" && column.referenceColumn && (
<div className="flex items-center gap-2 rounded-md bg-violet-50 px-3 py-2">
<span className="font-mono text-[11px] font-semibold text-violet-600">
{column.referenceTable}
</span>
<span className="text-muted-foreground text-[10px]"></span>
<span className="font-mono text-[11px] font-semibold text-violet-600">
{column.referenceColumn}
</span>
</div>
)}
</section>
)}

View File

@ -45,6 +45,7 @@ export function ColumnGrid({
columns,
selectedColumn,
onSelectColumn,
onColumnChange,
constraints,
typeFilter = null,
getColumnIndexState: externalGetIndexState,
@ -128,8 +129,8 @@ export function ColumnGrid({
isSelected && "border-primary/30 bg-primary/5 shadow-sm",
)}
>
{/* 4px 색상바 */}
<div className={cn("h-full min-h-8 w-1 rounded-full", typeConf.bgColor, typeConf.color)} />
{/* 4px 색상바 (타입별 진한 색) */}
<div className={cn("h-full min-h-8 w-1 rounded-full", typeConf.barColor)} />
{/* 라벨 + 컬럼명 */}
<div className="min-w-0">
@ -180,48 +181,72 @@ export function ColumnGrid({
{typeConf.label}
</div>
{/* PK / NN / IDX / UQ (읽기 전용) */}
{/* PK / NN / IDX / UQ (클릭 토글) */}
<div className="flex flex-wrap items-center justify-center gap-1">
<span
<button
type="button"
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
? "border-primary/30 bg-primary/10 text-primary"
: "border-border bg-muted/50 text-muted-foreground",
? "border-blue-200 bg-blue-50 text-blue-600"
: "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
</span>
<span
</button>
<button
type="button"
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"
? "border-amber-200 bg-amber-50 text-amber-600 dark:border-amber-900/50 dark:bg-amber-950/40 dark:text-amber-400"
: "border-border bg-muted/50 text-muted-foreground",
? "border-amber-200 bg-amber-50 text-amber-600"
: "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
</span>
<span
</button>
<button
type="button"
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
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-900/50 dark:bg-emerald-950/40 dark:text-emerald-400"
: "border-border bg-muted/50 text-muted-foreground",
? "border-emerald-200 bg-emerald-50 text-emerald-600"
: "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
</span>
<span
</button>
<button
type="button"
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"
? "border-violet-200 bg-violet-50 text-violet-600 dark:border-violet-900/50 dark:bg-violet-950/40 dark:text-violet-400"
: "border-border bg-muted/50 text-muted-foreground",
? "border-violet-200 bg-violet-50 text-violet-600"
: "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
</span>
</button>
</div>
<div className="flex items-center justify-center">

View File

@ -47,23 +47,25 @@ export type ColumnGroup = "basic" | "reference" | "meta";
export interface TypeColorConfig {
color: string;
bgColor: string;
barColor: string;
label: string;
icon?: string;
desc: string;
iconChar: string;
}
/** 입력 타입별 색상 맵 - 배경/텍스트/보더는 다크에서 자동 변환 */
/** 입력 타입별 색상 맵 - iconChar는 카드 선택용 시각 아이콘 */
export const INPUT_TYPE_COLORS: Record<string, TypeColorConfig> = {
text: { color: "text-slate-600", bgColor: "bg-slate-50", label: "텍스트" },
number: { color: "text-indigo-600", bgColor: "bg-indigo-50", label: "숫자" },
date: { color: "text-amber-600", bgColor: "bg-amber-50", label: "날짜" },
code: { color: "text-emerald-600", bgColor: "bg-emerald-50", label: "코드" },
entity: { color: "text-violet-600", bgColor: "bg-violet-50", label: "엔티티" },
select: { color: "text-cyan-600", bgColor: "bg-cyan-50", label: "셀렉트" },
checkbox: { color: "text-pink-600", bgColor: "bg-pink-50", label: "체크박스" },
numbering: { color: "text-orange-600", bgColor: "bg-orange-50", label: "채번" },
category: { color: "text-teal-600", bgColor: "bg-teal-50", label: "카테고리" },
textarea: { color: "text-indigo-600", bgColor: "bg-indigo-50", label: "텍스트영역" },
radio: { color: "text-rose-600", bgColor: "bg-rose-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", barColor: "bg-indigo-500", label: "숫자", desc: "숫자만 입력", iconChar: "#" },
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", barColor: "bg-emerald-500", label: "코드", desc: "공통코드 선택", iconChar: "{}" },
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", barColor: "bg-cyan-500", label: "셀렉트", desc: "직접 옵션 선택", iconChar: "☰" },
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", barColor: "bg-orange-500", label: "채번", desc: "자동 번호 생성", iconChar: "≡" },
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", barColor: "bg-indigo-400", label: "여러 줄", desc: "긴 텍스트 입력", iconChar: "≡" },
radio: { color: "text-rose-600", bgColor: "bg-rose-50", barColor: "bg-rose-500", label: "라디오", desc: "하나만 선택", iconChar: "◉" },
};
/** 컬럼 그룹 판별 */