ERP-node/frontend/components/numbering-rule/NumberingRuleDesigner.tsx

492 lines
21 KiB
TypeScript
Raw Normal View History

2025-11-04 13:58:21 +09:00
"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, ListOrdered } from "lucide-react";
2025-11-04 13:58:21 +09:00
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
import { CODE_PART_TYPE_OPTIONS } from "@/types/numbering-rule";
2025-11-04 13:58:21 +09:00
import { NumberingRuleCard } from "./NumberingRuleCard";
import { NumberingRulePreview, computePartDisplayItems, getPartTypeColorClass } from "./NumberingRulePreview";
import { getNumberingRules, saveNumberingRuleToTest } from "@/lib/api/numberingRule";
import { cn } from "@/lib/utils";
2025-11-04 13:58:21 +09:00
interface NumberingRuleDesignerProps {
initialConfig?: NumberingRuleConfig;
onSave?: (config: NumberingRuleConfig) => void;
onChange?: (config: NumberingRuleConfig) => void;
maxRules?: number;
isPreview?: boolean;
className?: string;
currentTableName?: string;
menuObjid?: number;
2025-11-04 13:58:21 +09:00
}
export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
initialConfig,
onSave,
onChange,
maxRules = 6,
isPreview = false,
className = "",
currentTableName,
menuObjid,
2025-11-04 13:58:21 +09:00
}) => {
const [rulesList, setRulesList] = useState<NumberingRuleConfig[]>([]);
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
2025-11-04 13:58:21 +09:00
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
const [selectedPartOrder, setSelectedPartOrder] = useState<number | null>(null);
2025-11-04 13:58:21 +09:00
const [loading, setLoading] = useState(false);
const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
2025-11-04 13:58:21 +09:00
const selectedRule = rulesList.find((r) => r.ruleId === selectedRuleId) ?? currentRule;
// 좌측: 규칙 목록 로드
2025-11-04 13:58:21 +09:00
useEffect(() => {
loadRules();
2025-11-04 13:58:21 +09:00
}, []);
const loadRules = async () => {
setLoading(true);
try {
const response = await getNumberingRules();
if (response.success && response.data) {
setRulesList(response.data);
if (response.data.length > 0 && !selectedRuleId) {
const first = response.data[0];
setSelectedRuleId(first.ruleId);
setCurrentRule(JSON.parse(JSON.stringify(first)));
}
}
} catch (e) {
console.error("채번 규칙 목록 로드 실패:", e);
} finally {
setLoading(false);
}
};
const handleSelectRule = (rule: NumberingRuleConfig) => {
setSelectedRuleId(rule.ruleId);
setCurrentRule(JSON.parse(JSON.stringify(rule)));
setSelectedPartOrder(null);
};
const handleAddNewRule = () => {
const newRule: NumberingRuleConfig = {
ruleId: `rule-${Date.now()}`,
ruleName: "새 규칙",
parts: [],
separator: "-",
resetPeriod: "none",
currentSequence: 1,
scopeType: "global",
tableName: currentTableName ?? "",
columnName: "",
};
setRulesList((prev) => [...prev, newRule]);
setSelectedRuleId(newRule.ruleId);
setCurrentRule(JSON.parse(JSON.stringify(newRule)));
setSelectedPartOrder(null);
toast.success("새 규칙이 추가되었습니다");
};
2025-11-04 13:58:21 +09:00
useEffect(() => {
if (currentRule) onChange?.(currentRule);
2025-11-04 13:58:21 +09:00
}, [currentRule, onChange]);
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 opt = SEPARATOR_OPTIONS.find(
(o) => o.value !== "custom" && o.value !== "none" && o.displayValue === sep
);
if (opt) {
newSepTypes[part.order] = opt.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((p) => (p.order === partOrder ? { ...p, separatorAfter: newSeparator } : p)),
};
});
}
}, []);
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((p) => (p.order === partOrder ? { ...p, separatorAfter: trimmedValue } : p)),
};
});
}, []);
2025-11-04 13:58:21 +09:00
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",
2025-11-04 13:58:21 +09:00
generationMethod: "auto",
autoConfig: { textValue: "CODE" },
separatorAfter: "-",
2025-11-04 13:58:21 +09:00
};
setCurrentRule((prev) => (prev ? { ...prev, parts: [...prev.parts, newPart] } : null));
setSeparatorTypes((prev) => ({ ...prev, [newPart.order]: "-" }));
setCustomSeparators((prev) => ({ ...prev, [newPart.order]: "" }));
2025-11-04 13:58:21 +09:00
toast.success(`규칙 ${newPart.order}가 추가되었습니다`);
}, [currentRule, maxRules]);
const handleUpdatePart = useCallback((partOrder: number, updates: Partial<NumberingRulePart>) => {
2025-11-04 13:58:21 +09:00
setCurrentRule((prev) => {
if (!prev) return null;
return {
...prev,
parts: prev.parts.map((p) => (p.order === partOrder ? { ...p, ...updates } : p)),
2025-11-04 13:58:21 +09:00
};
});
}, []);
const handleDeletePart = useCallback((partOrder: number) => {
2025-11-04 13:58:21 +09:00
setCurrentRule((prev) => {
if (!prev) return null;
return {
...prev,
parts: prev.parts
.filter((p) => p.order !== partOrder)
.map((p, i) => ({ ...p, order: i + 1 })),
2025-11-04 13:58:21 +09:00
};
});
setSelectedPartOrder(null);
2025-11-04 13:58:21 +09:00
toast.success("규칙이 삭제되었습니다");
}, []);
const handleSave = useCallback(async () => {
if (!currentRule) {
toast.error("저장할 규칙이 없습니다");
return;
}
if (currentRule.parts.length === 0) {
toast.error("최소 1개 이상의 규칙을 추가해주세요");
return;
}
setLoading(true);
try {
const defaultAutoConfigs: Record<string, any> = {
sequence: { sequenceLength: 3, startFrom: 1 },
number: { numberLength: 4, numberValue: 1 },
date: { dateFormat: "YYYYMMDD" },
text: { textValue: "" },
};
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: "global" as const,
tableName: currentRule.tableName || currentTableName || "",
columnName: currentRule.columnName || "",
};
const response = await saveNumberingRuleToTest(ruleToSave);
2025-11-04 13:58:21 +09:00
if (response.success && response.data) {
const saved: NumberingRuleConfig = JSON.parse(JSON.stringify(response.data));
setCurrentRule(saved);
setRulesList((prev) => {
const idx = prev.findIndex((r) => r.ruleId === currentRule.ruleId);
if (idx >= 0) {
const next = [...prev];
next[idx] = saved;
return next;
}
return [...prev, saved];
});
setSelectedRuleId(saved.ruleId);
2025-11-04 13:58:21 +09:00
await onSave?.(response.data);
toast.success("채번 규칙이 저장되었습니다");
} else {
showErrorToast("채번 규칙 저장에 실패했습니다", response.error, {
guidance: "설정을 확인하고 다시 시도해 주세요.",
});
2025-11-04 13:58:21 +09:00
}
} catch (error: unknown) {
showErrorToast("채번 규칙 저장에 실패했습니다", error, {
guidance: "설정을 확인하고 다시 시도해 주세요.",
});
2025-11-04 13:58:21 +09:00
} finally {
setLoading(false);
}
}, [currentRule, onSave, currentTableName]);
const selectedPart = currentRule?.parts.find((p) => p.order === selectedPartOrder) ?? null;
const globalSep = currentRule?.separator ?? "-";
const partItems = currentRule ? computePartDisplayItems(currentRule) : [];
return (
<div className={cn("flex h-full", className)}>
{/* 좌측: 규칙 리스트 (code-nav, 220px) */}
<div className="code-nav flex w-[220px] flex-shrink-0 flex-col border-r border-border">
<div className="code-nav-head flex items-center justify-between gap-2 border-b border-border px-3 py-2.5">
<div className="flex min-w-0 flex-1 items-center gap-2">
<ListOrdered className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate text-xs font-bold"> ({rulesList.length})</span>
</div>
<Button
size="sm"
variant="default"
className="h-8 shrink-0 gap-1 text-xs font-medium"
onClick={handleAddNewRule}
disabled={isPreview || loading}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
<div className="code-nav-list flex-1 overflow-y-auto">
{loading && rulesList.length === 0 ? (
<div className="flex h-24 items-center justify-center text-xs text-muted-foreground">
...
2025-11-04 13:58:21 +09:00
</div>
) : rulesList.length === 0 ? (
<div className="flex h-24 items-center justify-center rounded-lg border border-dashed border-border bg-muted/50 text-xs text-muted-foreground">
2025-11-04 13:58:21 +09:00
</div>
) : (
rulesList.map((rule) => {
const isSelected = selectedRuleId === rule.ruleId;
return (
<button
key={rule.ruleId}
type="button"
className={cn(
"code-nav-item flex w-full items-center gap-2 border-b border-border/50 px-3 py-2 text-left transition-colors",
isSelected
? "border-l-[3px] border-primary bg-primary/5 pl-2.5 font-bold"
: "hover:bg-accent"
)}
onClick={() => handleSelectRule(rule)}
>
<span className="rule-name min-w-0 flex-1 truncate text-xs font-semibold">
{rule.ruleName}
</span>
<span className="rule-table max-w-[70px] shrink-0 truncate text-[9px] text-muted-foreground">
{rule.tableName || "-"}
</span>
<span className="rule-parts shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[8px] font-bold text-muted-foreground">
{rule.parts?.length ?? 0}
</span>
</button>
);
})
2025-11-04 13:58:21 +09:00
)}
</div>
</div>
{/* 우측: 미리보기 + 파이프라인 + 설정 + 저장 바 (code-main) */}
<div className="code-main flex min-w-0 flex-1 flex-col overflow-hidden">
2025-11-04 13:58:21 +09:00
{!currentRule ? (
<div className="flex flex-1 flex-col items-center justify-center text-center">
<ListOrdered className="mb-3 h-10 w-10 text-muted-foreground" />
<p className="mb-2 text-lg font-medium text-muted-foreground"> </p>
<p className="text-sm text-muted-foreground">
&quot;&quot;
</p>
2025-11-04 13:58:21 +09:00
</div>
) : (
<>
<div className="flex flex-col gap-2 px-6 pt-4">
<Label className="text-xs font-medium"></Label>
<Input
value={currentRule.ruleName}
onChange={(e) => setCurrentRule((prev) => (prev ? { ...prev, ruleName: e.target.value } : null))}
placeholder="예: 프로젝트 코드"
className="h-9 text-sm"
/>
2025-11-04 13:58:21 +09:00
</div>
{/* 큰 미리보기 스트립 (code-preview-strip) */}
<div className="code-preview-strip flex-shrink-0 border-b border-border px-6 py-5">
<NumberingRulePreview config={currentRule} variant="strip" />
</div>
2025-11-04 13:58:21 +09:00
{/* 파이프라인 영역 (code-pipeline-area) */}
<div className="code-pipeline-area flex flex-col gap-3 border-b border-border px-6 py-5">
<div className="area-label flex items-center gap-1.5">
<span className="text-xs font-bold"> </span>
<span className="cnt text-xs font-medium text-muted-foreground">
2025-11-04 13:58:21 +09:00
{currentRule.parts.length}/{maxRules}
</span>
</div>
<div className="code-pipeline flex flex-1 flex-wrap items-center gap-0 overflow-x-auto overflow-y-hidden pb-2">
{currentRule.parts.length === 0 ? (
<div className="flex h-24 min-w-[200px] items-center justify-center rounded-xl border-2 border-dashed border-border bg-muted/30 text-xs text-muted-foreground">
</div>
) : (
<>
{currentRule.parts.map((part, index) => {
const item = partItems.find((i) => i.order === part.order);
const sep = part.separatorAfter ?? globalSep;
const isSelected = selectedPartOrder === part.order;
const typeLabel = CODE_PART_TYPE_OPTIONS.find((o) => o.value === part.partType)?.label ?? part.partType;
return (
<React.Fragment key={`part-${part.order}-${index}`}>
<button
type="button"
className={cn(
"pipe-segment min-w-[120px] flex-shrink-0 rounded-[10px] border-2 px-4 py-3 text-center transition-all",
part.partType === "date" && "border-warning",
part.partType === "text" && "border-primary",
part.partType === "sequence" && "border-primary",
(part.partType === "number" || part.partType === "category" || part.partType === "reference") && "border-border",
isSelected && "border-primary bg-primary/5 shadow-md ring-2 ring-primary/30"
)}
onClick={() => setSelectedPartOrder(part.order)}
>
<div className="seg-type text-[8px] font-bold uppercase tracking-wide text-muted-foreground">
{typeLabel}
</div>
<div className={cn("seg-value mt-1 truncate font-mono text-base font-extrabold leading-none", getPartTypeColorClass(part.partType))}>
{item?.displayValue ?? "-"}
</div>
</button>
{index < currentRule.parts.length - 1 && (
<div className="pipe-connector flex w-8 flex-shrink-0 flex-col items-center justify-center gap-0.5">
<span className="conn-line text-xs font-bold text-muted-foreground"></span>
<span className="conn-sep rounded border border-border bg-muted px-1 py-0.5 text-[8px] font-semibold text-muted-foreground">
{sep || "-"}
</span>
</div>
)}
</React.Fragment>
);
})}
<button
type="button"
className="pipe-add flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-[10px] border-2 border-dashed border-border text-muted-foreground transition-colors hover:border-primary hover:bg-primary/5 hover:text-primary"
onClick={handleAddPart}
disabled={currentRule.parts.length >= maxRules || isPreview || loading}
aria-label="규칙 추가"
>
<Plus className="h-5 w-5" />
</button>
</>
)}
</div>
2025-11-04 13:58:21 +09:00
</div>
{/* 설정 패널 (선택된 세그먼트 상세, code-config-panel) */}
{selectedPart && (
<div className="code-config-panel min-h-0 flex-1 overflow-y-auto px-6 py-5">
<div className="code-config-grid grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-3">
<NumberingRuleCard
part={selectedPart}
onUpdate={(updates) => handleUpdatePart(selectedPart.order, updates)}
onDelete={() => handleDeletePart(selectedPart.order)}
isPreview={isPreview}
tableName={currentRule.tableName ?? currentTableName}
/>
</div>
{currentRule.parts.some((p) => p.order === selectedPart.order) && (
<div className="mt-3 flex items-center gap-2">
<span className="text-[10px] text-muted-foreground"> </span>
<Select
value={separatorTypes[selectedPart.order] ?? "-"}
onValueChange={(v) => handlePartSeparatorChange(selectedPart.order, v as SeparatorType)}
>
<SelectTrigger className="h-7 w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SEPARATOR_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{separatorTypes[selectedPart.order] === "custom" && (
<Input
value={customSeparators[selectedPart.order] ?? ""}
onChange={(e) => handlePartCustomSeparatorChange(selectedPart.order, e.target.value)}
className="h-7 w-14 text-center text-[10px]"
placeholder="2자"
maxLength={2}
/>
)}
</div>
)}
</div>
)}
{/* 저장 바 (code-save-bar) */}
<div className="code-save-bar flex flex-shrink-0 items-center justify-between gap-4 border-t border-border bg-muted/30 px-6 py-4">
<div className="min-w-0 flex-1 text-xs text-muted-foreground">
{currentRule.tableName && (
<span>: {currentRule.tableName}</span>
)}
{currentRule.columnName && (
<span className="ml-2">: {currentRule.columnName}</span>
)}
<span className="ml-2">: {globalSep || "-"}</span>
{currentRule.resetPeriod && currentRule.resetPeriod !== "none" && (
<span className="ml-2">: {currentRule.resetPeriod}</span>
)}
</div>
2025-11-04 13:58:21 +09:00
<Button
onClick={handleSave}
disabled={isPreview || loading}
className="h-9 gap-2 text-sm font-medium"
2025-11-04 13:58:21 +09:00
>
<Save className="h-4 w-4" />
2025-11-04 13:58:21 +09:00
{loading ? "저장 중..." : "저장"}
</Button>
</div>
</>
)}
</div>
</div>
);
};