434 lines
15 KiB
TypeScript
434 lines
15 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: "global",
|
|
};
|
|
|
|
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-xs text-muted-foreground">로딩 중...</p>
|
|
</div>
|
|
) : savedRules.length === 0 ? (
|
|
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-border bg-muted/50">
|
|
<p className="text-xs text-muted-foreground">저장된 규칙이 없습니다</p>
|
|
</div>
|
|
) : (
|
|
savedRules.map((rule) => (
|
|
<Card
|
|
key={rule.ruleId}
|
|
className={`cursor-pointer border-border transition-colors hover:bg-accent ${
|
|
selectedRuleId === rule.ruleId ? "border-primary bg-primary/5" : "bg-card"
|
|
}`}
|
|
onClick={() => handleSelectRule(rule)}
|
|
>
|
|
<CardHeader className="p-3">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<CardTitle className="text-sm font-medium">{rule.ruleName}</CardTitle>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
규칙 {rule.parts.length}개
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDeleteSavedRule(rule.ruleId);
|
|
}}
|
|
>
|
|
<Trash2 className="h-3 w-3 text-destructive" />
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-3 pt-0">
|
|
<NumberingRulePreview config={rule} compact />
|
|
</CardContent>
|
|
</Card>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 구분선 */}
|
|
<div className="h-full w-px bg-border"></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="mb-2 text-lg font-medium text-muted-foreground">
|
|
규칙을 선택해주세요
|
|
</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
좌측에서 규칙을 선택하거나 새로 생성하세요
|
|
</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-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="space-y-2">
|
|
<Label className="text-sm font-medium">적용 범위</Label>
|
|
<Select
|
|
value={currentRule.scopeType || "global"}
|
|
onValueChange={(value: "global" | "menu") =>
|
|
setCurrentRule((prev) => ({ ...prev!, scopeType: value }))
|
|
}
|
|
disabled={isPreview}
|
|
>
|
|
<SelectTrigger className="h-9">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="global">회사 전체</SelectItem>
|
|
<SelectItem value="menu">메뉴별</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
|
{currentRule.scopeType === "menu"
|
|
? "이 규칙이 설정된 상위 메뉴의 모든 하위 메뉴에서 사용 가능합니다"
|
|
: "회사 내 모든 메뉴에서 사용 가능한 전역 규칙입니다"}
|
|
</p>
|
|
</div>
|
|
|
|
<Card className="border-border bg-card">
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-medium">미리보기</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<NumberingRulePreview config={currentRule} />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<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-xs text-muted-foreground">
|
|
{currentRule.parts.length}/{maxRules}
|
|
</span>
|
|
</div>
|
|
|
|
{currentRule.parts.length === 0 ? (
|
|
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-border bg-muted/50">
|
|
<p className="text-xs text-muted-foreground 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>
|
|
);
|
|
};
|