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, 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()),
),
[tables, searchTerm],
); );
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); const selectedTableInfo = tables.find((table) => table.tableName === selectedTable);
@ -1292,30 +1299,19 @@ 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>
<p className="text-muted-foreground mt-2 text-sm"> <Badge variant="secondary" className="text-[10px] font-bold">
{getTextFromUI( {tables.length}
TABLE_MANAGEMENT_KEYS.PAGE_DESCRIPTION, </Badge>
"데이터베이스 테이블과 컬럼의 타입을 관리합니다",
)}
</p>
{isSuperAdmin && (
<p className="text-primary mt-1 text-sm font-medium">
</p>
)}
</div> </div>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-2">
{/* DDL 기능 버튼들 (최고 관리자만) */}
{isSuperAdmin && ( {isSuperAdmin && (
<> <>
<Button <Button
@ -1324,12 +1320,12 @@ export default function TableManagementPage() {
setDuplicateSourceTable(null); setDuplicateSourceTable(null);
setCreateTableModalOpen(true); setCreateTableModalOpen(true);
}} }}
className="h-10 gap-2 text-sm font-medium" size="sm"
size="default" className="h-8 gap-1.5 text-xs"
> >
<Plus className="h-4 w-4" /> <Plus className="h-3.5 w-3.5" />
</Button> </Button>
<Button <Button
onClick={() => { onClick={() => {
if (selectedTableIds.size !== 1) { if (selectedTableIds.size !== 1) {
@ -1342,91 +1338,76 @@ export default function TableManagementPage() {
setCreateTableModalOpen(true); setCreateTableModalOpen(true);
}} }}
variant="outline" variant="outline"
size="sm"
disabled={selectedTableIds.size !== 1} disabled={selectedTableIds.size !== 1}
className="h-10 gap-2 text-sm font-medium" className="h-8 gap-1.5 text-xs"
> >
<Copy className="h-4 w-4" /> <Copy className="h-3.5 w-3.5" />
</Button> </Button>
{selectedTable && ( {selectedTable && (
<Button <Button
onClick={() => setAddColumnModalOpen(true)} onClick={() => setAddColumnModalOpen(true)}
variant="outline" variant="outline"
className="h-10 gap-2 text-sm font-medium" size="sm"
className="h-8 gap-1.5 text-xs"
> >
<Plus className="h-4 w-4" /> <Plus className="h-3.5 w-3.5" />
</Button> </Button>
)} )}
<Button <Button
onClick={() => setDdlLogViewerOpen(true)} onClick={() => setDdlLogViewerOpen(true)}
variant="outline" variant="ghost"
className="h-10 gap-2 text-sm font-medium" size="sm"
className="h-8 gap-1.5 text-xs"
> >
<Activity className="h-4 w-4" /> <Activity className="h-3.5 w-3.5" />
DDL DDL
</Button> </Button>
</> </>
)} )}
<Button <Button
onClick={loadTables} onClick={loadTables}
disabled={loading} disabled={loading}
variant="outline" variant="ghost"
className="h-10 gap-2 text-sm font-medium" size="sm"
className="h-8 gap-1.5 text-xs"
> >
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} /> <RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
{getTextFromUI(TABLE_MANAGEMENT_KEYS.BUTTON_REFRESH, "새로고침")}
</Button> </Button>
</div> </div>
</div> </div>
</div>
<ResponsiveSplitPanel {/* 3패널 메인 */}
left={ <div className="flex flex-1 overflow-hidden">
<div className="flex h-full flex-col space-y-4"> {/* 좌측: 테이블 목록 (240px) */}
<div className="bg-card flex w-[240px] min-w-[240px] flex-shrink-0 flex-col border-r">
{/* 검색 */} {/* 검색 */}
<div className="flex-shrink-0"> <div className="flex-shrink-0 p-3 pb-0">
<div className="relative"> <div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" /> <Search className="text-muted-foreground absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2" />
<Input <Input
placeholder={getTextFromUI(TABLE_MANAGEMENT_KEYS.SEARCH_PLACEHOLDER, "테이블 검색...")} placeholder={getTextFromUI(TABLE_MANAGEMENT_KEYS.SEARCH_PLACEHOLDER, "테이블 검색...")}
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="h-10 pl-10 text-sm" className="bg-background h-[34px] pl-8 text-xs"
/> />
</div> </div>
</div>
{/* 테이블 목록 */}
<div className="flex-1 space-y-3 overflow-y-auto">
{/* 전체 선택 및 일괄 삭제 (최고 관리자만) */}
{isSuperAdmin && ( {isSuperAdmin && (
<div className="flex items-center justify-between border-b pb-3"> <div className="mt-2 flex items-center justify-between border-b pb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5">
<Checkbox <Checkbox
checked={ checked={
tables.filter( filteredTables.length > 0 &&
(table) => filteredTables.every((table) => selectedTableIds.has(table.tableName))
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} onCheckedChange={handleSelectAll}
aria-label="전체 선택" aria-label="전체 선택"
className="h-3.5 w-3.5"
/> />
<span className="text-muted-foreground text-sm"> <span className="text-muted-foreground text-[10px]">
{selectedTableIds.size > 0 && `${selectedTableIds.size} 선택됨`} {selectedTableIds.size > 0 ? `${selectedTableIds.size}` : "전체"}
</span> </span>
</div> </div>
{selectedTableIds.size > 0 && ( {selectedTableIds.size > 0 && (
@ -1434,7 +1415,7 @@ export default function TableManagementPage() {
variant="destructive" variant="destructive"
size="sm" size="sm"
onClick={handleBulkDeleteClick} onClick={handleBulkDeleteClick}
className="h-8 gap-2 text-xs" className="h-6 gap-1 px-2 text-[10px]"
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
@ -1442,128 +1423,159 @@ export default function TableManagementPage() {
)} )}
</div> </div>
)} )}
</div>
{/* 테이블 리스트 */}
<div className="flex-1 overflow-y-auto px-1">
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<LoadingSpinner /> <LoadingSpinner />
<span className="text-muted-foreground ml-2 text-sm">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_TABLES, "테이블 로딩 중...")}
</span>
</div> </div>
) : tables.length === 0 ? ( ) : filteredTables.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm"> <div className="text-muted-foreground py-8 text-center text-xs">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_TABLES, "테이블이 없습니다")} {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_TABLES, "테이블이 없습니다")}
</div> </div>
) : ( ) : (
tables filteredTables.map((table, idx) => {
.filter( const isActive = selectedTable === table.tableName;
(table) => const prevTable = idx > 0 ? filteredTables[idx - 1] : null;
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) || const isKo = /^[가-힣ㄱ-ㅎ]/.test(table.displayName || table.tableName);
(table.displayName && table.displayName.toLowerCase().includes(searchTerm.toLowerCase())), const prevIsKo = prevTable ? /^[가-힣ㄱ-ㅎ]/.test(prevTable.displayName || prevTable.tableName) : null;
) const showDivider = idx === 0 || (prevIsKo !== null && isKo !== prevIsKo);
.map((table) => (
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 <div
key={table.tableName} className={cn(
className={`bg-card rounded-lg p-4 shadow-sm transition-all ${ "group relative flex items-center gap-2 rounded-md px-2.5 py-[7px] transition-colors",
selectedTable === table.tableName isActive
? "bg-muted/30 shadow-md" ? "bg-accent text-foreground"
: "hover:bg-muted/20 hover:shadow-lg" : "text-foreground/80 hover:bg-accent/50",
}`} )}
style={ onClick={() => handleTableSelect(table.tableName)}
selectedTable === table.tableName role="button"
? { border: "2px solid #000000" } tabIndex={0}
: { border: "2px solid transparent" } onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleTableSelect(table.tableName);
} }
}}
> >
<div className="flex items-start gap-3"> {isActive && (
{/* 체크박스 (최고 관리자만) */} <div className="bg-primary absolute top-1.5 bottom-1.5 left-0 w-[3px] rounded-r" />
)}
{isSuperAdmin && ( {isSuperAdmin && (
<Checkbox <Checkbox
checked={selectedTableIds.has(table.tableName)} checked={selectedTableIds.has(table.tableName)}
onCheckedChange={(checked) => handleTableCheck(table.tableName, checked as boolean)} onCheckedChange={(checked) => handleTableCheck(table.tableName, checked as boolean)}
aria-label={`${table.displayName || table.tableName} 선택`} aria-label={`${table.displayName || table.tableName} 선택`}
className="mt-0.5" className="h-3.5 w-3.5 flex-shrink-0"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
)} )}
<div className="flex-1 cursor-pointer" onClick={() => handleTableSelect(table.tableName)}> <div className="min-w-0 flex-1">
<h4 className="text-sm font-semibold">{table.displayName || table.tableName}</h4> <div className="flex items-baseline gap-1">
<p className="text-muted-foreground mt-1 text-xs"> <span className={cn(
{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")} "truncate text-[12px] leading-tight",
</p> isActive ? "font-bold" : "font-medium",
<div className="mt-2 flex items-center justify-between border-t pt-2"> )}>
<span className="text-muted-foreground text-xs"></span> {table.displayName || table.tableName}
<Badge variant="secondary" className="text-xs"> </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} {table.columnCount}
</Badge> </span>
</div> </div>
</div> </div>
</div> );
</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>
right={
<div className="flex h-full flex-col overflow-hidden"> {/* 중앙: 컬럼 그리드 */}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{!selectedTable ? ( {!selectedTable ? (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border"> <div className="flex flex-1 flex-col items-center justify-center gap-2">
<div className="flex flex-col items-center gap-2 text-center"> <Database className="text-muted-foreground/40 h-10 w-10" />
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")} {getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")}
</p> </p>
</div> </div>
</div>
) : ( ) : (
<> <>
{/* 테이블 라벨 설정 + 저장 버튼 (고정 영역) */} {/* 중앙 헤더: 테이블명 + 라벨 입력 + 저장 */}
<div className="mb-4 flex items-center gap-4"> <div className="bg-card flex flex-shrink-0 items-center gap-3 border-b px-5 py-3">
<div className="flex-1"> <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
value={tableLabel} value={tableLabel}
onChange={(e) => setTableLabel(e.target.value)} onChange={(e) => setTableLabel(e.target.value)}
placeholder="테이블 표시명" placeholder="표시명"
className="h-10 text-sm" className="h-8 max-w-[160px] text-xs"
/> />
</div>
<div className="flex-1">
<Input <Input
value={tableDescription} value={tableDescription}
onChange={(e) => setTableDescription(e.target.value)} onChange={(e) => setTableDescription(e.target.value)}
placeholder="테이블 설명" placeholder="설명"
className="h-10 text-sm" className="h-8 max-w-[200px] text-xs"
/> />
</div> </div>
{/* 저장 버튼 (항상 보이도록 상단에 배치) */}
<Button <Button
onClick={saveAllSettings} onClick={saveAllSettings}
disabled={!selectedTable || columns.length === 0 || isSaving} disabled={!selectedTable || columns.length === 0 || isSaving}
className="h-10 gap-2 text-sm font-medium" size="sm"
className="h-8 gap-1.5 text-xs"
> >
{isSaving ? ( {isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
) : ( ) : (
<Settings className="h-4 w-4" /> <Save className="h-3.5 w-3.5" />
)} )}
{isSaving ? "저장 중..." : "전체 설정 저장"} {isSaving ? "저장 중..." : "전체 설정 저장"}
</Button> </Button>
</div> </div>
{columnsLoading ? ( {columnsLoading ? (
<div className="flex items-center justify-center py-8"> <div className="flex flex-1 items-center justify-center">
<LoadingSpinner /> <LoadingSpinner />
<span className="text-muted-foreground ml-2 text-sm"> <span className="text-muted-foreground ml-2 text-sm">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")} {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")}
</span> </span>
</div> </div>
) : columns.length === 0 ? ( ) : columns.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm"> <div className="text-muted-foreground flex flex-1 items-center justify-center text-sm">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")} {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
</div> </div>
) : ( ) : (
<div className="flex flex-1 overflow-hidden"> <>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<TypeOverviewStrip <TypeOverviewStrip
columns={columns} columns={columns}
activeFilter={typeFilter} activeFilter={typeFilter}
@ -1581,9 +1593,15 @@ export default function TableManagementPage() {
typeFilter={typeFilter} typeFilter={typeFilter}
getColumnIndexState={getColumnIndexState} getColumnIndexState={getColumnIndexState}
/> />
</>
)}
</>
)}
</div> </div>
{/* 우측: 상세 패널 (selectedColumn 있을 때만) */}
{selectedColumn && ( {selectedColumn && (
<div className="w-[360px] flex-shrink-0 overflow-hidden"> <div className="w-[320px] min-w-[320px] flex-shrink-0 overflow-hidden">
<ColumnDetailPanel <ColumnDetailPanel
column={columns.find((c) => c.columnName === selectedColumn) ?? null} column={columns.find((c) => c.columnName === selectedColumn) ?? null}
tables={tables} tables={tables}
@ -1613,18 +1631,6 @@ export default function TableManagementPage() {
</div> </div>
)} )}
</div> </div>
)}
</>
)}
</div>
}
leftTitle="테이블 목록"
leftWidth={20}
minLeftWidth={10}
maxLeftWidth={35}
height="100%"
className="flex-1 overflow-hidden"
/>
{/* DDL 모달 컴포넌트들 */} {/* DDL 모달 컴포넌트들 */}
{isSuperAdmin && ( {isSuperAdmin && (
@ -1818,6 +1824,5 @@ export default function TableManagementPage() {
{/* Scroll to Top 버튼 */} {/* Scroll to Top 버튼 */}
<ScrollToTop /> <ScrollToTop />
</div> </div>
</div>
); );
} }

View File

@ -100,41 +100,57 @@ 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]) => {
const isSelected = (column.inputType || "text") === type;
return (
<button <button
key={type} key={type}
type="button" type="button"
onClick={() => onColumnChange("inputType", type)} onClick={() => onColumnChange("inputType", type)}
className={cn( className={cn(
"rounded-lg border p-2 text-left text-xs transition-colors", "flex flex-col items-center gap-1 rounded-lg border px-1.5 py-2.5 text-center transition-all",
conf.bgColor, isSelected
conf.color, ? "border-primary bg-primary/5 ring-1 ring-primary/30"
(column.inputType || "text") === type : "border-border hover:border-primary/30 hover:bg-accent/50",
? "ring-2 ring-ring"
: "border-border hover:bg-muted/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} {conf.label}
</span>
<span className="text-[9px] leading-tight text-muted-foreground">
{conf.desc}
</span>
</button> </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">
<Label className="text-[11px] font-medium text-muted-foreground"> </Label>
<Popover open={entityTableOpen} onOpenChange={setEntityTableOpen}> <Popover open={entityTableOpen} onOpenChange={setEntityTableOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
@ -177,9 +193,11 @@ export function ColumnDetailPanel({
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</div> </div>
{/* 조인 컬럼 */}
{column.referenceTable && column.referenceTable !== "none" && ( {column.referenceTable && column.referenceTable !== "none" && (
<div className="space-y-1.5"> <div className="space-y-1">
<Label className="text-xs text-muted-foreground"> ()</Label> <Label className="text-[11px] font-medium text-muted-foreground"> ()</Label>
<Popover open={entityColumnOpen} onOpenChange={setEntityColumnOpen}> <Popover open={entityColumnOpen} onOpenChange={setEntityColumnOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
@ -237,7 +255,19 @@ export function ColumnDetailPanel({
</Popover> </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> </div>
)}
</section> </section>
)} )}

View File

@ -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">

View File

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