437 lines
16 KiB
TypeScript
437 lines
16 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 {
|
|
getAvailableNumberingRules,
|
|
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;
|
|
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);
|
|
|
|
useEffect(() => {
|
|
loadRules();
|
|
}, []);
|
|
|
|
const loadRules = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
console.log("🔍 [NumberingRuleDesigner] 채번 규칙 목록 로드 시작:", {
|
|
menuObjid,
|
|
hasMenuObjid: !!menuObjid,
|
|
});
|
|
|
|
const response = await getAvailableNumberingRules(menuObjid);
|
|
|
|
console.log("📦 [NumberingRuleDesigner] 채번 규칙 API 응답:", {
|
|
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]);
|
|
|
|
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);
|
|
|
|
// 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정
|
|
const ruleToSave = {
|
|
...currentRule,
|
|
scopeType: "table" as const, // ⚠️ 임시: DB 제약 조건 때문에 table 유지
|
|
tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 자동 설정 (빈 값은 null)
|
|
menuObjid: menuObjid || currentRule.menuObjid || null, // 🆕 메뉴 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,
|
|
});
|
|
|
|
let response;
|
|
if (existing) {
|
|
response = await updateNumberingRule(ruleToSave.ruleId, ruleToSave);
|
|
} else {
|
|
response = await createNumberingRule(ruleToSave);
|
|
}
|
|
|
|
if (response.success && response.data) {
|
|
setSavedRules((prev) => {
|
|
if (existing) {
|
|
return prev.map((r) => (r.ruleId === ruleToSave.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, currentTableName]);
|
|
|
|
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(() => {
|
|
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>
|
|
|
|
{/* 두 번째 줄: 자동 감지된 테이블 정보 표시 */}
|
|
{currentTableName && (
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium">적용 테이블</Label>
|
|
<div className="border-input bg-muted text-muted-foreground flex h-9 items-center rounded-md border px-3 text-sm">
|
|
{currentTableName}
|
|
</div>
|
|
<p className="text-muted-foreground text-xs">
|
|
이 규칙은 현재 화면의 테이블({currentTableName})에 자동으로 적용됩니다
|
|
</p>
|
|
</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>
|
|
);
|
|
};
|