feat(테이블타입): 컬럼 그리드 DBeaver 식 탭 분리 — 컬럼 / 참조
ColumnGrid 의 "참조/설정" 컬럼이 두 가지 다른 역할 (entity/code/numbering 의 참조 대상 표시 vs 그 외 타입의 default_value 표시) 을 한 셀에 욱여넣고 있었음. text/number/date 행에선 대부분 — 빈 칸. 진단 결과는 architect 가 동의한 대로 컬럼 통째 제거가 답. DBeaver 의 Columns / Foreign Keys 탭 분리 패턴 차용: - 컬럼 탭 (기본 활성) — 컬럼 본연의 속성만 (라벨 / 타입 / PK NN IDX UQ / ⋯). 참조/설정 컬럼 통째 제거, grid 6→5 컬럼으로 슬림화. 라벨 셀 폭 1fr 로 확보 - 참조 탭 — entity / code / category / numbering 컬럼만 모아 표 형태로. 종류별 그룹 헤더 + (소스 컬럼 / 참조 종류 / 참조 대상) 3컬럼 그리드. code 행에서 누락되어있던 코드 그룹 표시도 같이 채움 ReferenceListView 신규 컴포넌트로 분리. 사용자가 행 클릭하면 우측 상세 패널 슬라이드 in 하는 기존 동작은 양 탭 모두 동일. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,10 @@ import {
|
||||
ChevronsUpDown,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Columns3,
|
||||
Link2,
|
||||
} from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
import { toast } from "sonner";
|
||||
@@ -56,6 +59,7 @@ import type { TableInfo, ColumnTypeInfo, SecondLevelMenu } from "@/components/ad
|
||||
import { TypeOverviewStrip } from "@/components/admin/table-type/TypeOverviewStrip";
|
||||
import { ColumnGrid } from "@/components/admin/table-type/ColumnGrid";
|
||||
import { ColumnDetailPanel } from "@/components/admin/table-type/ColumnDetailPanel";
|
||||
import { ReferenceListView } from "@/components/admin/table-type/ReferenceListView";
|
||||
|
||||
export default function TableManagementPage() {
|
||||
const { userLang, getText } = useMultiLang({ companyCode: "*" });
|
||||
@@ -1690,46 +1694,79 @@ export default function TableManagementPage() {
|
||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<TypeOverviewStrip
|
||||
columns={columns}
|
||||
activeFilter={typeFilter}
|
||||
onFilterChange={setTypeFilter}
|
||||
/>
|
||||
<ColumnGrid
|
||||
columns={columns}
|
||||
selectedColumn={selectedColumn}
|
||||
onSelectColumn={(c) => setSelectedColumn((prev) => (prev === c ? null : c))}
|
||||
onColumnChange={(columnName, field, value) => {
|
||||
if (field === "is_unique") {
|
||||
const currentColumn = columns.find((c) => c.column_name === columnName);
|
||||
if (currentColumn) {
|
||||
handleUniqueToggle(columnName, currentColumn.is_unique || "NO");
|
||||
<Tabs defaultValue="columns" className="flex min-h-0 flex-1 flex-col">
|
||||
<TabsList className="h-8 w-fit shrink-0 self-start mx-4 mt-1">
|
||||
<TabsTrigger value="columns" className="flex items-center gap-1.5 text-xs">
|
||||
<Columns3 className="h-3.5 w-3.5" />
|
||||
컬럼
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="references" className="flex items-center gap-1.5 text-xs">
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
참조
|
||||
{(() => {
|
||||
const refCount = columns.filter((c) =>
|
||||
["entity", "code", "category", "numbering"].includes(c.input_type),
|
||||
).length;
|
||||
return refCount > 0 ? (
|
||||
<Badge variant="secondary" className="ml-1 h-4 px-1.5 text-[10px]">
|
||||
{refCount}
|
||||
</Badge>
|
||||
) : null;
|
||||
})()}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="columns" className="mt-0 flex min-h-0 flex-1 flex-col">
|
||||
<TypeOverviewStrip
|
||||
columns={columns}
|
||||
activeFilter={typeFilter}
|
||||
onFilterChange={setTypeFilter}
|
||||
/>
|
||||
<ColumnGrid
|
||||
columns={columns}
|
||||
selectedColumn={selectedColumn}
|
||||
onSelectColumn={(c) => setSelectedColumn((prev) => (prev === c ? null : c))}
|
||||
onColumnChange={(columnName, field, value) => {
|
||||
if (field === "is_unique") {
|
||||
const currentColumn = columns.find((c) => c.column_name === columnName);
|
||||
if (currentColumn) {
|
||||
handleUniqueToggle(columnName, currentColumn.is_unique || "NO");
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (field === "is_nullable") {
|
||||
const currentColumn = columns.find((c) => c.column_name === columnName);
|
||||
if (currentColumn) {
|
||||
handleNullableToggle(columnName, currentColumn.is_nullable || "YES");
|
||||
if (field === "is_nullable") {
|
||||
const currentColumn = columns.find((c) => c.column_name === columnName);
|
||||
if (currentColumn) {
|
||||
handleNullableToggle(columnName, currentColumn.is_nullable || "YES");
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
const idx = columns.findIndex((c) => c.column_name === columnName);
|
||||
if (idx >= 0) handleColumnChange(idx, field, value);
|
||||
}}
|
||||
constraints={constraints}
|
||||
typeFilter={typeFilter}
|
||||
getColumnIndexState={getColumnIndexState}
|
||||
onPkToggle={handlePkToggle}
|
||||
onIndexToggle={(columnName, checked) =>
|
||||
handleIndexToggle(columnName, "index", checked)
|
||||
}
|
||||
const idx = columns.findIndex((c) => c.column_name === columnName);
|
||||
if (idx >= 0) handleColumnChange(idx, field, value);
|
||||
}}
|
||||
constraints={constraints}
|
||||
typeFilter={typeFilter}
|
||||
getColumnIndexState={getColumnIndexState}
|
||||
onPkToggle={handlePkToggle}
|
||||
onIndexToggle={(columnName, checked) =>
|
||||
handleIndexToggle(columnName, "index", checked)
|
||||
}
|
||||
onDeleteColumn={handleDeleteColumnClick}
|
||||
tables={tables}
|
||||
referenceTableColumns={referenceTableColumns}
|
||||
/>
|
||||
</>
|
||||
onDeleteColumn={handleDeleteColumnClick}
|
||||
tables={tables}
|
||||
referenceTableColumns={referenceTableColumns}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="references" className="mt-0 flex min-h-0 flex-1 flex-col">
|
||||
<ReferenceListView
|
||||
columns={columns}
|
||||
tables={tables}
|
||||
referenceTableColumns={referenceTableColumns}
|
||||
selectedColumn={selectedColumn}
|
||||
onSelectColumn={(c) => setSelectedColumn((prev) => (prev === c ? null : c))}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -92,11 +92,10 @@ export function ColumnGrid({
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
className="grid flex-shrink-0 items-center border-b bg-muted/50 px-4 py-2 text-xs font-semibold text-foreground"
|
||||
style={{ gridTemplateColumns: "4px 140px 1fr 100px 160px 40px" }}
|
||||
style={{ gridTemplateColumns: "4px 1fr 100px 160px 40px" }}
|
||||
>
|
||||
<span />
|
||||
<span>라벨 · 컬럼명</span>
|
||||
<span>참조/설정</span>
|
||||
<span>타입</span>
|
||||
<span className="text-center">PK / NN / IDX / UQ</span>
|
||||
<span />
|
||||
@@ -142,7 +141,7 @@ export function ColumnGrid({
|
||||
}}
|
||||
className={cn(
|
||||
"grid min-h-12 cursor-pointer items-center gap-2 rounded-md border px-4 py-2 transition-colors",
|
||||
"grid-cols-[4px_140px_1fr_100px_160px_40px]",
|
||||
"grid-cols-[4px_1fr_100px_160px_40px]",
|
||||
"bg-card border-transparent hover:border-border hover:shadow-sm",
|
||||
isSelected && "border-primary/30 bg-primary/5 shadow-sm",
|
||||
)}
|
||||
@@ -159,66 +158,6 @@ export function ColumnGrid({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 참조/설정 칩 */}
|
||||
<div className="flex min-w-0 flex-wrap gap-1">
|
||||
{column.input_type === "entity" && column.reference_table && column.reference_table !== "none" && (
|
||||
<>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs font-normal"
|
||||
title={
|
||||
tables
|
||||
? (() => {
|
||||
const t = tables.find((tb) => tb.table_name === column.reference_table);
|
||||
return t?.display_name && t.display_name !== t.table_name
|
||||
? `${t.display_name} (${column.reference_table})`
|
||||
: column.reference_table;
|
||||
})()
|
||||
: column.reference_table
|
||||
}
|
||||
>
|
||||
{column.reference_table}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">→</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs font-normal"
|
||||
title={
|
||||
referenceTableColumns?.[column.reference_table]
|
||||
? (() => {
|
||||
const refCols = referenceTableColumns[column.reference_table];
|
||||
const c = refCols.find((rc) => rc.column_name === (column.reference_column ?? ""));
|
||||
return c?.display_name && c.display_name !== c.column_name
|
||||
? `${c.display_name} (${column.reference_column})`
|
||||
: column.reference_column ?? "—";
|
||||
})()
|
||||
: column.reference_column ?? "—"
|
||||
}
|
||||
>
|
||||
{column.reference_column || "—"}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{column.input_type === "code" && (
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{column.code_info ?? "—"} · {column.default_value ?? ""}
|
||||
</span>
|
||||
)}
|
||||
{column.input_type === "numbering" && column.numbering_rule_id && (
|
||||
<Badge variant="outline" className="text-xs font-normal">
|
||||
{column.numbering_rule_id}
|
||||
</Badge>
|
||||
)}
|
||||
{column.input_type !== "entity" &&
|
||||
column.input_type !== "code" &&
|
||||
column.input_type !== "numbering" &&
|
||||
(column.default_value ? (
|
||||
<span className="text-muted-foreground truncate text-xs">{column.default_value}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/60 text-xs">—</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 타입 뱃지 */}
|
||||
<div className={cn("rounded-md border px-2 py-0.5 text-xs", typeConf.bgColor, typeConf.color)}>
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-current opacity-70" />
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Database, FolderTree, Hash, Link2, FileCode2 } from "lucide-react";
|
||||
import type { ColumnTypeInfo, TableInfo } from "./types";
|
||||
import { INPUT_TYPE_COLORS } from "./types";
|
||||
import type { ReferenceTableColumn } from "@/lib/api/entityJoin";
|
||||
|
||||
export interface ReferenceListViewProps {
|
||||
columns: ColumnTypeInfo[];
|
||||
tables?: TableInfo[];
|
||||
referenceTableColumns?: Record<string, ReferenceTableColumn[]>;
|
||||
onSelectColumn?: (columnName: string) => void;
|
||||
selectedColumn?: string | null;
|
||||
}
|
||||
|
||||
type RefKind = "entity" | "code" | "category" | "numbering";
|
||||
|
||||
const KIND_META: Record<
|
||||
RefKind,
|
||||
{ icon: React.FC<{ className?: string }>; label: string; color: string; bgColor: string }
|
||||
> = {
|
||||
entity: { icon: Link2, label: "테이블 참조", color: "text-violet-600", bgColor: "bg-violet-50" },
|
||||
code: { icon: FileCode2, label: "공통코드", color: "text-emerald-600", bgColor: "bg-emerald-50" },
|
||||
category: { icon: FolderTree, label: "카테고리", color: "text-teal-600", bgColor: "bg-teal-50" },
|
||||
numbering: { icon: Hash, label: "채번", color: "text-orange-600", bgColor: "bg-orange-50" },
|
||||
};
|
||||
|
||||
function getRefKind(col: ColumnTypeInfo): RefKind | null {
|
||||
const t = col.input_type;
|
||||
if (t === "entity" || t === "code" || t === "category" || t === "numbering") return t;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ReferenceListView({
|
||||
columns,
|
||||
tables,
|
||||
referenceTableColumns,
|
||||
onSelectColumn,
|
||||
selectedColumn = null,
|
||||
}: ReferenceListViewProps) {
|
||||
const grouped = useMemo(() => {
|
||||
const groups: Record<RefKind, ColumnTypeInfo[]> = {
|
||||
entity: [],
|
||||
code: [],
|
||||
category: [],
|
||||
numbering: [],
|
||||
};
|
||||
for (const col of columns) {
|
||||
const kind = getRefKind(col);
|
||||
if (kind) groups[kind].push(col);
|
||||
}
|
||||
return groups;
|
||||
}, [columns]);
|
||||
|
||||
const totalRefs =
|
||||
grouped.entity.length + grouped.code.length + grouped.category.length + grouped.numbering.length;
|
||||
|
||||
if (totalRefs === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Database className="h-8 w-8 text-muted-foreground/50" />
|
||||
<span>이 테이블에는 참조 컬럼이 없어요.</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
className="grid flex-shrink-0 items-center border-b bg-muted/50 px-4 py-2 text-xs font-semibold text-foreground"
|
||||
style={{ gridTemplateColumns: "4px 220px 110px 1fr" }}
|
||||
>
|
||||
<span />
|
||||
<span>소스 컬럼</span>
|
||||
<span>참조 종류</span>
|
||||
<span>참조 대상</span>
|
||||
</div>
|
||||
|
||||
{/* 그룹별 행 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{(["entity", "code", "category", "numbering"] as const).map((kind) => {
|
||||
const list = grouped[kind];
|
||||
if (list.length === 0) return null;
|
||||
const meta = KIND_META[kind];
|
||||
const KindIcon = meta.icon;
|
||||
return (
|
||||
<div key={kind} className="space-y-1 py-2">
|
||||
<div className="flex items-center gap-2 border-b border-border/60 px-4 pb-1.5">
|
||||
<KindIcon className={cn("h-4 w-4", meta.color)} />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{meta.label}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{list.length}
|
||||
</Badge>
|
||||
</div>
|
||||
{list.map((column) => {
|
||||
const typeConf = INPUT_TYPE_COLORS[column.input_type || "text"] || INPUT_TYPE_COLORS.text;
|
||||
const isSelected = selectedColumn === column.column_name;
|
||||
return (
|
||||
<div
|
||||
key={column.column_name}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelectColumn?.(column.column_name)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelectColumn?.(column.column_name);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"grid min-h-10 cursor-pointer items-center gap-2 rounded-md border px-4 py-2 transition-colors",
|
||||
"bg-card border-transparent hover:border-border hover:shadow-sm",
|
||||
isSelected && "border-primary/30 bg-primary/5 shadow-sm",
|
||||
)}
|
||||
style={{ gridTemplateColumns: "4px 220px 110px 1fr" }}
|
||||
>
|
||||
{/* 색상바 */}
|
||||
<div className={cn("h-full min-h-8 w-1 rounded-full", typeConf.barColor)} />
|
||||
|
||||
{/* 소스 컬럼명 */}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-medium">
|
||||
{column.display_name && column.display_name !== column.column_name
|
||||
? `${column.display_name} (${column.column_name})`
|
||||
: column.column_name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 참조 종류 칩 */}
|
||||
<div className={cn("inline-flex w-fit items-center gap-1 rounded-md border px-2 py-0.5 text-xs", meta.bgColor, meta.color)}>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-current opacity-70" />
|
||||
{meta.label}
|
||||
</div>
|
||||
|
||||
{/* 참조 대상 */}
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1">
|
||||
{kind === "entity" && column.reference_table && column.reference_table !== "none" ? (
|
||||
<>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs font-normal"
|
||||
title={
|
||||
tables
|
||||
? (() => {
|
||||
const t = tables.find((tb) => tb.table_name === column.reference_table);
|
||||
return t?.display_name && t.display_name !== t.table_name
|
||||
? `${t.display_name} (${column.reference_table})`
|
||||
: column.reference_table;
|
||||
})()
|
||||
: column.reference_table
|
||||
}
|
||||
>
|
||||
{column.reference_table}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">→</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs font-normal"
|
||||
title={
|
||||
referenceTableColumns?.[column.reference_table]
|
||||
? (() => {
|
||||
const refCols = referenceTableColumns[column.reference_table];
|
||||
const c = refCols.find((rc) => rc.column_name === (column.reference_column ?? ""));
|
||||
return c?.display_name && c.display_name !== c.column_name
|
||||
? `${c.display_name} (${column.reference_column})`
|
||||
: column.reference_column ?? "—";
|
||||
})()
|
||||
: column.reference_column ?? "—"
|
||||
}
|
||||
>
|
||||
{column.reference_column || "—"}
|
||||
</Badge>
|
||||
</>
|
||||
) : kind === "code" ? (
|
||||
column.code_info ? (
|
||||
<Badge variant="outline" className="text-xs font-normal">
|
||||
코드: {column.code_info}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground/60 text-xs">— (코드 그룹 미지정)</span>
|
||||
)
|
||||
) : kind === "category" ? (
|
||||
column.category_ref ? (
|
||||
<Badge variant="outline" className="text-xs font-normal">
|
||||
카테고리: {column.category_ref}
|
||||
</Badge>
|
||||
) : column.category_menus && column.category_menus.length > 0 ? (
|
||||
<Badge variant="outline" className="text-xs font-normal">
|
||||
카테고리 메뉴 {column.category_menus.length}개
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground/60 text-xs">— (카테고리 미지정)</span>
|
||||
)
|
||||
) : kind === "numbering" ? (
|
||||
column.numbering_rule_id ? (
|
||||
<Badge variant="outline" className="text-xs font-normal">
|
||||
채번: {column.numbering_rule_id}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground/60 text-xs">— (채번 규칙 미지정)</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-muted-foreground/60 text-xs">—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user