"use client"; import React, { useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; import { Textarea } from "@/components/ui/textarea"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { toast } from "sonner"; import { Plus, Edit, Trash2, Search, Loader2, UserPlus, GripVertical, } from "lucide-react"; import { ScrollToTop } from "@/components/common/ScrollToTop"; import { type ApprovalDefinition, type ApprovalLineTemplate, type ApprovalLineTemplateStep, getApprovalDefinitions, getApprovalTemplates, getApprovalTemplate, createApprovalTemplate, updateApprovalTemplate, deleteApprovalTemplate, } from "@/lib/api/approval"; import { getUserList } from "@/lib/api/user"; // ============================================================ // 타입 정의 // ============================================================ type StepType = "approval" | "consensus" | "notification"; interface StepApprover { approver_type: "user" | "position" | "dept"; approver_user_id?: string; approver_position?: string; approver_dept_code?: string; approver_label?: string; } interface StepFormData { step_order: number; step_type: StepType; approvers: StepApprover[]; } interface TemplateFormData { template_name: string; description: string; definition_id: number | null; steps: StepFormData[]; } const STEP_TYPE_OPTIONS: { value: StepType; label: string }[] = [ { value: "approval", label: "결재" }, { value: "consensus", label: "합의" }, { value: "notification", label: "통보" }, ]; const STEP_TYPE_BADGE: Record = { approval: { label: "결재", variant: "default" }, consensus: { label: "합의", variant: "secondary" }, notification: { label: "통보", variant: "outline" }, }; const INITIAL_FORM: TemplateFormData = { template_name: "", description: "", definition_id: null, steps: [ { step_order: 1, step_type: "approval", approvers: [{ approver_type: "user", approver_user_id: "", approver_label: "1차 결재자" }], }, ], }; // ============================================================ // 사용자 검색 컴포넌트 // ============================================================ function UserSearchInput({ value, label, onSelect, onLabelChange, }: { value: string; label: string; onSelect: (userId: string, userName: string) => void; onLabelChange: (label: string) => void; }) { const [searchText, setSearchText] = useState(""); const [results, setResults] = useState([]); const [showResults, setShowResults] = useState(false); const [searching, setSearching] = useState(false); const containerRef = React.useRef(null); useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setShowResults(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, []); const handleSearch = useCallback( async (text: string) => { setSearchText(text); if (text.length < 1) { setResults([]); setShowResults(false); return; } setSearching(true); try { const res = await getUserList({ search: text, limit: 10 }); const users = res?.success !== false ? (res?.data || res || []) : []; setResults(Array.isArray(users) ? users : []); setShowResults(true); } catch { setResults([]); } finally { setSearching(false); } }, [], ); const selectUser = (user: any) => { const userId = user.user_id || user.userId || ""; const userName = user.user_name || user.userName || userId; onSelect(userId, userName); setSearchText(""); setShowResults(false); }; return (
{ if (value) { onSelect("", ""); } handleSearch(e.target.value); }} placeholder="ID 또는 이름 검색" className="h-7 text-xs" /> {showResults && results.length > 0 && (
{results.map((user, i) => ( ))}
)} {showResults && results.length === 0 && !searching && searchText.length > 0 && (
검색 결과 없음
)}
onLabelChange(e.target.value)} placeholder="예: 팀장" className="h-7 text-xs" />
); } // ============================================================ // 단계 편집 행 컴포넌트 // ============================================================ function StepEditor({ step, stepIndex, onUpdate, onRemove, }: { step: StepFormData; stepIndex: number; onUpdate: (stepIndex: number, updated: StepFormData) => void; onRemove: (stepIndex: number) => void; }) { const updateStepType = (newType: StepType) => { const updated = { ...step, step_type: newType }; if (newType === "notification" && updated.approvers.length > 1) { updated.approvers = [updated.approvers[0]]; } onUpdate(stepIndex, updated); }; const addApprover = () => { onUpdate(stepIndex, { ...step, approvers: [ ...step.approvers, { approver_type: "user", approver_user_id: "", approver_label: "" }, ], }); }; const removeApprover = (approverIdx: number) => { if (step.approvers.length <= 1) return; onUpdate(stepIndex, { ...step, approvers: step.approvers.filter((_, i) => i !== approverIdx), }); }; const updateApprover = (approverIdx: number, field: string, value: string) => { onUpdate(stepIndex, { ...step, approvers: step.approvers.map((a, i) => i === approverIdx ? { ...a, [field]: value } : a, ), }); }; const handleUserSelect = (approverIdx: number, userId: string, userName: string) => { onUpdate(stepIndex, { ...step, approvers: step.approvers.map((a, i) => i === approverIdx ? { ...a, approver_user_id: userId, approver_label: a.approver_label || userName } : a, ), }); }; const badgeInfo = STEP_TYPE_BADGE[step.step_type]; return (
{/* 단계 헤더 */}
{step.step_order}단계 {badgeInfo.label}
{/* step_type 선택 */}
{/* 통보 타입 안내 */} {step.step_type === "notification" && (

(자동 처리됩니다 - 통보 대상자에게 알림만 발송)

)} {/* 결재자 목록 */}
{step.approvers.map((approver, aIdx) => (
{step.step_type === "consensus" ? `합의자 ${aIdx + 1}` : step.step_type === "notification" ? "통보 대상" : "결재자"} {step.approvers.length > 1 && ( )}
{approver.approver_type === "user" && ( handleUserSelect(aIdx, userId, userName)} onLabelChange={(label) => updateApprover(aIdx, "approver_label", label)} /> )} {approver.approver_type === "position" && (
updateApprover(aIdx, "approver_position", e.target.value)} placeholder="예: 부장, 이사" className="h-7 text-xs" />
updateApprover(aIdx, "approver_label", e.target.value)} placeholder="예: 팀장" className="h-7 text-xs" />
)} {approver.approver_type === "dept" && (
updateApprover(aIdx, "approver_dept_code", e.target.value)} placeholder="예: DEPT001" className="h-7 text-xs" />
updateApprover(aIdx, "approver_label", e.target.value)} placeholder="예: 경영지원팀" className="h-7 text-xs" />
)}
))}
{/* 합의 타입일 때만 결재자 추가 버튼 */} {step.step_type === "consensus" && ( )}
); } // ============================================================ // 메인 페이지 // ============================================================ export default function ApprovalTemplatePage() { const [templates, setTemplates] = useState([]); const [definitions, setDefinitions] = useState([]); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(""); const [editOpen, setEditOpen] = useState(false); const [editingTpl, setEditingTpl] = useState(null); const [saving, setSaving] = useState(false); const [formData, setFormData] = useState({ ...INITIAL_FORM }); const [deleteTarget, setDeleteTarget] = useState(null); // ---- 데이터 로딩 ---- const fetchData = useCallback(async () => { setLoading(true); const [tplRes, defRes] = await Promise.all([ getApprovalTemplates(), getApprovalDefinitions({ is_active: "Y" }), ]); if (tplRes.success && tplRes.data) setTemplates(tplRes.data); if (defRes.success && defRes.data) setDefinitions(defRes.data); setLoading(false); }, []); useEffect(() => { fetchData(); }, [fetchData]); // ---- 템플릿 등록/수정에서 steps를 StepFormData로 변환 ---- const stepsToFormData = (steps: ApprovalLineTemplateStep[]): StepFormData[] => { const stepMap = new Map(); const sorted = [...steps].sort((a, b) => a.step_order - b.step_order); for (const s of sorted) { const existing = stepMap.get(s.step_order); const approver: StepApprover = { approver_type: s.approver_type, approver_user_id: s.approver_user_id, approver_position: s.approver_position, approver_dept_code: s.approver_dept_code, approver_label: s.approver_label, }; if (existing) { existing.approvers.push(approver); if (s.step_type) existing.step_type = s.step_type; } else { stepMap.set(s.step_order, { step_order: s.step_order, step_type: s.step_type || "approval", approvers: [approver], }); } } return Array.from(stepMap.values()).sort((a, b) => a.step_order - b.step_order); }; // ---- StepFormData를 API payload로 변환 ---- const formDataToSteps = ( steps: StepFormData[], ): Omit[] => { const result: Omit[] = []; for (const step of steps) { for (const approver of step.approvers) { result.push({ step_order: step.step_order, step_type: step.step_type, approver_type: approver.approver_type, approver_user_id: approver.approver_user_id || undefined, approver_position: approver.approver_position || undefined, approver_dept_code: approver.approver_dept_code || undefined, approver_label: approver.approver_label || undefined, }); } } return result; }; // ---- 모달 열기 ---- const openCreate = () => { setEditingTpl(null); setFormData({ template_name: "", description: "", definition_id: null, steps: [ { step_order: 1, step_type: "approval", approvers: [{ approver_type: "user", approver_user_id: "", approver_label: "1차 결재자" }], }, ], }); setEditOpen(true); }; const openEdit = async (tpl: ApprovalLineTemplate) => { const res = await getApprovalTemplate(tpl.template_id); if (!res.success || !res.data) { toast.error("템플릿 정보를 불러올 수 없습니다."); return; } const detail = res.data; setEditingTpl(detail); setFormData({ template_name: detail.template_name, description: detail.description || "", definition_id: detail.definition_id || null, steps: detail.steps && detail.steps.length > 0 ? stepsToFormData(detail.steps) : [ { step_order: 1, step_type: "approval", approvers: [{ approver_type: "user", approver_user_id: "", approver_label: "1차 결재자" }], }, ], }); setEditOpen(true); }; // ---- 단계 관리 ---- const addStep = () => { setFormData((p) => ({ ...p, steps: [ ...p.steps, { step_order: p.steps.length + 1, step_type: "approval", approvers: [ { approver_type: "user", approver_user_id: "", approver_label: `${p.steps.length + 1}차 결재자`, }, ], }, ], })); }; const removeStep = (idx: number) => { setFormData((p) => ({ ...p, steps: p.steps .filter((_, i) => i !== idx) .map((s, i) => ({ ...s, step_order: i + 1 })), })); }; const updateStep = (idx: number, updated: StepFormData) => { setFormData((p) => ({ ...p, steps: p.steps.map((s, i) => (i === idx ? updated : s)), })); }; // ---- 저장 ---- const handleSave = async () => { if (!formData.template_name.trim()) { toast.warning("템플릿명을 입력해주세요."); return; } if (formData.steps.length === 0) { toast.warning("결재 단계를 최소 1개 추가해주세요."); return; } const hasEmptyApprover = formData.steps.some((step) => step.approvers.some((a) => { if (a.approver_type === "user" && !a.approver_user_id) return true; if (a.approver_type === "position" && !a.approver_position) return true; if (a.approver_type === "dept" && !a.approver_dept_code) return true; return false; }), ); if (hasEmptyApprover) { toast.warning("모든 결재자 정보를 입력해주세요."); return; } setSaving(true); const payload = { template_name: formData.template_name, description: formData.description || undefined, definition_id: formData.definition_id || undefined, steps: formDataToSteps(formData.steps), }; let res; if (editingTpl) { res = await updateApprovalTemplate(editingTpl.template_id, payload); } else { res = await createApprovalTemplate(payload); } setSaving(false); if (res.success) { toast.success(editingTpl ? "수정되었습니다." : "등록되었습니다."); setEditOpen(false); fetchData(); } else { toast.error(res.error || "저장 실패"); } }; // ---- 삭제 ---- const handleDelete = async () => { if (!deleteTarget) return; const res = await deleteApprovalTemplate(deleteTarget.template_id); if (res.success) { toast.success("삭제되었습니다."); setDeleteTarget(null); fetchData(); } else { toast.error(res.error || "삭제 실패"); } }; // ---- 필터 ---- const filtered = templates.filter( (t) => t.template_name.toLowerCase().includes(searchTerm.toLowerCase()) || (t.description || "").toLowerCase().includes(searchTerm.toLowerCase()), ); // ---- 단계 요약 뱃지 생성 ---- const renderStepSummary = (tpl: ApprovalLineTemplate) => { if (!tpl.steps || tpl.steps.length === 0) return -; const stepMap = new Map(); for (const s of tpl.steps) { const existing = stepMap.get(s.step_order); if (existing) { existing.count++; } else { stepMap.set(s.step_order, { type: s.step_type || "approval", count: 1 }); } } return (
{Array.from(stepMap.entries()) .sort(([a], [b]) => a - b) .map(([order, info]) => { const badge = STEP_TYPE_BADGE[info.type]; return ( {order}단계 {badge.label} {info.count > 1 && ` (${info.count}명)`} ); })}
); }; // ---- 날짜 포맷 ---- const formatDate = (dateStr: string) => { if (!dateStr) return "-"; const d = new Date(dateStr); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; }; return (
{/* 페이지 헤더 */}

