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

379 lines
13 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 } from "lucide-react";
import { toast } from "sonner";
import { NumberingRuleConfig, NumberingRulePart } from "@/types/numbering-rule";
import { NumberingRuleCard } from "./NumberingRuleCard";
import { NumberingRulePreview } from "./NumberingRulePreview";
import {
getNumberingRules,
createNumberingRule,
updateNumberingRule,
deleteNumberingRule,
} from "@/lib/api/numberingRule";
interface NumberingRuleDesignerProps {
initialConfig?: NumberingRuleConfig;
onSave?: (config: NumberingRuleConfig) => void;
onChange?: (config: NumberingRuleConfig) => void;
maxRules?: number;
isPreview?: boolean;
className?: string;
}
export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
initialConfig,
onSave,
onChange,
maxRules = 6,
isPreview = false,
className = "",
}) => {
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);
useEffect(() => {
loadRules();
}, []);
const loadRules = useCallback(async () => {
setLoading(true);
try {
const response = await getNumberingRules();
if (response.success && response.data) {
setSavedRules(response.data);
} else {
toast.error(response.error || "규칙 목록을 불러올 수 없습니다");
}
} catch (error: any) {
toast.error(`로딩 실패: ${error.message}`);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (currentRule) {
onChange?.(currentRule);
}
}, [currentRule, onChange]);
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" },
};
setCurrentRule((prev) => {
if (!prev) return null;
return { ...prev, parts: [...prev.parts, newPart] };
});
toast.success(`규칙 ${newPart.order}가 추가되었습니다`);
}, [currentRule, maxRules]);
const handleUpdatePart = useCallback((partId: string, updates: Partial<NumberingRulePart>) => {
setCurrentRule((prev) => {
if (!prev) return null;
return {
...prev,
parts: prev.parts.map((part) => (part.id === partId ? { ...part, ...updates } : part)),
};
});
}, []);
const handleDeletePart = useCallback((partId: string) => {
setCurrentRule((prev) => {
if (!prev) return null;
return {
...prev,
parts: prev.parts.filter((part) => part.id !== partId).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 {
const existing = savedRules.find((r) => r.ruleId === currentRule.ruleId);
let response;
if (existing) {
response = await updateNumberingRule(currentRule.ruleId, currentRule);
} else {
response = await createNumberingRule(currentRule);
}
if (response.success && response.data) {
setSavedRules((prev) => {
if (existing) {
return prev.map((r) => (r.ruleId === currentRule.ruleId ? response.data! : r));
} else {
return [...prev, response.data!];
}
});
setCurrentRule(response.data);
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, savedRules, onSave]);
const handleSelectRule = useCallback((rule: NumberingRuleConfig) => {
setSelectedRuleId(rule.ruleId);
setCurrentRule(rule);
toast.info(`"${rule.ruleName}" 규칙을 불러왔습니다`);
}, []);
const handleDeleteSavedRule = useCallback(
async (ruleId: string) => {
setLoading(true);
try {
const response = await deleteNumberingRule(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(() => {
const newRule: NumberingRuleConfig = {
ruleId: `rule-${Date.now()}`,
ruleName: "새 채번 규칙",
parts: [],
separator: "-",
resetPeriod: "none",
currentSequence: 1,
scopeType: "menu",
};
setSelectedRuleId(newRule.ruleId);
setCurrentRule(newRule);
toast.success("새 규칙이 생성되었습니다");
}, []);
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={`py-2 border-border hover:bg-accent cursor-pointer 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="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 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="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
{currentRule.parts.map((part) => (
<NumberingRuleCard
key={part.id}
part={part}
onUpdate={(updates) => handleUpdatePart(part.id, updates)}
onDelete={() => handleDeletePart(part.id)}
isPreview={isPreview}
/>
))}
</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>
);
};