"use client"; import React, { useState, useCallback, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Plus, Save, Edit2, FolderTree } from "lucide-react"; import { toast } from "sonner"; import { showErrorToast } from "@/lib/utils/toastUtils"; import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule"; import { NumberingRuleCard } from "./NumberingRuleCard"; import { NumberingRulePreview } from "./NumberingRulePreview"; import { saveNumberingRuleToTest } from "@/lib/api/numberingRule"; import { apiClient } from "@/lib/api/client"; import { cn } from "@/lib/utils"; interface NumberingColumn { tableName: string; tableLabel: string; columnName: string; columnLabel: string; } interface GroupedColumns { tableLabel: string; columns: NumberingColumn[]; } interface NumberingRuleDesignerProps { initialConfig?: NumberingRuleConfig; onSave?: (config: NumberingRuleConfig) => void; onChange?: (config: NumberingRuleConfig) => void; maxRules?: number; isPreview?: boolean; className?: string; currentTableName?: string; // 현재 화면의 테이블명 (자동 감지용) menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프) } export const NumberingRuleDesigner: React.FC = ({ initialConfig, onSave, onChange, maxRules = 6, isPreview = false, className = "", currentTableName, menuObjid, }) => { const [numberingColumns, setNumberingColumns] = useState([]); const [selectedColumn, setSelectedColumn] = useState<{ tableName: string; columnName: string } | null>(null); const [currentRule, setCurrentRule] = useState(null); const [loading, setLoading] = useState(false); const [columnSearch, setColumnSearch] = useState(""); const [rightTitle, setRightTitle] = useState("규칙 편집"); const [editingRightTitle, setEditingRightTitle] = useState(false); // 구분자 관련 상태 (개별 파트 사이 구분자) const [separatorTypes, setSeparatorTypes] = useState>({}); const [customSeparators, setCustomSeparators] = useState>({}); // 좌측: 채번 타입 컬럼 목록 로드 useEffect(() => { loadNumberingColumns(); }, []); const loadNumberingColumns = async () => { setLoading(true); try { const response = await apiClient.get("/table-management/numbering-columns"); if (response.data.success && response.data.data) { setNumberingColumns(response.data.data); } } catch (error: any) { console.error("채번 컬럼 목록 로드 실패:", error); } finally { setLoading(false); } }; // 컬럼 선택 시 해당 컬럼의 채번 규칙 로드 const handleSelectColumn = async (tableName: string, columnName: string) => { setSelectedColumn({ tableName, columnName }); setLoading(true); try { const response = await apiClient.get(`/numbering-rules/by-column/${tableName}/${columnName}`); if (response.data.success && response.data.data) { const rule = response.data.data as NumberingRuleConfig; setCurrentRule(JSON.parse(JSON.stringify(rule))); } else { // 규칙 없으면 신규 생성 모드 const newRule: NumberingRuleConfig = { ruleId: `rule-${Date.now()}`, ruleName: `${columnName} 채번`, parts: [], separator: "-", resetPeriod: "none", currentSequence: 1, scopeType: "table", tableName, columnName, }; setCurrentRule(newRule); } } catch { const newRule: NumberingRuleConfig = { ruleId: `rule-${Date.now()}`, ruleName: `${columnName} 채번`, parts: [], separator: "-", resetPeriod: "none", currentSequence: 1, scopeType: "table", tableName, columnName, }; setCurrentRule(newRule); } finally { setLoading(false); } }; // 테이블별로 그룹화 const groupedColumns = numberingColumns.reduce>((acc, col) => { if (!acc[col.tableName]) { acc[col.tableName] = { tableLabel: col.tableLabel, columns: [] }; } acc[col.tableName].columns.push(col); return acc; }, {}); // 검색 필터 적용 const filteredGroups = Object.entries(groupedColumns).filter(([tableName, group]) => { if (!columnSearch) return true; const search = columnSearch.toLowerCase(); return ( tableName.toLowerCase().includes(search) || group.tableLabel.toLowerCase().includes(search) || group.columns.some( (c) => c.columnName.toLowerCase().includes(search) || c.columnLabel.toLowerCase().includes(search) ) ); }); useEffect(() => { if (currentRule) { onChange?.(currentRule); } }, [currentRule, onChange]); // currentRule이 변경될 때 파트별 구분자 상태 동기화 useEffect(() => { if (currentRule && currentRule.parts.length > 0) { const newSepTypes: Record = {}; const newCustomSeps: Record = {}; currentRule.parts.forEach((part) => { const sep = part.separatorAfter ?? currentRule.separator ?? "-"; if (sep === "") { newSepTypes[part.order] = "none"; newCustomSeps[part.order] = ""; } else { const predefinedOption = SEPARATOR_OPTIONS.find( opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep ); if (predefinedOption) { newSepTypes[part.order] = predefinedOption.value; newCustomSeps[part.order] = ""; } else { newSepTypes[part.order] = "custom"; newCustomSeps[part.order] = sep; } } }); setSeparatorTypes(newSepTypes); setCustomSeparators(newCustomSeps); } }, [currentRule?.ruleId]); // 개별 파트 구분자 변경 핸들러 const handlePartSeparatorChange = useCallback((partOrder: number, type: SeparatorType) => { setSeparatorTypes(prev => ({ ...prev, [partOrder]: type })); if (type !== "custom") { const option = SEPARATOR_OPTIONS.find(opt => opt.value === type); const newSeparator = option?.displayValue ?? ""; setCustomSeparators(prev => ({ ...prev, [partOrder]: "" })); setCurrentRule((prev) => { if (!prev) return null; return { ...prev, parts: prev.parts.map((part) => part.order === partOrder ? { ...part, separatorAfter: newSeparator } : part ), }; }); } }, []); // 개별 파트 직접 입력 구분자 변경 핸들러 const handlePartCustomSeparatorChange = useCallback((partOrder: number, value: string) => { const trimmedValue = value.slice(0, 2); setCustomSeparators(prev => ({ ...prev, [partOrder]: trimmedValue })); setCurrentRule((prev) => { if (!prev) return null; return { ...prev, parts: prev.parts.map((part) => part.order === partOrder ? { ...part, separatorAfter: trimmedValue } : part ), }; }); }, []); const handleAddPart = useCallback(() => { if (!currentRule) return; if (currentRule.parts.length >= maxRules) { toast.error(`최대 ${maxRules}개까지 추가할 수 있습니다`); return; } const newPart: NumberingRulePart = { id: `part-${Date.now()}`, order: currentRule.parts.length + 1, partType: "text", generationMethod: "auto", autoConfig: { textValue: "CODE" }, separatorAfter: "-", }; setCurrentRule((prev) => { if (!prev) return null; return { ...prev, parts: [...prev.parts, newPart] }; }); // 새 파트의 구분자 상태 초기화 setSeparatorTypes(prev => ({ ...prev, [newPart.order]: "-" })); setCustomSeparators(prev => ({ ...prev, [newPart.order]: "" })); toast.success(`규칙 ${newPart.order}가 추가되었습니다`); }, [currentRule, maxRules]); // partOrder 기반으로 파트 업데이트 (id가 null일 수 있으므로 order 사용) const handleUpdatePart = useCallback((partOrder: number, updates: Partial) => { setCurrentRule((prev) => { if (!prev) return null; return { ...prev, parts: prev.parts.map((part) => (part.order === partOrder ? { ...part, ...updates } : part)), }; }); }, []); // partOrder 기반으로 파트 삭제 (id가 null일 수 있으므로 order 사용) const handleDeletePart = useCallback((partOrder: number) => { setCurrentRule((prev) => { if (!prev) return null; return { ...prev, parts: prev.parts.filter((part) => part.order !== partOrder).map((part, index) => ({ ...part, order: index + 1 })), }; }); toast.success("규칙이 삭제되었습니다"); }, []); const handleSave = useCallback(async () => { if (!currentRule) { toast.error("저장할 규칙이 없습니다"); return; } if (currentRule.parts.length === 0) { toast.error("최소 1개 이상의 규칙을 추가해주세요"); return; } setLoading(true); try { // 파트별 기본 autoConfig 정의 const defaultAutoConfigs: Record = { sequence: { sequenceLength: 3, startFrom: 1 }, number: { numberLength: 4, numberValue: 1 }, date: { dateFormat: "YYYYMMDD" }, text: { textValue: "" }, }; // 저장 전에 각 파트의 autoConfig에 기본값 채우기 const partsWithDefaults = currentRule.parts.map((part) => { if (part.generationMethod === "auto") { const defaults = defaultAutoConfigs[part.partType] || {}; return { ...part, autoConfig: { ...defaults, ...part.autoConfig }, }; } return part; }); const ruleToSave = { ...currentRule, parts: partsWithDefaults, scopeType: "table" as const, tableName: selectedColumn?.tableName || currentRule.tableName || "", columnName: selectedColumn?.columnName || currentRule.columnName || "", }; // 테스트 테이블에 저장 (numbering_rules) const response = await saveNumberingRuleToTest(ruleToSave); if (response.success && response.data) { const currentData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig; setCurrentRule(currentData); await onSave?.(response.data); toast.success("채번 규칙이 저장되었습니다"); } else { showErrorToast("채번 규칙 저장에 실패했습니다", response.error, { guidance: "설정을 확인하고 다시 시도해 주세요." }); } } catch (error: any) { showErrorToast("채번 규칙 저장에 실패했습니다", error, { guidance: "설정을 확인하고 다시 시도해 주세요." }); } finally { setLoading(false); } }, [currentRule, onSave, selectedColumn]); return (
{/* 좌측: 채번 컬럼 목록 (카테고리 패턴) */}

