[agent-pipeline] pipe-20260316081628-53mz round-1
This commit is contained in:
parent
825f164bde
commit
a391918e58
|
|
@ -50,43 +50,10 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface TableInfo {
|
||||
tableName: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
columnCount: number;
|
||||
}
|
||||
|
||||
interface ColumnTypeInfo {
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
inputType: string; // webType → inputType 변경
|
||||
detailSettings: string;
|
||||
description: string;
|
||||
isNullable: string;
|
||||
isUnique: string;
|
||||
defaultValue?: string;
|
||||
maxLength?: number;
|
||||
numericPrecision?: number;
|
||||
numericScale?: number;
|
||||
codeCategory?: string;
|
||||
codeValue?: string;
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
|
||||
categoryMenus?: number[];
|
||||
hierarchyRole?: "large" | "medium" | "small";
|
||||
numberingRuleId?: string;
|
||||
categoryRef?: string | null;
|
||||
}
|
||||
|
||||
interface SecondLevelMenu {
|
||||
menuObjid: number;
|
||||
menuName: string;
|
||||
parentMenuName: string;
|
||||
screenCode?: string;
|
||||
}
|
||||
import type { TableInfo, ColumnTypeInfo, SecondLevelMenu } from "@/components/admin/table-type/types";
|
||||
import { TypeOverviewStrip } from "@/components/admin/table-type/TypeOverviewStrip";
|
||||
import { ColumnGrid } from "@/components/admin/table-type/ColumnGrid";
|
||||
import { ColumnDetailPanel } from "@/components/admin/table-type/ColumnDetailPanel";
|
||||
|
||||
export default function TableManagementPage() {
|
||||
const { userLang, getText } = useMultiLang({ companyCode: "*" });
|
||||
|
|
@ -164,6 +131,11 @@ export default function TableManagementPage() {
|
|||
// 선택된 테이블 목록 (체크박스)
|
||||
const [selectedTableIds, setSelectedTableIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 컬럼 그리드: 선택된 컬럼(우측 상세 패널 표시)
|
||||
const [selectedColumn, setSelectedColumn] = useState<string | null>(null);
|
||||
// 타입 오버뷰 스트립: 타입 필터 (null = 전체)
|
||||
const [typeFilter, setTypeFilter] = useState<string | null>(null);
|
||||
|
||||
// 최고 관리자 여부 확인 (회사코드가 "*" AND userType이 "SUPER_ADMIN")
|
||||
const isSuperAdmin = user?.companyCode === "*" && user?.userType === "SUPER_ADMIN";
|
||||
|
||||
|
|
@ -442,6 +414,8 @@ export default function TableManagementPage() {
|
|||
setSelectedTable(tableName);
|
||||
setCurrentPage(1);
|
||||
setColumns([]);
|
||||
setSelectedColumn(null);
|
||||
setTypeFilter(null);
|
||||
|
||||
// 선택된 테이블 정보에서 라벨 설정
|
||||
const tableInfo = tables.find((table) => table.tableName === tableName);
|
||||
|
|
@ -1588,417 +1562,56 @@ export default function TableManagementPage() {
|
|||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* 컬럼 헤더 (고정) */}
|
||||
<div
|
||||
className="text-foreground grid h-12 flex-shrink-0 items-center border-b px-6 py-3 text-sm font-semibold"
|
||||
style={{ gridTemplateColumns: "160px 200px 250px minmax(80px,1fr) 70px 70px 70px 70px" }}
|
||||
>
|
||||
<div className="pr-4">라벨</div>
|
||||
<div className="px-4">컬럼명</div>
|
||||
<div className="pr-6">입력 타입</div>
|
||||
<div className="pl-4">설명</div>
|
||||
<div className="text-center text-xs">Primary</div>
|
||||
<div className="text-center text-xs">NotNull</div>
|
||||
<div className="text-center text-xs">Index</div>
|
||||
<div className="text-center text-xs">Unique</div>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 리스트 (스크롤 영역) */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto"
|
||||
onScroll={(e) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||
// 스크롤이 끝에 가까워지면 더 많은 데이터 로드
|
||||
if (scrollHeight - scrollTop <= clientHeight + 100) {
|
||||
loadMoreColumns();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{columns.map((column, index) => {
|
||||
const idxState = getColumnIndexState(column.columnName);
|
||||
return (
|
||||
<div
|
||||
key={column.columnName}
|
||||
className="bg-background hover:bg-muted/50 grid min-h-16 items-start border-b px-6 py-3 transition-colors"
|
||||
style={{ gridTemplateColumns: "160px 200px 250px minmax(80px,1fr) 70px 70px 70px 70px" }}
|
||||
>
|
||||
<div className="pr-4">
|
||||
<Input
|
||||
value={column.displayName || ""}
|
||||
onChange={(e) => handleLabelChange(column.columnName, e.target.value)}
|
||||
placeholder={column.columnName}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 pt-1">
|
||||
<div className="font-mono text-sm">{column.columnName}</div>
|
||||
</div>
|
||||
<div className="pr-6">
|
||||
<div className="space-y-3">
|
||||
{/* 입력 타입 선택 */}
|
||||
<Select
|
||||
value={column.inputType || "text"}
|
||||
onValueChange={(value) => handleInputTypeChange(column.columnName, value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="입력 타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{memoizedInputTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* 입력 타입이 'code'인 경우 공통코드 선택 */}
|
||||
{column.inputType === "code" && (
|
||||
<>
|
||||
<Select
|
||||
value={column.codeCategory || "none"}
|
||||
onValueChange={(value) =>
|
||||
handleDetailSettingsChange(column.columnName, "code", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="공통코드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{commonCodeOptions.map((option, index) => (
|
||||
<SelectItem key={`code-${option.value}-${index}`} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* 계층구조 역할 선택 */}
|
||||
{column.codeCategory && column.codeCategory !== "none" && (
|
||||
<Select
|
||||
value={column.hierarchyRole || "none"}
|
||||
onValueChange={(value) =>
|
||||
handleDetailSettingsChange(column.columnName, "hierarchy_role", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="계층 역할" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">일반</SelectItem>
|
||||
<SelectItem value="large">대분류</SelectItem>
|
||||
<SelectItem value="medium">중분류</SelectItem>
|
||||
<SelectItem value="small">소분류</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* 카테고리 타입: 참조 설정 */}
|
||||
{column.inputType === "category" && (
|
||||
<div className="w-56">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">카테고리 참조 (선택)</label>
|
||||
<Input
|
||||
value={column.categoryRef || ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value || null;
|
||||
setColumns((prev) =>
|
||||
prev.map((c) =>
|
||||
c.columnName === column.columnName
|
||||
? { ...c, categoryRef: val }
|
||||
: c
|
||||
)
|
||||
);
|
||||
}}
|
||||
placeholder="테이블명.컬럼명"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
||||
다른 테이블의 카테고리 값 참조 시 입력
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
|
||||
{column.inputType === "entity" && (
|
||||
<>
|
||||
{/* 참조 테이블 - 검색 가능한 Combobox */}
|
||||
<div className="w-56">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">참조 테이블</label>
|
||||
<Popover
|
||||
open={entityComboboxOpen[column.columnName]?.table || false}
|
||||
onOpenChange={(open) =>
|
||||
setEntityComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: { ...prev[column.columnName], table: open },
|
||||
}))
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={entityComboboxOpen[column.columnName]?.table || false}
|
||||
className="bg-background h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{column.referenceTable && column.referenceTable !== "none"
|
||||
? referenceTableOptions.find((opt) => opt.value === column.referenceTable)
|
||||
?.label || column.referenceTable
|
||||
: "테이블 선택..."}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 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>
|
||||
{referenceTableOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={`${option.label} ${option.value}`}
|
||||
onSelect={() => {
|
||||
handleDetailSettingsChange(
|
||||
column.columnName,
|
||||
"entity",
|
||||
option.value,
|
||||
);
|
||||
setEntityComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: {
|
||||
...prev[column.columnName],
|
||||
table: false,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
column.referenceTable === option.value
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{option.label}</span>
|
||||
{option.value !== "none" && (
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{option.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 조인 컬럼 - 검색 가능한 Combobox */}
|
||||
{column.referenceTable && column.referenceTable !== "none" && (
|
||||
<div className="w-56">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">조인 컬럼</label>
|
||||
<Popover
|
||||
open={entityComboboxOpen[column.columnName]?.joinColumn || false}
|
||||
onOpenChange={(open) =>
|
||||
setEntityComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: { ...prev[column.columnName], joinColumn: open },
|
||||
}))
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={entityComboboxOpen[column.columnName]?.joinColumn || false}
|
||||
className="bg-background h-8 w-full justify-between text-xs"
|
||||
disabled={
|
||||
!referenceTableColumns[column.referenceTable] ||
|
||||
referenceTableColumns[column.referenceTable].length === 0
|
||||
}
|
||||
>
|
||||
{!referenceTableColumns[column.referenceTable] ||
|
||||
referenceTableColumns[column.referenceTable].length === 0 ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
|
||||
로딩중...
|
||||
</span>
|
||||
) : column.referenceColumn && column.referenceColumn !== "none" ? (
|
||||
column.referenceColumn
|
||||
) : (
|
||||
"컬럼 선택..."
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 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={() => {
|
||||
handleDetailSettingsChange(
|
||||
column.columnName,
|
||||
"entity_reference_column",
|
||||
"none",
|
||||
);
|
||||
setEntityComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: {
|
||||
...prev[column.columnName],
|
||||
joinColumn: false,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
column.referenceColumn === "none" || !column.referenceColumn
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
-- 선택 안함 --
|
||||
</CommandItem>
|
||||
{referenceTableColumns[column.referenceTable]?.map((refCol) => (
|
||||
<CommandItem
|
||||
key={refCol.columnName}
|
||||
value={`${refCol.displayName || ""} ${refCol.columnName}`}
|
||||
onSelect={() => {
|
||||
handleDetailSettingsChange(
|
||||
column.columnName,
|
||||
"entity_reference_column",
|
||||
refCol.columnName,
|
||||
);
|
||||
setEntityComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: {
|
||||
...prev[column.columnName],
|
||||
joinColumn: false,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
column.referenceColumn === refCol.columnName
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{refCol.columnName}</span>
|
||||
{refCol.displayName && (
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{refCol.displayName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 설정 완료 표시 */}
|
||||
{column.referenceTable &&
|
||||
column.referenceTable !== "none" &&
|
||||
column.referenceColumn &&
|
||||
column.referenceColumn !== "none" && (
|
||||
<div className="bg-primary/10 text-primary flex items-center gap-1 rounded px-2 py-1 text-xs">
|
||||
<Check className="h-3 w-3" />
|
||||
<span className="truncate">설정 완료</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* 채번 타입은 옵션설정 > 채번설정에서 관리 (별도 선택 불필요) */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pl-4">
|
||||
<Input
|
||||
value={column.description || ""}
|
||||
onChange={(e) => handleColumnChange(index, "description", e.target.value)}
|
||||
placeholder="설명"
|
||||
className="h-8 w-full text-xs"
|
||||
/>
|
||||
</div>
|
||||
{/* PK 체크박스 */}
|
||||
<div className="flex items-center justify-center pt-1">
|
||||
<Checkbox
|
||||
checked={idxState.isPk}
|
||||
onCheckedChange={(checked) =>
|
||||
handlePkToggle(column.columnName, checked as boolean)
|
||||
}
|
||||
aria-label={`${column.columnName} PK 설정`}
|
||||
/>
|
||||
</div>
|
||||
{/* NN (NOT NULL) 체크박스 */}
|
||||
<div className="flex items-center justify-center pt-1">
|
||||
<Checkbox
|
||||
checked={column.isNullable === "NO"}
|
||||
onCheckedChange={() =>
|
||||
handleNullableToggle(column.columnName, column.isNullable)
|
||||
}
|
||||
aria-label={`${column.columnName} NOT NULL 설정`}
|
||||
/>
|
||||
</div>
|
||||
{/* IDX 체크박스 */}
|
||||
<div className="flex items-center justify-center pt-1">
|
||||
<Checkbox
|
||||
checked={idxState.hasIndex}
|
||||
onCheckedChange={(checked) =>
|
||||
handleIndexToggle(column.columnName, "index", checked as boolean)
|
||||
}
|
||||
aria-label={`${column.columnName} 인덱스 설정`}
|
||||
/>
|
||||
</div>
|
||||
{/* UQ 체크박스 (앱 레벨 소프트 제약조건) */}
|
||||
<div className="flex items-center justify-center pt-1">
|
||||
<Checkbox
|
||||
checked={column.isUnique === "YES"}
|
||||
onCheckedChange={() =>
|
||||
handleUniqueToggle(column.columnName, column.isUnique)
|
||||
}
|
||||
aria-label={`${column.columnName} 유니크 설정`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 로딩 표시 */}
|
||||
{columnsLoading && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<LoadingSpinner />
|
||||
<span className="text-muted-foreground ml-2 text-sm">더 많은 컬럼 로딩 중...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 페이지 정보 (고정 하단) */}
|
||||
<div className="text-muted-foreground flex-shrink-0 border-t py-2 text-center text-sm">
|
||||
{columns.length} / {totalColumns} 컬럼 표시됨
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,468 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { X, Type, Settings2, Tag, ToggleLeft, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ColumnTypeInfo, TableInfo, SecondLevelMenu } from "./types";
|
||||
import { INPUT_TYPE_COLORS } from "./types";
|
||||
import type { ReferenceTableColumn } from "@/lib/api/entityJoin";
|
||||
import type { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||
|
||||
export interface ColumnDetailPanelProps {
|
||||
column: ColumnTypeInfo | null;
|
||||
tables: TableInfo[];
|
||||
referenceTableColumns: Record<string, ReferenceTableColumn[]>;
|
||||
secondLevelMenus: SecondLevelMenu[];
|
||||
numberingRules: NumberingRuleConfig[];
|
||||
onColumnChange: (field: keyof ColumnTypeInfo, value: unknown) => void;
|
||||
onClose: () => void;
|
||||
onLoadReferenceColumns?: (tableName: string) => void;
|
||||
/** 코드 카테고리 옵션 (value, label) */
|
||||
codeCategoryOptions?: Array<{ value: string; label: string }>;
|
||||
/** 참조 테이블 옵션 (value, label) */
|
||||
referenceTableOptions?: Array<{ value: string; label: string }>;
|
||||
}
|
||||
|
||||
export function ColumnDetailPanel({
|
||||
column,
|
||||
tables,
|
||||
referenceTableColumns,
|
||||
numberingRules,
|
||||
onColumnChange,
|
||||
onClose,
|
||||
onLoadReferenceColumns,
|
||||
codeCategoryOptions = [],
|
||||
referenceTableOptions = [],
|
||||
}: ColumnDetailPanelProps) {
|
||||
const [advancedOpen, setAdvancedOpen] = React.useState(false);
|
||||
const [entityTableOpen, setEntityTableOpen] = React.useState(false);
|
||||
const [entityColumnOpen, setEntityColumnOpen] = React.useState(false);
|
||||
const [numberingOpen, setNumberingOpen] = React.useState(false);
|
||||
|
||||
const typeConf = column ? INPUT_TYPE_COLORS[column.inputType || "text"] : null;
|
||||
const refColumns = column?.referenceTable
|
||||
? referenceTableColumns[column.referenceTable] ?? []
|
||||
: [];
|
||||
|
||||
React.useEffect(() => {
|
||||
if (column?.referenceTable && column.referenceTable !== "none") {
|
||||
onLoadReferenceColumns?.(column.referenceTable);
|
||||
}
|
||||
}, [column?.referenceTable, onLoadReferenceColumns]);
|
||||
|
||||
const advancedCount = useMemo(() => {
|
||||
if (!column) return 0;
|
||||
let n = 0;
|
||||
if (column.defaultValue != null && column.defaultValue !== "") n++;
|
||||
if (column.maxLength != null && column.maxLength > 0) n++;
|
||||
return n;
|
||||
}, [column]);
|
||||
|
||||
if (!column) return null;
|
||||
|
||||
const refTableOpts = referenceTableOptions.length
|
||||
? referenceTableOptions
|
||||
: [{ value: "none", label: "선택 안함" }, ...tables.map((t) => ({ value: t.tableName, label: t.displayName || t.tableName }))];
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col border-l bg-card">
|
||||
{/* 헤더 */}
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-b px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{typeConf && (
|
||||
<span className={cn("rounded px-2 py-0.5 text-xs", typeConf.bgColor, typeConf.color)}>
|
||||
{typeConf.label}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate font-mono text-sm font-medium">{column.columnName}</span>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={onClose} aria-label="닫기">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
{/* [섹션 2] 타입별 상세 설정 */}
|
||||
{column.inputType === "entity" && (
|
||||
<section className="space-y-2">
|
||||
<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}>
|
||||
<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.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>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{column.inputType === "code" && (
|
||||
<section className="space-y-2">
|
||||
<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-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">공통코드 카테고리</Label>
|
||||
<Select
|
||||
value={column.codeCategory ?? "none"}
|
||||
onValueChange={(v) => onColumnChange("codeCategory", v === "none" ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue placeholder="코드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[{ value: "none", label: "선택 안함" }, ...codeCategoryOptions].map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{column.codeCategory && column.codeCategory !== "none" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">계층 역할</Label>
|
||||
<Select
|
||||
value={column.hierarchyRole ?? "none"}
|
||||
onValueChange={(v) =>
|
||||
onColumnChange("hierarchyRole", v === "none" ? undefined : (v as "large" | "medium" | "small"))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue placeholder="일반" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">일반</SelectItem>
|
||||
<SelectItem value="large">대분류</SelectItem>
|
||||
<SelectItem value="medium">중분류</SelectItem>
|
||||
<SelectItem value="small">소분류</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{column.inputType === "category" && (
|
||||
<section className="space-y-2">
|
||||
<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-1.5">
|
||||
<Label className="text-xs text-muted-foreground">참조 (테이블.컬럼)</Label>
|
||||
<Input
|
||||
value={column.categoryRef ?? ""}
|
||||
onChange={(e) => onColumnChange("categoryRef", e.target.value || null)}
|
||||
placeholder="테이블명.컬럼명"
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{column.inputType === "numbering" && (
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
||||
<Label className="text-sm font-medium">채번 규칙</Label>
|
||||
</div>
|
||||
<Popover open={numberingOpen} onOpenChange={setNumberingOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="h-9 w-full justify-between text-xs">
|
||||
{column.numberingRuleId
|
||||
? numberingRules.find((r) => r.ruleId === column.numberingRuleId)?.ruleName ?? column.numberingRuleId
|
||||
: "규칙 선택..."}
|
||||
<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("numberingRuleId", undefined);
|
||||
setNumberingOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check className={cn("mr-2 h-3 w-3", !column.numberingRuleId ? "opacity-100" : "opacity-0")} />
|
||||
선택 안함
|
||||
</CommandItem>
|
||||
{numberingRules.map((r) => (
|
||||
<CommandItem
|
||||
key={r.ruleId}
|
||||
value={`${r.ruleName} ${r.ruleId}`}
|
||||
onSelect={() => {
|
||||
onColumnChange("numberingRuleId", r.ruleId);
|
||||
setNumberingOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-3 w-3", column.numberingRuleId === r.ruleId ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
{r.ruleName}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* [섹션 3] 표시 이름 */}
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="h-4 w-4 text-muted-foreground" />
|
||||
<Label className="text-sm font-medium">표시 이름</Label>
|
||||
</div>
|
||||
<Input
|
||||
value={column.displayName ?? ""}
|
||||
onChange={(e) => onColumnChange("displayName", e.target.value)}
|
||||
placeholder={column.columnName}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* [섹션 4] 표시 옵션 */}
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ToggleLeft className="h-4 w-4 text-muted-foreground" />
|
||||
<Label className="text-sm font-medium">표시 옵션</Label>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">필수 입력</p>
|
||||
<p className="text-xs text-muted-foreground">비워두면 저장할 수 없어요.</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={column.isNullable === "NO"}
|
||||
onCheckedChange={(checked) => onColumnChange("isNullable", checked ? "NO" : "YES")}
|
||||
aria-label="필수 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">읽기 전용</p>
|
||||
<p className="text-xs text-muted-foreground">편집할 수 없어요.</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={false}
|
||||
onCheckedChange={() => {}}
|
||||
disabled
|
||||
aria-label="읽기 전용 (향후 확장)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* [섹션 5] 고급 설정 */}
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between py-1 text-left"
|
||||
aria-expanded={advancedOpen}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{advancedOpen ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-sm font-medium">고급 설정</span>
|
||||
{advancedCount > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{advancedCount}개 설정됨
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">기본값</Label>
|
||||
<Input
|
||||
value={column.defaultValue ?? ""}
|
||||
onChange={(e) => onColumnChange("defaultValue", e.target.value)}
|
||||
placeholder="기본값"
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">최대 길이</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={column.maxLength ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
onColumnChange("maxLength", v === "" ? undefined : Number(v));
|
||||
}}
|
||||
placeholder="숫자"
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { MoreHorizontal, Database, Layers, FileStack } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ColumnTypeInfo } from "./types";
|
||||
import { INPUT_TYPE_COLORS, getColumnGroup } from "./types";
|
||||
|
||||
export interface ColumnGridConstraints {
|
||||
primaryKey: { columns: string[] };
|
||||
indexes: Array<{ columns: string[]; isUnique: boolean }>;
|
||||
}
|
||||
|
||||
export interface ColumnGridProps {
|
||||
columns: ColumnTypeInfo[];
|
||||
selectedColumn: string | null;
|
||||
onSelectColumn: (columnName: string) => void;
|
||||
onColumnChange: (columnName: string, field: keyof ColumnTypeInfo, value: unknown) => void;
|
||||
constraints: ColumnGridConstraints;
|
||||
typeFilter?: string | null;
|
||||
getColumnIndexState?: (columnName: string) => { isPk: boolean; hasIndex: boolean };
|
||||
}
|
||||
|
||||
function getIndexState(
|
||||
columnName: string,
|
||||
constraints: ColumnGridConstraints,
|
||||
): { isPk: boolean; hasIndex: boolean } {
|
||||
const isPk = constraints.primaryKey.columns.includes(columnName);
|
||||
const hasIndex = constraints.indexes.some(
|
||||
(idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName,
|
||||
);
|
||||
return { isPk, hasIndex };
|
||||
}
|
||||
|
||||
/** 그룹 헤더 라벨 */
|
||||
const GROUP_LABELS: Record<string, { icon: React.ElementType; label: string }> = {
|
||||
basic: { icon: FileStack, label: "기본 정보" },
|
||||
reference: { icon: Layers, label: "참조 정보" },
|
||||
meta: { icon: Database, label: "메타 정보" },
|
||||
};
|
||||
|
||||
export function ColumnGrid({
|
||||
columns,
|
||||
selectedColumn,
|
||||
onSelectColumn,
|
||||
constraints,
|
||||
typeFilter = null,
|
||||
getColumnIndexState: externalGetIndexState,
|
||||
}: ColumnGridProps) {
|
||||
const getIdxState = useMemo(
|
||||
() => externalGetIndexState ?? ((name: string) => getIndexState(name, constraints)),
|
||||
[constraints, externalGetIndexState],
|
||||
);
|
||||
|
||||
/** typeFilter 적용 후 그룹별로 정렬 */
|
||||
const filteredAndGrouped = useMemo(() => {
|
||||
const filtered =
|
||||
typeFilter != null ? columns.filter((c) => (c.inputType || "text") === typeFilter) : columns;
|
||||
const groups = { basic: [] as ColumnTypeInfo[], reference: [] as ColumnTypeInfo[], meta: [] as ColumnTypeInfo[] };
|
||||
for (const col of filtered) {
|
||||
const group = getColumnGroup(col);
|
||||
groups[group].push(col);
|
||||
}
|
||||
return groups;
|
||||
}, [columns, typeFilter]);
|
||||
|
||||
const totalFiltered =
|
||||
filteredAndGrouped.basic.length + filteredAndGrouped.reference.length + filteredAndGrouped.meta.length;
|
||||
|
||||
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 140px 1fr 100px 160px 40px" }}
|
||||
>
|
||||
<span />
|
||||
<span>라벨 · 컬럼명</span>
|
||||
<span>참조/설정</span>
|
||||
<span>타입</span>
|
||||
<span className="text-center">PK / NN / IDX / UQ</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{totalFiltered === 0 ? (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
{typeFilter ? "해당 타입의 컬럼이 없습니다." : "컬럼이 없습니다."}
|
||||
</div>
|
||||
) : (
|
||||
(["basic", "reference", "meta"] as const).map((groupKey) => {
|
||||
const list = filteredAndGrouped[groupKey];
|
||||
if (list.length === 0) return null;
|
||||
const { icon: Icon, label } = GROUP_LABELS[groupKey];
|
||||
return (
|
||||
<div key={groupKey} className="space-y-1 py-2">
|
||||
<div className="flex items-center gap-2 border-b border-border/60 px-4 pb-1.5">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{list.length}
|
||||
</Badge>
|
||||
</div>
|
||||
{list.map((column) => {
|
||||
const typeConf = INPUT_TYPE_COLORS[column.inputType || "text"] || INPUT_TYPE_COLORS.text;
|
||||
const idxState = getIdxState(column.columnName);
|
||||
const isSelected = selectedColumn === column.columnName;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={column.columnName}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelectColumn(column.columnName)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelectColumn(column.columnName);
|
||||
}
|
||||
}}
|
||||
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]",
|
||||
"bg-card border-transparent hover:border-border hover:shadow-sm",
|
||||
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)} />
|
||||
|
||||
{/* 라벨 + 컬럼명 */}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{column.displayName || column.columnName}
|
||||
</div>
|
||||
<div className="truncate font-mono text-xs text-muted-foreground">
|
||||
{column.columnName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 참조/설정 칩 */}
|
||||
<div className="flex min-w-0 flex-wrap gap-1">
|
||||
{column.inputType === "entity" && column.referenceTable && column.referenceTable !== "none" && (
|
||||
<>
|
||||
<Badge variant="outline" className="text-xs font-normal">
|
||||
{column.referenceTable}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">→</span>
|
||||
<Badge variant="outline" className="text-xs font-normal">
|
||||
{column.referenceColumn || "—"}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{column.inputType === "code" && (
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{column.codeCategory ?? "—"} · {column.defaultValue ?? ""}
|
||||
</span>
|
||||
)}
|
||||
{column.inputType === "numbering" && column.numberingRuleId && (
|
||||
<Badge variant="outline" className="text-xs font-normal">
|
||||
{column.numberingRuleId}
|
||||
</Badge>
|
||||
)}
|
||||
{column.inputType !== "entity" &&
|
||||
column.inputType !== "code" &&
|
||||
column.inputType !== "numbering" &&
|
||||
(column.defaultValue ? (
|
||||
<span className="text-muted-foreground truncate text-xs">{column.defaultValue}</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" />
|
||||
{typeConf.label}
|
||||
</div>
|
||||
|
||||
{/* PK / NN / IDX / UQ (읽기 전용) */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded border px-1.5 py-0.5 text-[10px] font-medium",
|
||||
idxState.isPk
|
||||
? "border-primary/30 bg-primary/10 text-primary"
|
||||
: "border-border bg-muted/50 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
PK
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded border px-1.5 py-0.5 text-[10px] font-medium",
|
||||
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",
|
||||
)}
|
||||
>
|
||||
NN
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded border px-1.5 py-0.5 text-[10px] font-medium",
|
||||
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",
|
||||
)}
|
||||
>
|
||||
IDX
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded border px-1.5 py-0.5 text-[10px] font-medium",
|
||||
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",
|
||||
)}
|
||||
>
|
||||
UQ
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectColumn(column.columnName);
|
||||
}}
|
||||
aria-label="상세 설정"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ColumnTypeInfo } from "./types";
|
||||
import { INPUT_TYPE_COLORS } from "./types";
|
||||
|
||||
export interface TypeOverviewStripProps {
|
||||
columns: ColumnTypeInfo[];
|
||||
activeFilter?: string | null;
|
||||
onFilterChange?: (type: string | null) => void;
|
||||
}
|
||||
|
||||
/** inputType별 카운트 계산 */
|
||||
function countByInputType(columns: ColumnTypeInfo[]): Record<string, number> {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const col of columns) {
|
||||
const t = col.inputType || "text";
|
||||
counts[t] = (counts[t] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** 도넛 차트용 비율 (0~1) 배열 및 라벨 순서 */
|
||||
function getDonutSegments(counts: Record<string, number>, total: number): Array<{ type: string; ratio: number }> {
|
||||
const order = Object.keys(INPUT_TYPE_COLORS);
|
||||
return order
|
||||
.filter((type) => (counts[type] || 0) > 0)
|
||||
.map((type) => ({ type, ratio: (counts[type] || 0) / total }));
|
||||
}
|
||||
|
||||
export function TypeOverviewStrip({
|
||||
columns,
|
||||
activeFilter = null,
|
||||
onFilterChange,
|
||||
}: TypeOverviewStripProps) {
|
||||
const { counts, total, segments } = useMemo(() => {
|
||||
const counts = countByInputType(columns);
|
||||
const total = columns.length || 1;
|
||||
const segments = getDonutSegments(counts, total);
|
||||
return { counts, total, segments };
|
||||
}, [columns]);
|
||||
|
||||
/** stroke-dasharray: 비율만큼 둘레에 할당 (둘레 100 기준) */
|
||||
const circumference = 100;
|
||||
let offset = 0;
|
||||
const segmentPaths = segments.map(({ type, ratio }) => {
|
||||
const length = ratio * circumference;
|
||||
const dashArray = `${length} ${circumference - length}`;
|
||||
const dashOffset = -offset;
|
||||
offset += length;
|
||||
const conf = INPUT_TYPE_COLORS[type] || { color: "text-muted-foreground", bgColor: "bg-muted" };
|
||||
return {
|
||||
type,
|
||||
dashArray,
|
||||
dashOffset,
|
||||
...conf,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-shrink-0 items-center gap-3 border-b bg-muted/30 px-5 py-2.5">
|
||||
{/* SVG 도넛 (원형 stroke) */}
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center">
|
||||
<svg className="h-10 w-10 -rotate-90" viewBox="0 0 36 36">
|
||||
{segmentPaths.map((seg) => (
|
||||
<g key={seg.type} className={cn(seg.color, "opacity-80")}>
|
||||
<circle
|
||||
cx="18"
|
||||
cy="18"
|
||||
r="14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="6"
|
||||
strokeDasharray={seg.dashArray}
|
||||
strokeDashoffset={seg.dashOffset}
|
||||
aria-hidden
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
{segments.length === 0 && (
|
||||
<circle cx="18" cy="18" r="14" fill="none" stroke="currentColor" strokeWidth="6" className="text-muted-foreground/50" />
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 타입 칩 목록 (클릭 시 필터 토글) */}
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
{Object.entries(counts)
|
||||
.sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0))
|
||||
.map(([type]) => {
|
||||
const conf = INPUT_TYPE_COLORS[type] || { color: "text-muted-foreground", bgColor: "bg-muted", label: type };
|
||||
const isActive = activeFilter === null || activeFilter === type;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => onFilterChange?.(activeFilter === type ? null : type)}
|
||||
className={cn(
|
||||
"rounded-md border px-2 py-1 text-xs font-medium transition-colors",
|
||||
conf.bgColor,
|
||||
conf.color,
|
||||
"border-current/20",
|
||||
isActive ? "ring-1 ring-ring" : "opacity-70 hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
{conf.label} {counts[type]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* 테이블 타입 관리 페이지 공통 타입
|
||||
* page.tsx에서 추출한 인터페이스 및 타입별 색상/그룹 유틸
|
||||
*/
|
||||
|
||||
export interface TableInfo {
|
||||
tableName: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
columnCount: number;
|
||||
}
|
||||
|
||||
export interface ColumnTypeInfo {
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
inputType: string;
|
||||
detailSettings: string;
|
||||
description: string;
|
||||
isNullable: string;
|
||||
isUnique: string;
|
||||
defaultValue?: string;
|
||||
maxLength?: number;
|
||||
numericPrecision?: number;
|
||||
numericScale?: number;
|
||||
codeCategory?: string;
|
||||
codeValue?: string;
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
displayColumn?: string;
|
||||
categoryMenus?: number[];
|
||||
hierarchyRole?: "large" | "medium" | "small";
|
||||
numberingRuleId?: string;
|
||||
categoryRef?: string | null;
|
||||
}
|
||||
|
||||
export interface SecondLevelMenu {
|
||||
menuObjid: number;
|
||||
menuName: string;
|
||||
parentMenuName: string;
|
||||
screenCode?: string;
|
||||
}
|
||||
|
||||
/** 컬럼 그룹 분류 */
|
||||
export type ColumnGroup = "basic" | "reference" | "meta";
|
||||
|
||||
/** 타입별 색상 매핑 (다크모드 호환 레이어 사용) */
|
||||
export interface TypeColorConfig {
|
||||
color: string;
|
||||
bgColor: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/** 입력 타입별 색상 맵 - 배경/텍스트/보더는 다크에서 자동 변환 */
|
||||
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: "라디오" },
|
||||
};
|
||||
|
||||
/** 컬럼 그룹 판별 */
|
||||
export function getColumnGroup(col: ColumnTypeInfo): ColumnGroup {
|
||||
const metaCols = ["id", "created_date", "updated_date", "writer", "company_code"];
|
||||
if (metaCols.includes(col.columnName)) return "meta";
|
||||
if (["entity", "code", "category"].includes(col.inputType)) return "reference";
|
||||
return "basic";
|
||||
}
|
||||
Loading…
Reference in New Issue