- Introduced functionality to extract and manage individual separators for numbering rule parts. - Added methods to join parts with their respective separators, improving code generation flexibility. - Updated the numbering rule service to utilize the new separator logic during part processing. - Enhanced the frontend components to support custom separators for each part, allowing for more granular control over numbering formats.
685 lines
26 KiB
TypeScript
685 lines
26 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useCallback, useEffect } from "react";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
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, Trash2, FolderTree, Check, ChevronsUpDown } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
|
|
import { NumberingRuleCard } from "./NumberingRuleCard";
|
|
import { NumberingRulePreview } from "./NumberingRulePreview";
|
|
import {
|
|
saveNumberingRuleToTest,
|
|
deleteNumberingRuleFromTest,
|
|
getNumberingRulesFromTest,
|
|
} from "@/lib/api/numberingRule";
|
|
import { getCategoryTree, getAllCategoryKeys } from "@/lib/api/categoryTree";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
// 카테고리 값 트리 노드 타입
|
|
interface CategoryValueNode {
|
|
valueId: number;
|
|
valueCode: string;
|
|
valueLabel: string;
|
|
depth: number;
|
|
path: string;
|
|
parentValueId: number | null;
|
|
children?: CategoryValueNode[];
|
|
}
|
|
|
|
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<NumberingRuleDesignerProps> = ({
|
|
initialConfig,
|
|
onSave,
|
|
onChange,
|
|
maxRules = 6,
|
|
isPreview = false,
|
|
className = "",
|
|
currentTableName,
|
|
menuObjid,
|
|
}) => {
|
|
const [savedRules, setSavedRules] = useState<NumberingRuleConfig[]>([]);
|
|
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
|
|
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [leftTitle, setLeftTitle] = useState("저장된 규칙 목록");
|
|
const [rightTitle, setRightTitle] = useState("규칙 편집");
|
|
const [editingLeftTitle, setEditingLeftTitle] = useState(false);
|
|
const [editingRightTitle, setEditingRightTitle] = useState(false);
|
|
|
|
// 구분자 관련 상태 (개별 파트 사이 구분자)
|
|
const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
|
|
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
|
|
|
|
// 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회
|
|
interface CategoryOption {
|
|
tableName: string;
|
|
columnName: string;
|
|
displayName: string; // "테이블명.컬럼명" 형식
|
|
}
|
|
const [allCategoryOptions, setAllCategoryOptions] = useState<CategoryOption[]>([]);
|
|
const [selectedCategoryKey, setSelectedCategoryKey] = useState<string>(""); // "tableName.columnName"
|
|
const [categoryValues, setCategoryValues] = useState<CategoryValueNode[]>([]);
|
|
const [categoryKeyOpen, setCategoryKeyOpen] = useState(false);
|
|
const [categoryValueOpen, setCategoryValueOpen] = useState(false);
|
|
const [loadingCategories, setLoadingCategories] = useState(false);
|
|
|
|
useEffect(() => {
|
|
loadRules();
|
|
loadAllCategoryOptions(); // 전체 카테고리 옵션 로드
|
|
}, []);
|
|
|
|
// currentRule의 categoryColumn이 변경되면 selectedCategoryKey 동기화
|
|
useEffect(() => {
|
|
if (currentRule?.categoryColumn) {
|
|
setSelectedCategoryKey(currentRule.categoryColumn);
|
|
} else {
|
|
setSelectedCategoryKey("");
|
|
}
|
|
}, [currentRule?.categoryColumn]);
|
|
|
|
// 카테고리 키 선택 시 해당 카테고리 값 로드
|
|
useEffect(() => {
|
|
if (selectedCategoryKey) {
|
|
const [tableName, columnName] = selectedCategoryKey.split(".");
|
|
if (tableName && columnName) {
|
|
loadCategoryValues(tableName, columnName);
|
|
}
|
|
} else {
|
|
setCategoryValues([]);
|
|
}
|
|
}, [selectedCategoryKey]);
|
|
|
|
// 전체 카테고리 옵션 로드 (모든 테이블의 category 타입 컬럼)
|
|
const loadAllCategoryOptions = async () => {
|
|
try {
|
|
// category_values 테이블에서 고유한 테이블.컬럼 조합 조회
|
|
const response = await getAllCategoryKeys();
|
|
if (response.success && response.data) {
|
|
const options: CategoryOption[] = response.data.map((item) => ({
|
|
tableName: item.tableName,
|
|
columnName: item.columnName,
|
|
displayName: `${item.tableName}.${item.columnName}`,
|
|
}));
|
|
setAllCategoryOptions(options);
|
|
console.log("전체 카테고리 옵션 로드:", options);
|
|
}
|
|
} catch (error) {
|
|
console.error("카테고리 옵션 목록 조회 실패:", error);
|
|
}
|
|
};
|
|
|
|
// 특정 카테고리 컬럼의 값 트리 조회
|
|
const loadCategoryValues = async (tableName: string, columnName: string) => {
|
|
setLoadingCategories(true);
|
|
try {
|
|
const response = await getCategoryTree(tableName, columnName);
|
|
if (response.success && response.data) {
|
|
setCategoryValues(response.data);
|
|
console.log("카테고리 값 로드:", { tableName, columnName, count: response.data.length });
|
|
} else {
|
|
setCategoryValues([]);
|
|
}
|
|
} catch (error) {
|
|
console.error("카테고리 값 트리 조회 실패:", error);
|
|
setCategoryValues([]);
|
|
} finally {
|
|
setLoadingCategories(false);
|
|
}
|
|
};
|
|
|
|
// 카테고리 값을 플랫 리스트로 변환 (UI에서 선택용)
|
|
const flattenCategoryValues = (nodes: CategoryValueNode[], result: CategoryValueNode[] = []): CategoryValueNode[] => {
|
|
for (const node of nodes) {
|
|
result.push(node);
|
|
if (node.children && node.children.length > 0) {
|
|
flattenCategoryValues(node.children, result);
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const flatCategoryValues = flattenCategoryValues(categoryValues);
|
|
|
|
const loadRules = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
console.log("🔍 [NumberingRuleDesigner] 채번 규칙 목록 로드 시작 (test 테이블):", {
|
|
menuObjid,
|
|
hasMenuObjid: !!menuObjid,
|
|
});
|
|
|
|
// test 테이블에서 조회
|
|
const response = await getNumberingRulesFromTest(menuObjid);
|
|
|
|
console.log("📦 [NumberingRuleDesigner] 채번 규칙 API 응답 (test 테이블):", {
|
|
menuObjid,
|
|
success: response.success,
|
|
rulesCount: response.data?.length || 0,
|
|
rules: response.data,
|
|
});
|
|
|
|
if (response.success && response.data) {
|
|
setSavedRules(response.data);
|
|
} else {
|
|
toast.error(response.error || "규칙 목록을 불러올 수 없습니다");
|
|
}
|
|
} catch (error: any) {
|
|
toast.error(`로딩 실패: ${error.message}`);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [menuObjid]);
|
|
|
|
useEffect(() => {
|
|
if (currentRule) {
|
|
onChange?.(currentRule);
|
|
}
|
|
}, [currentRule, onChange]);
|
|
|
|
// currentRule이 변경될 때 파트별 구분자 상태 동기화
|
|
useEffect(() => {
|
|
if (currentRule && currentRule.parts.length > 0) {
|
|
const newSepTypes: Record<number, SeparatorType> = {};
|
|
const newCustomSeps: Record<number, string> = {};
|
|
|
|
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<NumberingRulePart>) => {
|
|
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<string, any> = {
|
|
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;
|
|
});
|
|
|
|
// 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정
|
|
// menuObjid가 있으면 menu 스코프, 없으면 기존 scopeType 유지
|
|
const effectiveMenuObjid = menuObjid || currentRule.menuObjid || null;
|
|
const effectiveScopeType = effectiveMenuObjid ? "menu" : (currentRule.scopeType || "global");
|
|
|
|
const ruleToSave = {
|
|
...currentRule,
|
|
parts: partsWithDefaults,
|
|
scopeType: effectiveScopeType as "menu" | "global", // menuObjid 유무에 따라 결정
|
|
tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 (참고용)
|
|
menuObjid: effectiveMenuObjid, // 메뉴 OBJID (필터링 기준)
|
|
};
|
|
|
|
console.log("💾 채번 규칙 저장:", {
|
|
currentTableName,
|
|
menuObjid,
|
|
"currentRule.tableName": currentRule.tableName,
|
|
"currentRule.menuObjid": currentRule.menuObjid,
|
|
"ruleToSave.tableName": ruleToSave.tableName,
|
|
"ruleToSave.menuObjid": ruleToSave.menuObjid,
|
|
"ruleToSave.scopeType": ruleToSave.scopeType,
|
|
ruleToSave,
|
|
});
|
|
|
|
// 테스트 테이블에 저장 (numbering_rules)
|
|
const response = await saveNumberingRuleToTest(ruleToSave);
|
|
|
|
if (response.success && response.data) {
|
|
// 깊은 복사하여 savedRules와 currentRule이 다른 객체를 참조하도록 함
|
|
const currentData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
|
|
|
|
// setSavedRules 내부에서 prev를 사용해서 existing 확인 (클로저 문제 방지)
|
|
setSavedRules((prev) => {
|
|
const savedData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
|
|
const existsInPrev = prev.some((r) => r.ruleId === ruleToSave.ruleId);
|
|
|
|
console.log("🔍 [handleSave] setSavedRules:", {
|
|
ruleId: ruleToSave.ruleId,
|
|
existsInPrev,
|
|
prevCount: prev.length,
|
|
});
|
|
|
|
if (existsInPrev) {
|
|
// 기존 규칙 업데이트
|
|
return prev.map((r) => (r.ruleId === ruleToSave.ruleId ? savedData : r));
|
|
} else {
|
|
// 새 규칙 추가
|
|
return [...prev, savedData];
|
|
}
|
|
});
|
|
|
|
setCurrentRule(currentData);
|
|
setSelectedRuleId(response.data.ruleId);
|
|
|
|
await onSave?.(response.data);
|
|
toast.success("채번 규칙이 저장되었습니다");
|
|
} else {
|
|
toast.error(response.error || "저장 실패");
|
|
}
|
|
} catch (error: any) {
|
|
toast.error(`저장 실패: ${error.message}`);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [currentRule, onSave, currentTableName, menuObjid]);
|
|
|
|
const handleSelectRule = useCallback((rule: NumberingRuleConfig) => {
|
|
console.log("🔍 [handleSelectRule] 규칙 선택:", {
|
|
ruleId: rule.ruleId,
|
|
ruleName: rule.ruleName,
|
|
partsCount: rule.parts?.length || 0,
|
|
parts: rule.parts?.map(p => ({ id: p.id, order: p.order, partType: p.partType })),
|
|
});
|
|
|
|
setSelectedRuleId(rule.ruleId);
|
|
// 깊은 복사하여 객체 참조 분리 (좌측 목록과 편집 영역의 객체가 공유되지 않도록)
|
|
const ruleCopy = JSON.parse(JSON.stringify(rule)) as NumberingRuleConfig;
|
|
|
|
console.log("🔍 [handleSelectRule] 깊은 복사 후:", {
|
|
ruleId: ruleCopy.ruleId,
|
|
partsCount: ruleCopy.parts?.length || 0,
|
|
parts: ruleCopy.parts?.map(p => ({ id: p.id, order: p.order, partType: p.partType })),
|
|
});
|
|
|
|
setCurrentRule(ruleCopy);
|
|
toast.info(`"${rule.ruleName}" 규칙을 불러왔습니다`);
|
|
}, []);
|
|
|
|
const handleDeleteSavedRule = useCallback(
|
|
async (ruleId: string) => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await deleteNumberingRuleFromTest(ruleId);
|
|
|
|
if (response.success) {
|
|
setSavedRules((prev) => prev.filter((r) => r.ruleId !== ruleId));
|
|
|
|
if (selectedRuleId === ruleId) {
|
|
setSelectedRuleId(null);
|
|
setCurrentRule(null);
|
|
}
|
|
|
|
toast.success("규칙이 삭제되었습니다");
|
|
} else {
|
|
toast.error(response.error || "삭제 실패");
|
|
}
|
|
} catch (error: any) {
|
|
toast.error(`삭제 실패: ${error.message}`);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[selectedRuleId],
|
|
);
|
|
|
|
const handleNewRule = useCallback(() => {
|
|
console.log("📋 새 규칙 생성:", { currentTableName, menuObjid });
|
|
|
|
const newRule: NumberingRuleConfig = {
|
|
ruleId: `rule-${Date.now()}`,
|
|
ruleName: "새 채번 규칙",
|
|
parts: [],
|
|
separator: "-",
|
|
resetPeriod: "none",
|
|
currentSequence: 1,
|
|
scopeType: "table", // ⚠️ 임시: DB 제약 조건 때문에 table 유지
|
|
tableName: currentTableName || "", // 현재 화면의 테이블명 자동 설정
|
|
menuObjid: menuObjid || null, // 🆕 메뉴 OBJID 설정 (필터링용)
|
|
};
|
|
|
|
console.log("📋 생성된 규칙 정보:", newRule);
|
|
|
|
setSelectedRuleId(newRule.ruleId);
|
|
setCurrentRule(newRule);
|
|
|
|
toast.success("새 규칙이 생성되었습니다");
|
|
}, [currentTableName, menuObjid]);
|
|
|
|
return (
|
|
<div className={`flex h-full gap-4 ${className}`}>
|
|
{/* 좌측: 저장된 규칙 목록 */}
|
|
<div className="flex w-80 flex-shrink-0 flex-col gap-4">
|
|
<div className="flex items-center justify-between">
|
|
{editingLeftTitle ? (
|
|
<Input
|
|
value={leftTitle}
|
|
onChange={(e) => setLeftTitle(e.target.value)}
|
|
onBlur={() => setEditingLeftTitle(false)}
|
|
onKeyDown={(e) => e.key === "Enter" && setEditingLeftTitle(false)}
|
|
className="h-8 text-sm font-semibold"
|
|
autoFocus
|
|
/>
|
|
) : (
|
|
<h2 className="text-sm font-semibold sm:text-base">{leftTitle}</h2>
|
|
)}
|
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditingLeftTitle(true)}>
|
|
<Edit2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
<Button onClick={handleNewRule} variant="outline" className="h-9 w-full text-sm">
|
|
<Plus className="mr-2 h-4 w-4" />새 규칙 생성
|
|
</Button>
|
|
|
|
<div className="flex-1 space-y-2 overflow-y-auto">
|
|
{loading ? (
|
|
<div className="flex h-32 items-center justify-center">
|
|
<p className="text-muted-foreground text-xs">로딩 중...</p>
|
|
</div>
|
|
) : savedRules.length === 0 ? (
|
|
<div className="border-border bg-muted/50 flex h-32 items-center justify-center rounded-lg border border-dashed">
|
|
<p className="text-muted-foreground text-xs">저장된 규칙이 없습니다</p>
|
|
</div>
|
|
) : (
|
|
savedRules.map((rule) => (
|
|
<Card
|
|
key={rule.ruleId}
|
|
className={`border-border hover:bg-accent cursor-pointer py-2 transition-colors ${
|
|
selectedRuleId === rule.ruleId ? "border-primary bg-primary/5" : "bg-card"
|
|
}`}
|
|
onClick={() => handleSelectRule(rule)}
|
|
>
|
|
<CardHeader className="px-3 py-0">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<CardTitle className="text-sm font-medium">{rule.ruleName}</CardTitle>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDeleteSavedRule(rule.ruleId);
|
|
}}
|
|
>
|
|
<Trash2 className="text-destructive h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
</Card>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 구분선 */}
|
|
<div className="bg-border h-full w-px"></div>
|
|
|
|
{/* 우측: 편집 영역 */}
|
|
<div className="flex flex-1 flex-col gap-4">
|
|
{!currentRule ? (
|
|
<div className="flex h-full flex-col items-center justify-center">
|
|
<div className="text-center">
|
|
<p className="text-muted-foreground mb-2 text-lg font-medium">규칙을 선택해주세요</p>
|
|
<p className="text-muted-foreground text-sm">좌측에서 규칙을 선택하거나 새로 생성하세요</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center justify-between">
|
|
{editingRightTitle ? (
|
|
<Input
|
|
value={rightTitle}
|
|
onChange={(e) => setRightTitle(e.target.value)}
|
|
onBlur={() => setEditingRightTitle(false)}
|
|
onKeyDown={(e) => e.key === "Enter" && setEditingRightTitle(false)}
|
|
className="h-8 text-sm font-semibold"
|
|
autoFocus
|
|
/>
|
|
) : (
|
|
<h2 className="text-sm font-semibold sm:text-base">{rightTitle}</h2>
|
|
)}
|
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditingRightTitle(true)}>
|
|
<Edit2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{/* 첫 번째 줄: 규칙명 + 미리보기 */}
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex-1 space-y-2">
|
|
<Label className="text-sm font-medium">규칙명</Label>
|
|
<Input
|
|
value={currentRule.ruleName}
|
|
onChange={(e) => setCurrentRule((prev) => ({ ...prev!, ruleName: e.target.value }))}
|
|
className="h-9"
|
|
placeholder="예: 프로젝트 코드"
|
|
/>
|
|
</div>
|
|
<div className="flex-1 space-y-2">
|
|
<Label className="text-sm font-medium">미리보기</Label>
|
|
<NumberingRulePreview config={currentRule} />
|
|
</div>
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto">
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<h3 className="text-sm font-semibold">코드 구성</h3>
|
|
<span className="text-muted-foreground text-xs">
|
|
{currentRule.parts.length}/{maxRules}
|
|
</span>
|
|
</div>
|
|
|
|
{currentRule.parts.length === 0 ? (
|
|
<div className="border-border bg-muted/50 flex h-32 items-center justify-center rounded-lg border border-dashed">
|
|
<p className="text-muted-foreground text-xs sm:text-sm">규칙을 추가하여 코드를 구성하세요</p>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-wrap items-stretch gap-3">
|
|
{currentRule.parts.map((part, index) => (
|
|
<React.Fragment key={`part-${part.order}-${index}`}>
|
|
<div className="flex w-[200px] flex-col">
|
|
<NumberingRuleCard
|
|
part={part}
|
|
onUpdate={(updates) => handleUpdatePart(part.order, updates)}
|
|
onDelete={() => handleDeletePart(part.order)}
|
|
isPreview={isPreview}
|
|
/>
|
|
{/* 카드 하단에 구분자 설정 (마지막 파트 제외) */}
|
|
{index < currentRule.parts.length - 1 && (
|
|
<div className="mt-2 flex items-center gap-1">
|
|
<span className="text-muted-foreground text-[10px] whitespace-nowrap">뒤 구분자</span>
|
|
<Select
|
|
value={separatorTypes[part.order] || "-"}
|
|
onValueChange={(value) => handlePartSeparatorChange(part.order, value as SeparatorType)}
|
|
>
|
|
<SelectTrigger className="h-6 flex-1 text-[10px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{SEPARATOR_OPTIONS.map((option) => (
|
|
<SelectItem key={option.value} value={option.value} className="text-xs">
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{separatorTypes[part.order] === "custom" && (
|
|
<Input
|
|
value={customSeparators[part.order] || ""}
|
|
onChange={(e) => handlePartCustomSeparatorChange(part.order, e.target.value)}
|
|
className="h-6 w-14 text-center text-[10px]"
|
|
placeholder="2자"
|
|
maxLength={2}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button
|
|
onClick={handleAddPart}
|
|
disabled={currentRule.parts.length >= maxRules || isPreview || loading}
|
|
variant="outline"
|
|
className="h-9 flex-1 text-sm"
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
규칙 추가
|
|
</Button>
|
|
<Button onClick={handleSave} disabled={isPreview || loading} className="h-9 flex-1 text-sm">
|
|
<Save className="mr-2 h-4 w-4" />
|
|
{loading ? "저장 중..." : "저장"}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|