채번 컬럼

setColumnSearch(e.target.value)} placeholder="검색..." className="h-8 text-xs" />
{loading && numberingColumns.length === 0 ? (

로딩 중...

) : filteredGroups.length === 0 ? (

{numberingColumns.length === 0 ? "채번 타입 컬럼이 없습니다" : "검색 결과가 없습니다"}

) : ( filteredGroups.map(([tableName, group]) => (
{group.tableLabel} ({group.columns.length})
{group.columns.map((col) => { const isSelected = selectedColumn?.tableName === col.tableName && selectedColumn?.columnName === col.columnName; return (
handleSelectColumn(col.tableName, col.columnName)} > {col.columnLabel}
); })}
)) )}
{/* 구분선 */}
{/* 우측: 편집 영역 */}
{!currentRule ? (

컬럼을 선택해주세요

좌측에서 채번 컬럼을 선택하면 규칙을 편집할 수 있습니다

) : ( <>
{editingRightTitle ? ( setRightTitle(e.target.value)} onBlur={() => setEditingRightTitle(false)} onKeyDown={(e) => e.key === "Enter" && setEditingRightTitle(false)} className="h-8 text-sm font-semibold" autoFocus /> ) : (

{rightTitle}

)}
{/* 첫 번째 줄: 규칙명 + 미리보기 */}
setCurrentRule((prev) => ({ ...prev!, ruleName: e.target.value }))} className="h-9" placeholder="예: 프로젝트 코드" />

코드 구성

{currentRule.parts.length}/{maxRules}
{currentRule.parts.length === 0 ? (

규칙을 추가하여 코드를 구성하세요

) : (
{currentRule.parts.map((part, index) => (
handleUpdatePart(part.order, updates)} onDelete={() => handleDeletePart(part.order)} isPreview={isPreview} /> {/* 카드 하단에 구분자 설정 (마지막 파트 제외) */} {index < currentRule.parts.length - 1 && (
뒤 구분자 {separatorTypes[part.order] === "custom" && ( handlePartCustomSeparatorChange(part.order, e.target.value)} className="h-6 w-14 text-center text-[10px]" placeholder="2자" maxLength={2} /> )}
)}
))}
)}
)}
); };