ECR 기능/스키마 wace_plm 일치 + 공통코드·테이블타입 화면 정리
- ECR 관리: wace 의 ecrList/Form/Detail JSP 와 동일하게 5개 필터(연도/기종/요청/작성자/상태), 변경전/후 2분할 모달, 작성중(0000100)만 삭제·수정 허용, 컬럼 순서/라벨 wace 일치 - ECR 스키마 wace 풀세트 동기화: ecr_mng 컬럼폭 확장, product_mgmt 16컬럼, part_mng 52컬럼, user_info 22컬럼, comm_code 보강(id/code_cd/ext_val), code_name(varchar) 함수, seq_ecr_no setval(33) 정렬 - wace_plm public.comm_code 733행 시드: src/seed/wace_comm_code.sql 추출 + 부팅 시 자동 적재 (writer='system-seed' placeholder 자동 정리, 무중단 재적재 엔드포인트 /comm-code-seed) - wace_plm 데이터 import 풀스키마: PRODUCT(7→16), PART(6→52), USER_INFO·COMM_CODE 신규 - 공통코드 관리 화면: 제목/설명 축소, 카테고리·코드 카드 → 컴팩트 리스트, 활성 토글 점, 계층 배지 톤다운, hover 시 액션 노출 - 테이블 타입 관리 — 좌측: 한 줄 리스트 + 알파벳 인덱스 sticky 헤더 - 테이블 타입 관리 — 우측: 타입 카드 그리드 → 그룹 셀렉트(기본/참조/자동/첨부/표시변형), 표시이름 제거 + 코멘트(description) Textarea 신설(화면관리에서 기본 라벨로 활용), 시스템 자동 생성 컬럼(id/company_code/writer/created_date/updated_date) 잠금, 표시옵션/고급설정(필수·읽기·기본값·최대길이) 제거 — 화면관리로 이관 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,6 @@
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Edit, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useUpdateCategory } from "@/hooks/queries/useCategories";
|
||||
@@ -18,9 +17,12 @@ interface CategoryItemProps {
|
||||
|
||||
export function CategoryItem({ category, isSelected, onSelect, onEdit, onDelete }: CategoryItemProps) {
|
||||
const updateCategoryMutation = useUpdateCategory();
|
||||
const isActive = category.is_active === "Y";
|
||||
|
||||
// 활성/비활성 토글 핸들러
|
||||
const handleToggleActive = async (checked: boolean) => {
|
||||
const handleToggleActive = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (updateCategoryMutation.isPending) return;
|
||||
try {
|
||||
await updateCategoryMutation.mutateAsync({
|
||||
categoryCode: category.category_code,
|
||||
@@ -29,7 +31,7 @@ export function CategoryItem({ category, isSelected, onSelect, onEdit, onDelete
|
||||
categoryNameEng: category.category_name_eng || "",
|
||||
description: category.description || "",
|
||||
sortOrder: category.sort_order,
|
||||
isActive: checked ? "Y" : "N",
|
||||
isActive: isActive ? "N" : "Y",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -39,52 +41,50 @@ export function CategoryItem({ category, isSelected, onSelect, onEdit, onDelete
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"cursor-pointer rounded-lg border bg-card p-4 shadow-sm transition-all",
|
||||
isSelected
|
||||
? "shadow-md"
|
||||
: "hover:shadow-md",
|
||||
)}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"group flex cursor-pointer items-center justify-between rounded-md border px-2.5 py-2 transition-colors",
|
||||
isSelected
|
||||
? "border-primary/60 bg-primary/5"
|
||||
: "border-border bg-card hover:border-primary/30 hover:bg-muted/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-semibold">{category.category_name}</h4>
|
||||
<Badge
|
||||
variant={category.is_active === "Y" ? "default" : "secondary"}
|
||||
className={cn(
|
||||
"cursor-pointer text-xs transition-colors",
|
||||
updateCategoryMutation.isPending && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!updateCategoryMutation.isPending) {
|
||||
handleToggleActive(category.is_active !== "Y");
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{category.is_active === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{category.category_code}</p>
|
||||
{category.description && <p className="mt-1 text-xs text-muted-foreground">{category.description}</p>}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* 활성 상태 점 (클릭으로 토글) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleActive}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
title={isActive ? "활성 (클릭하여 비활성)" : "비활성 (클릭하여 활성)"}
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 flex-shrink-0 rounded-full transition-colors",
|
||||
isActive ? "bg-emerald-500" : "bg-muted-foreground/40",
|
||||
updateCategoryMutation.isPending && "opacity-50",
|
||||
)}
|
||||
/>
|
||||
<span className="truncate text-[13px] font-medium" title={category.category_name}>
|
||||
{category.category_name}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 font-mono text-[10px] text-muted-foreground">{category.category_code}</p>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
{isSelected && (
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="sm" onClick={onEdit}>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onDelete}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* 액션 — 선택되었거나 hover 시 노출 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-0.5 transition-opacity",
|
||||
isSelected ? "opacity-100" : "opacity-0 group-hover:opacity-100",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onEdit} title="수정">
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive hover:bg-destructive/10" onClick={onDelete} title="삭제">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -93,49 +93,40 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{/* 검색 및 액션 (고정) */}
|
||||
<div className="space-y-3 pb-4">
|
||||
{/* 검색 + 버튼 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="카테고리 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleNewCategory} className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 활성 필터 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="activeOnly"
|
||||
checked={showActiveOnly}
|
||||
onChange={(e) => setShowActiveOnly(e.target.checked)}
|
||||
className="border-input h-4 w-4 rounded"
|
||||
{/* 검색 + 등록 + 활성 토글 — 한 줄로 컴팩트 */}
|
||||
<div className="flex flex-shrink-0 items-center gap-1.5 pb-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="카테고리 검색"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-7 pl-7 text-xs"
|
||||
/>
|
||||
<label htmlFor="activeOnly" className="text-muted-foreground text-sm">
|
||||
활성만 표시
|
||||
</label>
|
||||
</div>
|
||||
<Button onClick={handleNewCategory} size="sm" className="h-7 gap-1 px-2 text-xs">
|
||||
<Plus className="h-3 w-3" /> 등록
|
||||
</Button>
|
||||
</div>
|
||||
<label className="flex flex-shrink-0 items-center gap-1.5 pb-2 text-[11px] text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showActiveOnly}
|
||||
onChange={(e) => setShowActiveOnly(e.target.checked)}
|
||||
className="h-3 w-3 rounded border-input"
|
||||
/>
|
||||
활성만 표시
|
||||
</label>
|
||||
|
||||
{/* 카테고리 목록 (자체 스크롤 + 무한 스크롤) */}
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1" onScroll={handleScroll}>
|
||||
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto pr-1" onScroll={handleScroll}>
|
||||
{isLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<div className="flex h-24 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : categories.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<div className="flex h-24 items-center justify-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{searchTerm ? "검색 결과가 없습니다." : "카테고리가 없습니다."}
|
||||
</p>
|
||||
</div>
|
||||
@@ -154,15 +145,15 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
|
||||
|
||||
{/* 추가 로딩 표시 */}
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<LoadingSpinner size="sm" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">추가 로딩 중...</span>
|
||||
<span className="ml-1.5 text-[11px] text-muted-foreground">로딩 중…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 더 이상 데이터가 없을 때 */}
|
||||
{!hasNextPage && categories.length > 0 && (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">모든 카테고리를 불러왔습니다.</div>
|
||||
<div className="py-2 text-center text-[11px] text-muted-foreground">— 끝 —</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -227,18 +227,18 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
||||
|
||||
if (!categoryCode) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">카테고리를 선택하세요</p>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-xs text-muted-foreground">좌측에서 카테고리를 선택하세요</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-destructive text-sm font-semibold">코드를 불러오는 중 오류가 발생했습니다.</p>
|
||||
<Button variant="outline" onClick={() => window.location.reload()} className="mt-4 h-10 text-sm font-medium">
|
||||
<p className="text-xs font-semibold text-destructive">코드를 불러오는 중 오류가 발생했습니다.</p>
|
||||
<Button variant="outline" onClick={() => window.location.reload()} size="sm" className="mt-2 h-7 text-xs">
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
@@ -248,49 +248,40 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{/* 검색 및 액션 (고정) */}
|
||||
<div className="space-y-3 pb-4">
|
||||
{/* 검색 + 버튼 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-full sm:w-[300px]">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="코드 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleNewCode} className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
{/* 검색 + 등록 + 활성 토글 — 한 줄로 컴팩트 */}
|
||||
<div className="flex flex-shrink-0 items-center gap-1.5 pb-2">
|
||||
<div className="relative w-full max-w-[280px]">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="코드 검색"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-7 pl-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 활성 필터 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={handleNewCode} size="sm" className="h-7 gap-1 px-2 text-xs">
|
||||
<Plus className="h-3 w-3" /> 등록
|
||||
</Button>
|
||||
<label className="ml-auto flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="activeOnlyCodes"
|
||||
checked={showActiveOnly}
|
||||
onChange={(e) => setShowActiveOnly(e.target.checked)}
|
||||
className="border-input h-4 w-4 rounded"
|
||||
className="h-3 w-3 rounded border-input"
|
||||
/>
|
||||
<label htmlFor="activeOnlyCodes" className="text-muted-foreground text-sm">
|
||||
활성만 표시
|
||||
</label>
|
||||
</div>
|
||||
활성만 표시
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 코드 목록 (자체 스크롤 + 무한 스크롤) */}
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1" onScroll={handleScroll}>
|
||||
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1" onScroll={handleScroll}>
|
||||
{isLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<div className="flex h-24 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : visibleCodes.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<div className="flex h-24 items-center justify-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{codes.length === 0 ? "코드가 없습니다." : "검색 결과가 없습니다."}
|
||||
</p>
|
||||
</div>
|
||||
@@ -360,15 +351,15 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
||||
|
||||
{/* 무한 스크롤 로딩 인디케이터 */}
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<LoadingSpinner size="sm" />
|
||||
<span className="text-muted-foreground ml-2 text-sm">코드를 더 불러오는 중...</span>
|
||||
<span className="ml-1.5 text-[11px] text-muted-foreground">로딩 중…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 모든 코드 로드 완료 메시지 */}
|
||||
{!hasNextPage && codes.length > 0 && (
|
||||
<div className="text-muted-foreground py-4 text-center text-sm">모든 코드를 불러왔습니다.</div>
|
||||
<div className="py-2 text-center text-[11px] text-muted-foreground">— 끝 —</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -74,18 +74,19 @@ export function SortableCodeItem({
|
||||
|
||||
// 계층구조 깊이에 따른 들여쓰기
|
||||
const depth = code.depth || 1;
|
||||
const indentLevel = (depth - 1) * 28; // 28px per level
|
||||
const indentLevel = (depth - 1) * 16; // 16px per level (was 28)
|
||||
const hasParent = !!(code.parentCodeValue || code.parent_code_value);
|
||||
const isActive = code.isActive === "Y" || code.is_active === "Y";
|
||||
|
||||
return (
|
||||
<div className="flex items-stretch">
|
||||
{/* 계층구조 들여쓰기 영역 */}
|
||||
{depth > 1 && (
|
||||
<div
|
||||
className="flex items-center justify-end pr-2"
|
||||
className="flex items-center justify-end pr-1"
|
||||
style={{ width: `${indentLevel}px`, minWidth: `${indentLevel}px` }}
|
||||
>
|
||||
<CornerDownRight className="text-muted-foreground/50 h-4 w-4" />
|
||||
<CornerDownRight className="h-3 w-3 text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -95,136 +96,161 @@ export function SortableCodeItem({
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={cn(
|
||||
"group bg-card flex-1 cursor-grab rounded-lg border p-4 shadow-sm transition-all hover:shadow-md",
|
||||
isDragging && "cursor-grabbing opacity-50",
|
||||
depth === 1 && "border-l-primary border-l-4",
|
||||
depth === 2 && "border-l-4 border-l-blue-400",
|
||||
depth === 3 && "border-l-4 border-l-green-400",
|
||||
"group flex-1 cursor-grab rounded-md border bg-card px-2.5 py-1.5 transition-colors hover:bg-muted/40",
|
||||
isDragging && "cursor-grabbing opacity-50 shadow-md",
|
||||
depth === 1 && "border-l-2 border-l-primary",
|
||||
depth === 2 && "border-l-2 border-l-blue-400",
|
||||
depth === 3 && "border-l-2 border-l-emerald-400",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* 접기/펼치기 버튼 (자식이 있을 때만 표시) */}
|
||||
{hasChildren && onToggleExpand && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggleExpand();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="text-muted-foreground hover:text-foreground -ml-1 flex h-5 w-5 items-center justify-center rounded transition-colors hover:bg-muted"
|
||||
title={isExpanded ? "접기" : "펼치기"}
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
)}
|
||||
<h4 className="text-sm font-semibold">{code.codeName || code.code_name}</h4>
|
||||
{/* 접힌 상태에서 자식 개수 표시 */}
|
||||
{hasChildren && !isExpanded && <span className="text-muted-foreground text-[10px]">({childCount})</span>}
|
||||
{/* 깊이 표시 배지 */}
|
||||
{depth === 1 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-primary/30 bg-primary/10 text-primary px-1.5 py-0 text-[10px]"
|
||||
>
|
||||
대분류
|
||||
</Badge>
|
||||
)}
|
||||
{depth === 2 && (
|
||||
<Badge variant="outline" className="bg-primary/10 px-1.5 py-0 text-[10px] text-primary">
|
||||
중분류
|
||||
</Badge>
|
||||
)}
|
||||
{depth === 3 && (
|
||||
<Badge variant="outline" className="bg-emerald-50 px-1.5 py-0 text-[10px] text-emerald-600">
|
||||
소분류
|
||||
</Badge>
|
||||
)}
|
||||
{depth > 3 && (
|
||||
<Badge variant="outline" className="bg-muted px-1.5 py-0 text-[10px]">
|
||||
{depth}단계
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant={code.isActive === "Y" || code.is_active === "Y" ? "default" : "secondary"}
|
||||
className={cn(
|
||||
"cursor-pointer text-xs transition-colors",
|
||||
updateCodeMutation.isPending && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
{/* 접기/펼치기 버튼 */}
|
||||
{hasChildren && onToggleExpand ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!updateCodeMutation.isPending) {
|
||||
const isActive = code.isActive === "Y" || code.is_active === "Y";
|
||||
handleToggleActive(!isActive);
|
||||
}
|
||||
onToggleExpand();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title={isExpanded ? "접기" : "펼치기"}
|
||||
>
|
||||
{code.isActive === "Y" || code.is_active === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">{code.codeValue || code.code_value}</p>
|
||||
{/* 부모 코드 표시 */}
|
||||
{hasParent && (
|
||||
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
||||
상위: {code.parentCodeValue || code.parent_code_value}
|
||||
</p>
|
||||
{isExpanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
</button>
|
||||
) : (
|
||||
<div className="h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
{/* 활성 토글 (점) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!updateCodeMutation.isPending) handleToggleActive(!isActive);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 flex-shrink-0 rounded-full transition-colors",
|
||||
isActive ? "bg-emerald-500" : "bg-muted-foreground/40",
|
||||
updateCodeMutation.isPending && "opacity-50",
|
||||
)}
|
||||
title={isActive ? "활성 (클릭하여 비활성)" : "비활성 (클릭하여 활성)"}
|
||||
/>
|
||||
|
||||
{/* 코드명 */}
|
||||
<span className="truncate text-[13px] font-medium" title={code.codeName || code.code_name}>
|
||||
{code.codeName || code.code_name}
|
||||
</span>
|
||||
|
||||
{/* 코드값 (mono) */}
|
||||
<span className="hidden flex-shrink-0 font-mono text-[10px] text-muted-foreground sm:inline">
|
||||
{code.codeValue || code.code_value}
|
||||
</span>
|
||||
|
||||
{/* 자식 개수(접힌 경우) */}
|
||||
{hasChildren && !isExpanded && (
|
||||
<span className="flex-shrink-0 text-[10px] text-muted-foreground">({childCount})</span>
|
||||
)}
|
||||
|
||||
{/* 깊이 배지 — 1단계만 표시 (덜 어수선) */}
|
||||
{depth === 1 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-1 flex-shrink-0 border-primary/30 bg-primary/5 px-1 py-0 text-[9px] font-medium text-primary"
|
||||
>
|
||||
대분류
|
||||
</Badge>
|
||||
)}
|
||||
{depth === 2 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-1 flex-shrink-0 border-blue-300 bg-blue-50 px-1 py-0 text-[9px] font-medium text-blue-700 dark:bg-blue-950/20"
|
||||
>
|
||||
중분류
|
||||
</Badge>
|
||||
)}
|
||||
{depth === 3 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-1 flex-shrink-0 border-emerald-300 bg-emerald-50 px-1 py-0 text-[9px] font-medium text-emerald-700 dark:bg-emerald-950/20"
|
||||
>
|
||||
소분류
|
||||
</Badge>
|
||||
)}
|
||||
{depth > 3 && (
|
||||
<Badge variant="outline" className="ml-1 flex-shrink-0 px-1 py-0 text-[9px]">
|
||||
L{depth}
|
||||
</Badge>
|
||||
)}
|
||||
{code.description && <p className="text-muted-foreground mt-1 text-xs">{code.description}</p>}
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
{/* 액션 — hover 시 노출 */}
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
className="flex flex-shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 하위 코드 추가 버튼 (최대 깊이 미만일 때만 표시) */}
|
||||
{depth < maxDepth && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-primary hover:bg-primary/10"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onAddChild();
|
||||
}}
|
||||
title="하위 코드 추가"
|
||||
className="text-primary hover:bg-primary/10 hover:text-primary"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
title="수정"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive hover:bg-destructive/10"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 부모 표시(있을 때만, 한 줄) */}
|
||||
{hasParent && (
|
||||
<p className="ml-7 mt-0.5 truncate font-mono text-[10px] text-muted-foreground">
|
||||
↳ {code.parentCodeValue || code.parent_code_value}
|
||||
</p>
|
||||
)}
|
||||
{code.description && (
|
||||
<p className="ml-7 mt-0.5 truncate text-[11px] text-muted-foreground" title={code.description}>
|
||||
{code.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { X, Type, Settings2, Tag, ToggleLeft, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { X, Settings2, MessageSquareText, Lock } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
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 { INPUT_TYPE_COLORS, INPUT_TYPE_GROUPS, isSystemColumn } from "./types";
|
||||
import type { ReferenceTableColumn } from "@/lib/api/entityJoin";
|
||||
|
||||
export interface ColumnDetailPanelProps {
|
||||
@@ -49,7 +49,6 @@ export function ColumnDetailPanel({
|
||||
codeCategoryOptions = [],
|
||||
referenceTableOptions = [],
|
||||
}: ColumnDetailPanelProps) {
|
||||
const [advancedOpen, setAdvancedOpen] = React.useState(false);
|
||||
const [entityTableOpen, setEntityTableOpen] = React.useState(false);
|
||||
const [entityColumnOpen, setEntityColumnOpen] = React.useState(false);
|
||||
|
||||
@@ -64,16 +63,11 @@ export function ColumnDetailPanel({
|
||||
}
|
||||
}, [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 isSystem = isSystemColumn(column.columnName);
|
||||
|
||||
const refTableOpts = useMemo(() => {
|
||||
const hasKorean = (s: string) => /[가-힣]/.test(s);
|
||||
const raw = referenceTableOptions.length
|
||||
@@ -113,11 +107,7 @@ export function ColumnDetailPanel({
|
||||
{typeConf.label}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-sm font-medium">
|
||||
{column.displayName && column.displayName !== column.columnName
|
||||
? `${column.displayName} (${column.columnName})`
|
||||
: column.columnName}
|
||||
</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" />
|
||||
@@ -125,50 +115,70 @@ export function ColumnDetailPanel({
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{/* [섹션 1] 데이터 타입 선택 */}
|
||||
{/* 시스템 자동 생성 컬럼 안내 */}
|
||||
{isSystem && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 dark:border-amber-900/40 dark:bg-amber-950/20">
|
||||
<Lock className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<div className="text-[11px] leading-relaxed text-amber-700 dark:text-amber-300">
|
||||
<p className="font-semibold">시스템 자동 생성 컬럼</p>
|
||||
<p className="text-[10px] text-amber-600/80 dark:text-amber-400/70">
|
||||
테이블 생성 시 자동 부여되며 타입/속성을 수정할 수 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* [섹션 1] 데이터 타입 선택 — 셀렉트 박스 */}
|
||||
<section className="space-y-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">이 필드는 어떤 유형인가요?</p>
|
||||
<p className="text-xs text-muted-foreground">유형에 따라 입력 방식이 바뀌어요</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{Object.entries(INPUT_TYPE_COLORS).map(([type, conf]) => {
|
||||
const isSelected = (column.inputType || "text") === type;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => onColumnChange("inputType", type)}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 rounded-lg border px-1.5 py-2.5 text-center transition-all",
|
||||
isSelected
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary/30"
|
||||
: "border-border hover:border-primary/30 hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"text-base font-bold leading-none",
|
||||
isSelected ? "text-primary" : conf.color,
|
||||
)}>
|
||||
{conf.iconChar}
|
||||
</span>
|
||||
<span className={cn(
|
||||
"text-[16px] font-semibold leading-tight",
|
||||
isSelected ? "text-primary" : "text-foreground",
|
||||
)}>
|
||||
{conf.label}
|
||||
</span>
|
||||
<span className="text-[12px] leading-tight text-muted-foreground">
|
||||
{conf.desc}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
저장 타입 단위. UI 변형(text/textarea 등)은 화면관리에서 선택합니다.
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={column.inputType || "text"}
|
||||
onValueChange={(v) => onColumnChange("inputType", v)}
|
||||
disabled={isSystem}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{INPUT_TYPE_GROUPS.map((group, gi) => (
|
||||
<SelectGroup key={group.groupLabel}>
|
||||
<SelectLabel className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
{group.groupLabel}
|
||||
</SelectLabel>
|
||||
{group.types.map((type) => {
|
||||
const conf = INPUT_TYPE_COLORS[type];
|
||||
if (!conf) return null;
|
||||
return (
|
||||
<SelectItem key={type} value={type} className="text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-4 w-4 items-center justify-center rounded text-[10px] font-bold",
|
||||
conf.bgColor,
|
||||
conf.color,
|
||||
)}
|
||||
>
|
||||
{conf.iconChar}
|
||||
</span>
|
||||
<span>{conf.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground">· {conf.desc}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</section>
|
||||
|
||||
{/* [섹션 2] 타입별 상세 설정 */}
|
||||
{column.inputType === "entity" && (
|
||||
{/* [섹션 2] 타입별 상세 설정 — 시스템 컬럼은 표시하지 않음 */}
|
||||
{!isSystem && column.inputType === "entity" && (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
||||
@@ -328,7 +338,7 @@ export function ColumnDetailPanel({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{column.inputType === "code" && (
|
||||
{!isSystem && column.inputType === "code" && (
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
||||
@@ -345,11 +355,18 @@ export function ColumnDetailPanel({
|
||||
<SelectValue placeholder="코드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[{ value: "none", label: "선택 안함" }, ...codeCategoryOptions].map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{/* "none" 옵션을 항상 첫번째에 두되, codeCategoryOptions 가 이미 "none" 을 포함하면
|
||||
그 라벨을 사용 (key 중복 방지). */}
|
||||
{(() => {
|
||||
const existingNone = codeCategoryOptions.find((o) => o.value === "none");
|
||||
const rest = codeCategoryOptions.filter((o) => o.value !== "none");
|
||||
const noneOpt = existingNone ?? { value: "none", label: "선택 안함" };
|
||||
return [noneOpt, ...rest].map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
));
|
||||
})()}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -378,7 +395,7 @@ export function ColumnDetailPanel({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{column.inputType === "category" && (
|
||||
{!isSystem && column.inputType === "category" && (
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
||||
@@ -396,7 +413,7 @@ export function ColumnDetailPanel({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{column.inputType === "numbering" && (
|
||||
{!isSystem && column.inputType === "numbering" && (
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
||||
@@ -409,103 +426,25 @@ export function ColumnDetailPanel({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* [섹션 3] 표시 이름 */}
|
||||
{/* [섹션 3] 코멘트 — PostgreSQL 컬럼 COMMENT 와 동기화.
|
||||
화면관리에서 이 값을 기본 라벨로 가져다 쓰고, 거기서 화면별로 재정의 가능. */}
|
||||
<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>
|
||||
<MessageSquareText 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"
|
||||
<Textarea
|
||||
value={column.description ?? ""}
|
||||
onChange={(e) => onColumnChange("description", e.target.value)}
|
||||
placeholder="이 컬럼이 어떤 값인지 한 줄로 설명 (예: 거래처 세금유형 코드)"
|
||||
className="min-h-[72px] text-xs"
|
||||
rows={3}
|
||||
disabled={isSystem}
|
||||
/>
|
||||
<p className="text-[10px] leading-snug text-muted-foreground">
|
||||
화면관리에서 이 값을 기본 라벨로 사용합니다. 화면별 라벨은 거기서 별도 수정 가능.
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { MoreHorizontal, Database, Layers, FileStack } from "lucide-react";
|
||||
import { MoreHorizontal, Database, Layers, FileStack, Lock } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ColumnTypeInfo, TableInfo } from "./types";
|
||||
import { INPUT_TYPE_COLORS, getColumnGroup } from "./types";
|
||||
import { INPUT_TYPE_COLORS, getColumnGroup, isSystemColumn } from "./types";
|
||||
import type { ReferenceTableColumn } from "@/lib/api/entityJoin";
|
||||
|
||||
export interface ColumnGridConstraints {
|
||||
@@ -124,6 +124,7 @@ export function ColumnGrid({
|
||||
const typeConf = INPUT_TYPE_COLORS[column.inputType || "text"] || INPUT_TYPE_COLORS.text;
|
||||
const idxState = getIdxState(column.columnName);
|
||||
const isSelected = selectedColumn === column.columnName;
|
||||
const isLocked = isSystemColumn(column.columnName);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -141,17 +142,24 @@ export function ColumnGrid({
|
||||
"group grid h-9 cursor-pointer items-center gap-2 px-3 transition-colors",
|
||||
"hover:bg-muted/40",
|
||||
isSelected && "bg-primary/5 ring-1 ring-inset ring-primary/30",
|
||||
isLocked && "bg-muted/20",
|
||||
)}
|
||||
style={{ gridTemplateColumns: GRID_TEMPLATE }}
|
||||
>
|
||||
{/* 3px 색상바 (타입별 진한 색) */}
|
||||
<div className={cn("h-5 w-[3px] rounded-full", typeConf.barColor)} />
|
||||
|
||||
{/* 라벨 + 컬럼명 — 한글 라벨이 우선, 영문명은 옆에 모노폰트 */}
|
||||
{/* 라벨 + 컬럼명 — 시스템 컬럼이면 자물쇠 아이콘 표시 */}
|
||||
<div className="flex min-w-0 items-baseline gap-1.5">
|
||||
{isLocked && (
|
||||
<Lock
|
||||
className="h-2.5 w-2.5 flex-shrink-0 self-center text-muted-foreground/70"
|
||||
aria-label="시스템 자동 생성 컬럼"
|
||||
/>
|
||||
)}
|
||||
{column.displayName && column.displayName !== column.columnName ? (
|
||||
<>
|
||||
<span className="truncate text-[12px] font-medium leading-tight">
|
||||
<span className={cn("truncate text-[12px] font-medium leading-tight", isLocked && "text-muted-foreground")}>
|
||||
{column.displayName}
|
||||
</span>
|
||||
<span className="truncate font-mono text-[10px] text-muted-foreground leading-tight">
|
||||
@@ -159,7 +167,7 @@ export function ColumnGrid({
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="truncate font-mono text-[12px] font-medium leading-tight">
|
||||
<span className={cn("truncate font-mono text-[12px] font-medium leading-tight", isLocked && "text-muted-foreground")}>
|
||||
{column.columnName}
|
||||
</span>
|
||||
)}
|
||||
@@ -225,69 +233,77 @@ export function ColumnGrid({
|
||||
{typeConf.label}
|
||||
</div>
|
||||
|
||||
{/* PK / NN / IDX / UQ (클릭 토글) — 한 줄 nowrap */}
|
||||
{/* PK / NN / IDX / UQ (클릭 토글) — 시스템 컬럼은 잠금(읽기 전용) */}
|
||||
<div className="flex flex-nowrap items-center justify-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLocked}
|
||||
className={cn(
|
||||
"h-5 w-7 rounded border text-[9px] font-bold transition-colors",
|
||||
"h-5 w-7 rounded border text-[9px] font-bold transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||
idxState.isPk
|
||||
? "border-blue-200 bg-blue-50 text-blue-600"
|
||||
: "border-border/50 text-muted-foreground/40 hover:border-blue-200 hover:text-blue-400",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isLocked) return;
|
||||
onPkToggle?.(column.columnName, !idxState.isPk);
|
||||
}}
|
||||
title="Primary Key 토글"
|
||||
title={isLocked ? "시스템 컬럼 — 수정 불가" : "Primary Key 토글"}
|
||||
>
|
||||
PK
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLocked}
|
||||
className={cn(
|
||||
"h-5 w-7 rounded border text-[9px] font-bold transition-colors",
|
||||
"h-5 w-7 rounded border text-[9px] font-bold transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||
column.isNullable === "NO"
|
||||
? "border-amber-200 bg-amber-50 text-amber-600"
|
||||
: "border-border/50 text-muted-foreground/40 hover:border-amber-200 hover:text-amber-400",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isLocked) return;
|
||||
onColumnChange(column.columnName, "isNullable", column.isNullable === "NO" ? "YES" : "NO");
|
||||
}}
|
||||
title="Not Null 토글"
|
||||
title={isLocked ? "시스템 컬럼 — 수정 불가" : "Not Null 토글"}
|
||||
>
|
||||
NN
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLocked}
|
||||
className={cn(
|
||||
"h-5 w-7 rounded border text-[9px] font-bold transition-colors",
|
||||
"h-5 w-7 rounded border text-[9px] font-bold transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||
idxState.hasIndex
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-600"
|
||||
: "border-border/50 text-muted-foreground/40 hover:border-emerald-200 hover:text-emerald-400",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isLocked) return;
|
||||
onIndexToggle?.(column.columnName, !idxState.hasIndex);
|
||||
}}
|
||||
title="Index 토글"
|
||||
title={isLocked ? "시스템 컬럼 — 수정 불가" : "Index 토글"}
|
||||
>
|
||||
IDX
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLocked}
|
||||
className={cn(
|
||||
"h-5 w-7 rounded border text-[9px] font-bold transition-colors",
|
||||
"h-5 w-7 rounded border text-[9px] font-bold transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||
column.isUnique === "YES"
|
||||
? "border-violet-200 bg-violet-50 text-violet-600"
|
||||
: "border-border/50 text-muted-foreground/40 hover:border-violet-200 hover:text-violet-400",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isLocked) return;
|
||||
onColumnChange(column.columnName, "isUnique", column.isUnique === "YES" ? "NO" : "YES");
|
||||
}}
|
||||
title="Unique 토글"
|
||||
title={isLocked ? "시스템 컬럼 — 수정 불가" : "Unique 토글"}
|
||||
>
|
||||
UQ
|
||||
</button>
|
||||
|
||||
@@ -70,10 +70,31 @@ export const INPUT_TYPE_COLORS: Record<string, TypeColorConfig> = {
|
||||
image: { color: "text-sky-600", bgColor: "bg-sky-50", barColor: "bg-sky-500", label: "이미지", desc: "이미지 표시", iconChar: "🖼" },
|
||||
};
|
||||
|
||||
/** 시스템 자동 생성 컬럼 — 테이블 생성 시 일괄 부여, 사용자 편집 금지 */
|
||||
export const SYSTEM_COLUMNS = new Set<string>([
|
||||
"id",
|
||||
"created_date",
|
||||
"updated_date",
|
||||
"writer",
|
||||
"company_code",
|
||||
]);
|
||||
|
||||
export function isSystemColumn(columnName: string): boolean {
|
||||
return SYSTEM_COLUMNS.has(columnName);
|
||||
}
|
||||
|
||||
/** 컬럼 그룹 판별 */
|
||||
export function getColumnGroup(col: ColumnTypeInfo): ColumnGroup {
|
||||
const metaCols = ["id", "created_date", "updated_date", "writer", "company_code"];
|
||||
if (metaCols.includes(col.columnName)) return "meta";
|
||||
if (isSystemColumn(col.columnName)) return "meta";
|
||||
if (["entity", "code", "category"].includes(col.inputType)) return "reference";
|
||||
return "basic";
|
||||
}
|
||||
|
||||
/** 타입 선택 셀렉트박스용 그룹 정의 (저장계층 타입 위주, UI 표시 변형은 화면관리에서) */
|
||||
export const INPUT_TYPE_GROUPS: Array<{ groupLabel: string; types: string[] }> = [
|
||||
{ groupLabel: "기본", types: ["text", "number", "date", "checkbox"] },
|
||||
{ groupLabel: "참조", types: ["code", "entity", "category"] },
|
||||
{ groupLabel: "자동", types: ["numbering"] },
|
||||
{ groupLabel: "첨부", types: ["file", "image"] },
|
||||
{ groupLabel: "표시 변형", types: ["textarea", "select", "radio"] },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user