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:
chpark
2026-05-11 15:58:54 +09:00
parent 575098698c
commit 690b85805c
19 changed files with 3336 additions and 648 deletions
+45 -45
View File
@@ -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>
);
+28 -37
View File
@@ -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>
)}
</>
)}
+29 -38
View File
@@ -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>
)}
</>
)}
+108 -82
View File
@@ -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>
+23 -2
View File
@@ -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"] },
];