jskim-node #419

Merged
kjs merged 15 commits from jskim-node into main 2026-03-17 09:56:34 +09:00
4 changed files with 527 additions and 465 deletions
Showing only changes of commit 43aafb36c1 - Show all commits

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: "◉" },
};
/** 컬럼 그룹 판별 */