결재 템플릿 관리

결재선 템플릿의 단계 구성 및 결재자를 관리합니다.

{/* 검색 + 신규 등록 버튼 */}
setSearchTerm(e.target.value)} className="h-10 pl-10 text-sm" />
{filtered.length}
{/* 템플릿 목록 */} {loading ? ( <> {/* 데스크톱 스켈레톤 */}
템플릿명 설명 단계 구성 연결된 유형 생성일 관리 {Array.from({ length: 5 }).map((_, i) => (
))}
{/* 모바일 스켈레톤 */}
{Array.from({ length: 4 }).map((_, i) => (
{Array.from({ length: 3 }).map((_, j) => (
))}
))}
) : filtered.length === 0 ? (

등록된 결재 템플릿이 없습니다.

) : ( <> {/* 데스크톱 테이블 */}
템플릿명 설명 단계 구성 연결된 유형 생성일 관리 {filtered.map((tpl) => ( {tpl.template_name} {tpl.description || "-"} {renderStepSummary(tpl)} {tpl.definition_name || "-"} {formatDate(tpl.created_at)}
))}
{/* 모바일 카드 */}
{filtered.map((tpl) => (

{tpl.template_name}

{tpl.definition_name && ( {tpl.definition_name} )}
{tpl.description && (

{tpl.description}

)}
단계 구성
{renderStepSummary(tpl)}
생성일 {formatDate(tpl.created_at)}
))}
)}
{/* 등록/수정 Dialog */} {editingTpl ? "결재 템플릿 수정" : "결재 템플릿 등록"} 결재선의 기본 정보와 단계별 결재자를 설정합니다.
{/* 템플릿 기본 정보 */}
setFormData((p) => ({ ...p, template_name: e.target.value }))} placeholder="예: 일반 3단계 결재선" className="h-8 text-xs sm:h-10 sm:text-sm" />