523 lines
20 KiB
TypeScript
523 lines
20 KiB
TypeScript
"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<NumberingRuleDesignerProps> = ({
|
|
initialConfig,
|
|
onSave,
|
|
onChange,
|
|
maxRules = 6,
|
|
isPreview = false,
|
|
className = "",
|
|
currentTableName,
|
|
menuObjid,
|
|
}) => {
|
|
const [numberingColumns, setNumberingColumns] = useState<NumberingColumn[]>([]);
|
|
const [selectedColumn, setSelectedColumn] = useState<{ tableName: string; columnName: string } | null>(null);
|
|
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [columnSearch, setColumnSearch] = useState("");
|
|
const [rightTitle, setRightTitle] = useState("규칙 편집");
|
|
const [editingRightTitle, setEditingRightTitle] = useState(false);
|
|
|
|
// 구분자 관련 상태 (개별 파트 사이 구분자)
|
|
const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
|
|
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
|
|
|
|
// 좌측: 채번 타입 컬럼 목록 로드
|
|
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<Record<string, GroupedColumns>>((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<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;
|
|
});
|
|
|
|
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 (
|
|
<div className={`flex h-full gap-4 ${className}`}>
|
|
{/* 좌측: 채번 컬럼 목록 (카테고리 패턴) */}
|
|
<div className="flex w-72 flex-shrink-0 flex-col gap-3">
|
|
<h2 className="text-sm font-semibold sm:text-base">채번 컬럼</h2>
|
|
|
|
<Input
|
|
value={columnSearch}
|
|
onChange={(e) => setColumnSearch(e.target.value)}
|
|
placeholder="검색..."
|
|
className="h-8 text-xs"
|
|
/>
|
|
|
|
<div className="flex-1 space-y-1 overflow-y-auto">
|
|
{loading && numberingColumns.length === 0 ? (
|
|
<div className="flex h-32 items-center justify-center">
|
|
<p className="text-muted-foreground text-xs">로딩 중...</p>
|
|
</div>
|
|
) : filteredGroups.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">
|
|
{numberingColumns.length === 0
|
|
? "채번 타입 컬럼이 없습니다"
|
|
: "검색 결과가 없습니다"}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
filteredGroups.map(([tableName, group]) => (
|
|
<div key={tableName} className="mb-2">
|
|
<div className="text-muted-foreground mb-1 flex items-center gap-1 px-1 text-[11px] font-medium">
|
|
<FolderTree className="h-3 w-3" />
|
|
<span>{group.tableLabel}</span>
|
|
<span className="text-muted-foreground/60">({group.columns.length})</span>
|
|
</div>
|
|
{group.columns.map((col) => {
|
|
const isSelected =
|
|
selectedColumn?.tableName === col.tableName &&
|
|
selectedColumn?.columnName === col.columnName;
|
|
return (
|
|
<div
|
|
key={`${col.tableName}.${col.columnName}`}
|
|
className={cn(
|
|
"cursor-pointer rounded-md px-3 py-1.5 text-xs transition-colors",
|
|
isSelected
|
|
? "bg-primary/10 text-primary border-primary border font-medium"
|
|
: "hover:bg-accent"
|
|
)}
|
|
onClick={() => handleSelectColumn(col.tableName, col.columnName)}
|
|
>
|
|
{col.columnLabel}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
))
|
|
)}
|
|
</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">
|
|
<FolderTree className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
|
|
<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}
|
|
tableName={selectedColumn?.tableName}
|
|
/>
|
|
{/* 카드 하단에 구분자 설정 (마지막 파트 제외) */}
|
|
{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>
|
|
);
|
|
};
|