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