Files
vexplor/frontend/components/numbering-rule/AutoConfigPanel.tsx
kjs 4d6783e508 feat: Implement automatic serial number generation and reference handling in mold management
- Enhanced the `createMoldSerial` function to automatically generate serial numbers based on defined numbering rules when the serial number is not provided.
- Integrated error handling for the automatic numbering process, ensuring robust logging for success and failure cases.
- Updated the `NumberingRuleService` to support reference column handling, allowing for dynamic prefix generation based on related data.
- Modified the frontend components to accommodate new reference configurations, improving user experience in managing numbering rules.

These changes significantly enhance the mold management functionality by automating serial number generation and improving the flexibility of numbering rules.
2026-03-09 15:34:31 +09:00

1196 lines
42 KiB
TypeScript

"use client";
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { CodePartType, DATE_FORMAT_OPTIONS } from "@/types/numbering-rule";
import { tableManagementApi } from "@/lib/api/tableManagement";
interface AutoConfigPanelProps {
partType: CodePartType;
config?: any;
onChange: (config: any) => void;
isPreview?: boolean;
tableName?: string;
}
interface TableInfo {
tableName: string;
displayName: string;
}
interface ColumnInfo {
columnName: string;
displayName: string;
dataType: string;
inputType?: string;
}
export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
partType,
config = {},
onChange,
isPreview = false,
tableName,
}) => {
// 1. 순번 (자동 증가)
if (partType === "sequence") {
return (
<div className="space-y-3 sm:space-y-4">
<div>
<Label className="text-xs font-medium sm:text-sm"> 릿</Label>
<Input
type="number"
min={1}
max={10}
value={config.sequenceLength || 3}
onChange={(e) =>
onChange({ ...config, sequenceLength: parseInt(e.target.value) || 3 })
}
disabled={isPreview}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
: 3 001, 4 0001
</p>
</div>
<div>
<Label className="text-xs font-medium sm:text-sm"> </Label>
<Input
type="number"
min={1}
value={config.startFrom || 1}
onChange={(e) =>
onChange({ ...config, startFrom: parseInt(e.target.value) || 1 })
}
disabled={isPreview}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
</p>
</div>
</div>
);
}
// 2. 숫자 (고정 자릿수)
if (partType === "number") {
return (
<div className="space-y-3 sm:space-y-4">
<div>
<Label className="text-xs font-medium sm:text-sm"> 릿</Label>
<Input
type="number"
min={1}
max={10}
value={config.numberLength || 4}
onChange={(e) =>
onChange({ ...config, numberLength: parseInt(e.target.value) || 4 })
}
disabled={isPreview}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
: 4 0001, 5 00001
</p>
</div>
<div>
<Label className="text-xs font-medium sm:text-sm"> </Label>
<Input
type="number"
min={0}
value={config.numberValue || 0}
onChange={(e) =>
onChange({ ...config, numberValue: parseInt(e.target.value) || 0 })
}
disabled={isPreview}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
</p>
</div>
</div>
);
}
// 3. 날짜
if (partType === "date") {
return (
<DateConfigPanel
config={config}
onChange={onChange}
isPreview={isPreview}
/>
);
}
// 4. 문자
if (partType === "text") {
return (
<div>
<Label className="text-xs font-medium sm:text-sm"> </Label>
<Input
value={config.textValue || ""}
onChange={(e) => onChange({ ...config, textValue: e.target.value })}
placeholder="예: PRJ, CODE, PROD"
disabled={isPreview}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
</p>
</div>
);
}
// 5. 카테고리
if (partType === "category") {
return (
<CategoryConfigPanel
config={config}
onChange={onChange}
isPreview={isPreview}
/>
);
}
// 6. 참조 (마스터-디테일 분번)
if (partType === "reference") {
return (
<ReferenceConfigSection
config={config}
onChange={onChange}
isPreview={isPreview}
tableName={tableName}
/>
);
}
return null;
};
/**
* 날짜 타입 전용 설정 패널
* - 날짜 형식 선택
* - 컬럼 값 기준 생성 옵션
*/
interface DateConfigPanelProps {
config?: any;
onChange: (config: any) => void;
isPreview?: boolean;
}
const DateConfigPanel: React.FC<DateConfigPanelProps> = ({
config = {},
onChange,
isPreview = false,
}) => {
// 테이블 목록
const [tables, setTables] = useState<TableInfo[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
// 컬럼 목록
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
const [columnComboboxOpen, setColumnComboboxOpen] = useState(false);
// 체크박스 상태
const useColumnValue = config.useColumnValue || false;
const sourceTableName = config.sourceTableName || "";
const sourceColumnName = config.sourceColumnName || "";
// 테이블 목록 로드
useEffect(() => {
if (useColumnValue && tables.length === 0) {
loadTables();
}
}, [useColumnValue]);
// 테이블 변경 시 컬럼 로드
useEffect(() => {
if (sourceTableName) {
loadColumns(sourceTableName);
} else {
setColumns([]);
}
}, [sourceTableName]);
const loadTables = async () => {
setLoadingTables(true);
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
const tableList = response.data.map((t: any) => ({
tableName: t.tableName || t.table_name,
displayName: t.displayName || t.table_label || t.tableName || t.table_name,
}));
setTables(tableList);
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
} finally {
setLoadingTables(false);
}
};
const loadColumns = async (tableName: string) => {
setLoadingColumns(true);
try {
const response = await tableManagementApi.getColumnList(tableName);
if (response.success && response.data) {
const rawColumns = response.data?.columns || response.data;
// 날짜 타입 컬럼만 필터링
const dateColumns = (rawColumns as any[]).filter((col: any) => {
const inputType = col.inputType || col.input_type || "";
const dataType = (col.dataType || col.data_type || "").toLowerCase();
return (
inputType === "date" ||
inputType === "datetime" ||
dataType.includes("date") ||
dataType.includes("timestamp")
);
});
setColumns(
dateColumns.map((col: any) => ({
columnName: col.columnName || col.column_name,
displayName: col.displayName || col.column_label || col.columnName || col.column_name,
dataType: col.dataType || col.data_type || "",
inputType: col.inputType || col.input_type || "",
}))
);
}
} catch (error) {
console.error("컬럼 목록 로드 실패:", error);
} finally {
setLoadingColumns(false);
}
};
// 선택된 테이블/컬럼 라벨
const selectedTableLabel = useMemo(() => {
const found = tables.find((t) => t.tableName === sourceTableName);
return found ? `${found.displayName} (${found.tableName})` : "";
}, [tables, sourceTableName]);
const selectedColumnLabel = useMemo(() => {
const found = columns.find((c) => c.columnName === sourceColumnName);
return found ? `${found.displayName} (${found.columnName})` : "";
}, [columns, sourceColumnName]);
return (
<div className="space-y-3 sm:space-y-4">
{/* 날짜 형식 선택 */}
<div>
<Label className="text-xs font-medium sm:text-sm"> </Label>
<Select
value={config.dateFormat || "YYYYMMDD"}
onValueChange={(value) => onChange({ ...config, dateFormat: value })}
disabled={isPreview}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATE_FORMAT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value} className="text-xs sm:text-sm">
{option.label} ({option.example})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
{useColumnValue
? "선택한 컬럼의 날짜 값이 이 형식으로 변환됩니다"
: "현재 날짜가 자동으로 입력됩니다"}
</p>
</div>
{/* 컬럼 값 기준 생성 체크박스 */}
<div className="flex items-start gap-2">
<Checkbox
id="useColumnValue"
checked={useColumnValue}
onCheckedChange={(checked) => {
onChange({
...config,
useColumnValue: checked,
// 체크 해제 시 테이블/컬럼 초기화
...(checked ? {} : { sourceTableName: "", sourceColumnName: "" }),
});
}}
disabled={isPreview}
className="mt-0.5"
/>
<div className="flex-1">
<Label
htmlFor="useColumnValue"
className="cursor-pointer text-xs font-medium sm:text-sm"
>
</Label>
<p className="text-[10px] text-muted-foreground sm:text-xs">
</p>
</div>
</div>
{/* 테이블 선택 (체크 시 표시) */}
{useColumnValue && (
<>
<div>
<Label className="text-xs font-medium sm:text-sm"></Label>
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableComboboxOpen}
disabled={isPreview || loadingTables}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
{loadingTables
? "로딩 중..."
: sourceTableName
? selectedTableLabel
: "테이블 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm">
</CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.displayName} ${table.tableName}`}
onSelect={() => {
onChange({
...config,
sourceTableName: table.tableName,
sourceColumnName: "", // 테이블 변경 시 컬럼 초기화
});
setTableComboboxOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
sourceTableName === table.tableName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.displayName}</span>
<span className="text-[10px] text-gray-500">{table.tableName}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 컬럼 선택 */}
<div>
<Label className="text-xs font-medium sm:text-sm"> </Label>
<Popover open={columnComboboxOpen} onOpenChange={setColumnComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={columnComboboxOpen}
disabled={isPreview || loadingColumns || !sourceTableName}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
{loadingColumns
? "로딩 중..."
: !sourceTableName
? "테이블을 먼저 선택하세요"
: sourceColumnName
? selectedColumnLabel
: columns.length === 0
? "날짜 컬럼이 없습니다"
: "컬럼 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm">
</CommandEmpty>
<CommandGroup>
{columns.map((column) => (
<CommandItem
key={column.columnName}
value={`${column.displayName} ${column.columnName}`}
onSelect={() => {
onChange({ ...config, sourceColumnName: column.columnName });
setColumnComboboxOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
sourceColumnName === column.columnName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{column.displayName}</span>
<span className="text-[10px] text-gray-500">
{column.columnName} ({column.inputType || column.dataType})
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{sourceTableName && columns.length === 0 && !loadingColumns && (
<p className="mt-1 text-[10px] text-amber-600 sm:text-xs">
</p>
)}
</div>
</>
)}
</div>
);
};
/**
* 카테고리 타입 전용 설정 패널
* - 카테고리 선택 (테이블.컬럼)
* - 카테고리 값별 형식 매핑
*/
import { CategoryFormatMapping } from "@/types/numbering-rule";
import { Plus, Trash2, FolderTree } from "lucide-react";
import { getCategoryTree, getAllCategoryKeys } from "@/lib/api/categoryTree";
interface CategoryValueNode {
valueId: number;
valueCode: string;
valueLabel: string;
depth: number;
path: string;
parentValueId: number | null;
children?: CategoryValueNode[];
}
interface CategoryConfigPanelProps {
config?: {
categoryKey?: string;
categoryMappings?: CategoryFormatMapping[];
};
onChange: (config: any) => void;
isPreview?: boolean;
}
const CategoryConfigPanel: React.FC<CategoryConfigPanelProps> = ({
config = {},
onChange,
isPreview = false,
}) => {
// 카테고리 옵션 (테이블.컬럼 + 라벨)
const [categoryOptions, setCategoryOptions] = useState<{
tableName: string;
columnName: string;
displayName: string;
displayLabel: string; // 라벨 (테이블라벨.컬럼라벨)
}[]>([]);
const [categoryKeyOpen, setCategoryKeyOpen] = useState(false);
// 카테고리 값 트리
const [categoryValues, setCategoryValues] = useState<CategoryValueNode[]>([]);
const [loadingValues, setLoadingValues] = useState(false);
// 계층적 선택 상태 (대분류, 중분류, 소분류)
const [level1Id, setLevel1Id] = useState<number | null>(null);
const [level2Id, setLevel2Id] = useState<number | null>(null);
const [level3Id, setLevel3Id] = useState<number | null>(null);
const [level1Open, setLevel1Open] = useState(false);
const [level2Open, setLevel2Open] = useState(false);
const [level3Open, setLevel3Open] = useState(false);
// 형식 입력
const [newFormat, setNewFormat] = useState("");
// 수정 모드
const [editingId, setEditingId] = useState<number | null>(null);
// 수정 모드 진입 중 플래그 (useEffect 초기화 방지)
const isEditingRef = useRef(false);
const categoryKey = config.categoryKey || "";
const mappings = config.categoryMappings || [];
// 이미 추가된 카테고리 ID 목록 (수정 중인 항목 제외)
const addedValueIds = useMemo(() => {
return mappings
.filter(m => m.categoryValueId !== editingId)
.map(m => m.categoryValueId);
}, [mappings, editingId]);
// 카테고리 옵션 로드
useEffect(() => {
loadCategoryOptions();
}, []);
// 카테고리 키 변경 시 값 로드 및 선택 초기화
useEffect(() => {
if (categoryKey) {
const [tableName, columnName] = categoryKey.split(".");
if (tableName && columnName) {
loadCategoryValues(tableName, columnName);
}
} else {
setCategoryValues([]);
}
// 선택 초기화
setLevel1Id(null);
setLevel2Id(null);
setLevel3Id(null);
}, [categoryKey]);
// 대분류 변경 시 중분류/소분류 초기화 (수정 모드 진입 중에는 건너뜀)
useEffect(() => {
if (isEditingRef.current) return;
setLevel2Id(null);
setLevel3Id(null);
}, [level1Id]);
// 중분류 변경 시 소분류 초기화 (수정 모드 진입 중에는 건너뜀)
useEffect(() => {
if (isEditingRef.current) return;
setLevel3Id(null);
}, [level2Id]);
const loadCategoryOptions = async () => {
try {
const response = await getAllCategoryKeys();
if (response.success && response.data) {
const options = response.data.map((item: { tableName: string; columnName: string; tableLabel?: string; columnLabel?: string }) => ({
tableName: item.tableName,
columnName: item.columnName,
displayName: `${item.tableName}.${item.columnName}`,
displayLabel: `${item.tableLabel || item.tableName}.${item.columnLabel || item.columnName}`,
}));
setCategoryOptions(options);
}
} catch (error) {
console.error("카테고리 옵션 로드 실패:", error);
}
};
const loadCategoryValues = async (tableName: string, columnName: string) => {
console.log("loadCategoryValues 호출:", { tableName, columnName });
setLoadingValues(true);
try {
const response = await getCategoryTree(tableName, columnName);
console.log("getCategoryTree 응답:", response);
if (response.success && response.data) {
console.log("카테고리 트리 로드 성공:", response.data);
setCategoryValues(response.data);
} else {
console.log("카테고리 트리 로드 실패:", response.error);
setCategoryValues([]);
}
} catch (error) {
console.error("카테고리 값 로드 실패:", error);
setCategoryValues([]);
} finally {
setLoadingValues(false);
}
};
// 이미 추가된 항목 확인 (해당 노드 또는 하위 노드가 추가되었는지)
const isNodeOrDescendantAdded = useCallback((node: CategoryValueNode): boolean => {
if (addedValueIds.includes(node.valueId)) return true;
if (node.children?.length) {
return node.children.every(child => isNodeOrDescendantAdded(child));
}
return false;
}, [addedValueIds]);
// 각 레벨별 항목 계산 (이미 추가된 항목 필터링)
const level1Items = useMemo(() => {
return categoryValues.filter(v => !isNodeOrDescendantAdded(v));
}, [categoryValues, isNodeOrDescendantAdded]);
const level2Items = useMemo(() => {
if (!level1Id) return [];
const parent = categoryValues.find(v => v.valueId === level1Id);
const children = parent?.children || [];
return children.filter(v => !isNodeOrDescendantAdded(v));
}, [categoryValues, level1Id, isNodeOrDescendantAdded]);
const level3Items = useMemo(() => {
if (!level2Id) return [];
const parent = categoryValues.find(v => v.valueId === level1Id);
const level2Parent = parent?.children?.find(v => v.valueId === level2Id);
const children = level2Parent?.children || [];
return children.filter(v => !addedValueIds.includes(v.valueId));
}, [categoryValues, level1Id, level2Id, addedValueIds]);
// 선택된 값 정보 계산
const getSelectedInfo = () => {
// 가장 깊은 레벨의 선택된 값
const selectedId = level3Id || level2Id || level1Id;
if (!selectedId) return null;
// 선택된 노드 찾기
const findNode = (nodes: CategoryValueNode[], id: number): CategoryValueNode | null => {
for (const node of nodes) {
if (node.valueId === id) return node;
if (node.children?.length) {
const found = findNode(node.children, id);
if (found) return found;
}
}
return null;
};
const node = findNode(categoryValues, selectedId);
if (!node) return null;
// 경로 생성
const pathParts: string[] = [];
const l1 = categoryValues.find(v => v.valueId === level1Id);
if (l1) pathParts.push(l1.valueLabel);
if (level2Id) {
const l2 = level2Items.find(v => v.valueId === level2Id);
if (l2) pathParts.push(l2.valueLabel);
}
if (level3Id) {
const l3 = level3Items.find(v => v.valueId === level3Id);
if (l3) pathParts.push(l3.valueLabel);
}
return {
valueId: selectedId,
valueCode: node.valueCode, // valueCode 추가 (V2Select 호환)
valueLabel: node.valueLabel,
valuePath: pathParts.join(" > "),
};
};
const selectedInfo = getSelectedInfo();
// 매핑 추가/수정
const handleAddMapping = () => {
if (!selectedInfo || !newFormat.trim()) return;
const newMapping: CategoryFormatMapping = {
categoryValueId: selectedInfo.valueId,
categoryValueCode: selectedInfo.valueCode, // V2Select에서 valueCode를 value로 사용하므로 매칭용 저장
categoryValueLabel: selectedInfo.valueLabel,
categoryValuePath: selectedInfo.valuePath,
format: newFormat.trim(),
};
let updatedMappings: CategoryFormatMapping[];
if (editingId !== null) {
// 수정 모드: 기존 항목 교체
updatedMappings = mappings.map(m =>
m.categoryValueId === editingId ? newMapping : m
);
} else {
// 추가 모드: 중복 체크
const exists = mappings.some(m => m.categoryValueId === selectedInfo.valueId);
if (exists) {
alert("이미 추가된 카테고리입니다");
return;
}
updatedMappings = [...mappings, newMapping];
}
onChange({
...config,
categoryMappings: updatedMappings,
});
// 초기화
setLevel1Id(null);
setLevel2Id(null);
setLevel3Id(null);
setNewFormat("");
setEditingId(null);
};
// 매핑 수정 모드 진입
const handleEditMapping = (mapping: CategoryFormatMapping) => {
// useEffect 초기화 방지 플래그 설정
isEditingRef.current = true;
// 해당 카테고리의 경로를 파싱해서 레벨별로 설정
const findParentIds = (nodes: CategoryValueNode[], targetId: number, path: number[] = []): number[] | null => {
for (const node of nodes) {
if (node.valueId === targetId) {
return path;
}
if (node.children?.length) {
const result = findParentIds(node.children, targetId, [...path, node.valueId]);
if (result) return result;
}
}
return null;
};
const parentPath = findParentIds(categoryValues, mapping.categoryValueId);
if (parentPath && parentPath.length > 0) {
setLevel1Id(parentPath[0] || null);
if (parentPath.length === 2) {
// 3단계: 대분류 > 중분류 > 소분류
setLevel2Id(parentPath[1]);
setLevel3Id(mapping.categoryValueId);
} else if (parentPath.length === 1) {
// 2단계: 대분류 > 중분류
setLevel2Id(mapping.categoryValueId);
setLevel3Id(null);
} else {
setLevel2Id(null);
setLevel3Id(null);
}
} else {
// 루트 레벨 항목 (1단계)
setLevel1Id(mapping.categoryValueId);
setLevel2Id(null);
setLevel3Id(null);
}
setNewFormat(mapping.format);
setEditingId(mapping.categoryValueId);
// 다음 렌더링 사이클에서 플래그 해제
setTimeout(() => {
isEditingRef.current = false;
}, 0);
};
// 수정 취소
const handleCancelEdit = () => {
setLevel1Id(null);
setLevel2Id(null);
setLevel3Id(null);
setNewFormat("");
setEditingId(null);
};
// 매핑 삭제
const handleRemoveMapping = (valueId: number) => {
onChange({
...config,
categoryMappings: mappings.filter(m => m.categoryValueId !== valueId),
});
};
return (
<div className="space-y-4">
{/* 카테고리 선택 */}
<div>
<Label className="text-xs font-medium sm:text-sm"> </Label>
<Popover open={categoryKeyOpen} onOpenChange={setCategoryKeyOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={categoryKeyOpen}
disabled={isPreview}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
<span className="truncate">
{categoryKey
? categoryOptions.find(o => o.displayName === categoryKey)?.displayLabel || categoryKey
: "카테고리 선택"}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start">
<Command>
<CommandInput placeholder="카테고리 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs"> </CommandEmpty>
<CommandGroup>
{categoryOptions.map((opt) => (
<CommandItem
key={opt.displayName}
value={opt.displayLabel}
onSelect={() => {
onChange({ ...config, categoryKey: opt.displayName, categoryMappings: [] });
setCategoryKeyOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check className={cn("mr-2 h-4 w-4", categoryKey === opt.displayName ? "opacity-100" : "opacity-0")} />
{opt.displayLabel}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 형식 설정 */}
{categoryKey && (
<div className="space-y-3 border-t pt-3">
<Label className="flex items-center gap-2 text-xs font-medium sm:text-sm">
<FolderTree className="h-4 w-4" />
</Label>
{/* 계층적 선택 UI */}
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
{/* 대분류 선택 */}
<div className="min-w-[100px] flex-1">
<Label className="text-[10px] text-muted-foreground"></Label>
<Popover open={level1Open} onOpenChange={setLevel1Open}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={isPreview || loadingValues || level1Items.length === 0}
className="h-8 w-full justify-between text-xs"
>
<span className="truncate">
{loadingValues ? "로딩..." : level1Items.find(v => v.valueId === level1Id)?.valueLabel || "선택"}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-48 p-0" align="start">
<Command>
<CommandInput placeholder="검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs"> </CommandEmpty>
<CommandGroup>
{level1Items.map((val) => (
<CommandItem
key={val.valueId}
value={val.valueLabel}
onSelect={() => {
setLevel1Id(val.valueId);
setLevel1Open(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", level1Id === val.valueId ? "opacity-100" : "opacity-0")} />
{val.valueLabel}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 중분류 선택 (대분류 선택 후 하위가 있을 때만 표시) */}
{level1Id && level2Items.length > 0 && (
<div className="min-w-[100px] flex-1">
<Label className="text-[10px] text-muted-foreground"></Label>
<Popover open={level2Open} onOpenChange={setLevel2Open}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={isPreview}
className="h-8 w-full justify-between text-xs"
>
<span className="truncate">
{level2Items.find(v => v.valueId === level2Id)?.valueLabel || "선택"}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-48 p-0" align="start">
<Command>
<CommandInput placeholder="검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs"> </CommandEmpty>
<CommandGroup>
{level2Items.map((val) => (
<CommandItem
key={val.valueId}
value={val.valueLabel}
onSelect={() => {
setLevel2Id(val.valueId);
setLevel2Open(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", level2Id === val.valueId ? "opacity-100" : "opacity-0")} />
{val.valueLabel}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{/* 소분류 선택 (중분류 선택 후 하위가 있을 때만 표시) */}
{level2Id && level3Items.length > 0 && (
<div className="min-w-[100px] flex-1">
<Label className="text-[10px] text-muted-foreground"></Label>
<Popover open={level3Open} onOpenChange={setLevel3Open}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={isPreview}
className="h-8 w-full justify-between text-xs"
>
<span className="truncate">
{level3Items.find(v => v.valueId === level3Id)?.valueLabel || "선택"}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-48 p-0" align="start">
<Command>
<CommandInput placeholder="검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs"> </CommandEmpty>
<CommandGroup>
{level3Items.map((val) => (
<CommandItem
key={val.valueId}
value={val.valueLabel}
onSelect={() => {
setLevel3Id(val.valueId);
setLevel3Open(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", level3Id === val.valueId ? "opacity-100" : "opacity-0")} />
{val.valueLabel}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
</div>
{/* 형식 입력 + 추가/수정 버튼 */}
<div className="flex gap-2">
<div className="flex-1">
<Label className="text-[10px] text-muted-foreground"></Label>
<Input
value={newFormat}
onChange={(e) => setNewFormat(e.target.value.toUpperCase())}
placeholder="예: ITM, VLV, PIP"
disabled={isPreview || !selectedInfo}
className="h-8 text-xs"
maxLength={10}
/>
</div>
<div className="flex items-end gap-1">
{editingId !== null && (
<Button
size="sm"
variant="outline"
onClick={handleCancelEdit}
className="h-8 text-xs"
>
</Button>
)}
<Button
size="sm"
onClick={handleAddMapping}
disabled={isPreview || !selectedInfo || !newFormat.trim()}
className="h-8"
>
{editingId !== null ? <Check className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
</Button>
</div>
</div>
{/* 선택된 경로 표시 */}
{selectedInfo && (
<p className="text-[10px] text-muted-foreground">
{editingId !== null ? "수정 중: " : "선택: "}{selectedInfo.valuePath}
</p>
)}
</div>
{/* 추가된 매핑 목록 */}
{mappings.length > 0 && (
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground"> ( )</Label>
<div className="space-y-1">
{mappings.map((m) => (
<div
key={m.categoryValueId}
className={cn(
"flex cursor-pointer items-center justify-between rounded px-2 py-1 transition-colors hover:bg-muted",
editingId === m.categoryValueId ? "bg-primary/10 ring-1 ring-primary" : "bg-muted/50"
)}
onClick={() => !isPreview && handleEditMapping(m)}
>
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{m.categoryValuePath || m.categoryValueLabel}</span>
<span></span>
<span className="font-mono font-medium">{m.format}</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleRemoveMapping(m.categoryValueId);
}}
disabled={isPreview}
className="h-5 w-5"
>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div>
))}
</div>
</div>
)}
<p className="text-[10px] text-muted-foreground">
</p>
</div>
)}
</div>
);
};
function ReferenceConfigSection({
config,
onChange,
isPreview,
tableName,
}: {
config: any;
onChange: (c: any) => void;
isPreview: boolean;
tableName?: string;
}) {
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const [loadingCols, setLoadingCols] = useState(false);
useEffect(() => {
if (!tableName) return;
setLoadingCols(true);
const loadEntityColumns = async () => {
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(
`/screen-management/tables/${tableName}/columns`
);
const allCols = response.data?.data || response.data || [];
const entityCols = allCols.filter(
(c: any) =>
(c.inputType || c.input_type) === "entity" ||
(c.inputType || c.input_type) === "numbering"
);
setColumns(
entityCols.map((c: any) => ({
columnName: c.columnName || c.column_name,
displayName:
c.columnLabel || c.column_label || c.columnName || c.column_name,
dataType: c.dataType || c.data_type || "",
inputType: c.inputType || c.input_type || "",
}))
);
} catch {
setColumns([]);
} finally {
setLoadingCols(false);
}
};
loadEntityColumns();
}, [tableName]);
return (
<div className="space-y-3">
<div>
<Label className="text-xs font-medium sm:text-sm"> </Label>
<Select
value={config.referenceColumnName || ""}
onValueChange={(value) =>
onChange({ ...config, referenceColumnName: value })
}
disabled={isPreview || loadingCols}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue
placeholder={
loadingCols
? "로딩 중..."
: columns.length === 0
? "엔티티 컬럼 없음"
: "컬럼 선택"
}
/>
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem
key={col.columnName}
value={col.columnName}
className="text-xs"
>
{col.displayName} ({col.columnName})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
/
</p>
</div>
</div>
);
}