"use client"; import React, { useState, useEffect, useCallback, useRef } from "react"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Plus, X, Loader2, Search, GripVertical, Users, ArrowDown, Layers, FileText } from "lucide-react"; import { toast } from "sonner"; import { createApprovalRequest, getApprovalTemplates, getTemplateSteps, type ApprovalLineTemplate, } from "@/lib/api/approval"; import { getUserList } from "@/lib/api/user"; // 결재 방식 type ApprovalMode = "sequential" | "parallel"; // 결재 유형 type ApprovalType = "self" | "escalation" | "consensus" | "post"; // step_type 라벨 매핑 const STEP_TYPE_LABEL: Record = { approval: { label: "결재", variant: "default" }, consensus: { label: "합의", variant: "secondary" }, notification: { label: "통보", variant: "outline" }, }; interface ApproverRow { id: string; user_id: string; user_name: string; position_name: string; dept_name: string; step_type?: "approval" | "consensus" | "notification"; step_order?: number; } export interface ApprovalModalEventDetail { targetTable: string; targetRecordId: string; targetRecordData?: Record; definitionId?: number; screenId?: number; buttonComponentId?: string; } interface ApprovalRequestModalProps { open: boolean; onOpenChange: (open: boolean) => void; eventDetail?: ApprovalModalEventDetail | null; } interface UserSearchResult { userId: string; userName: string; positionName?: string; deptName?: string; deptCode?: string; email?: string; user_id?: string; user_name?: string; position_name?: string; dept_name?: string; } function genId(): string { return `a_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; } export const ApprovalRequestModal: React.FC = ({ open, onOpenChange, eventDetail, }) => { const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [approvalMode, setApprovalMode] = useState("sequential"); const [approvers, setApprovers] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); // 결재 유형 상태 const [approvalType, setApprovalType] = useState("escalation"); // 템플릿 상태 const [templates, setTemplates] = useState([]); const [selectedTemplateId, setSelectedTemplateId] = useState(null); const [showTemplatePopover, setShowTemplatePopover] = useState(false); const [isLoadingTemplates, setIsLoadingTemplates] = useState(false); // 사용자 검색 상태 const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); const searchInputRef = useRef(null); const searchTimerRef = useRef(null); // 모달 닫힐 때 초기화 useEffect(() => { if (!open) { setTitle(""); setDescription(""); setApprovalMode("sequential"); setApprovalType("escalation"); setApprovers([]); setError(null); setSearchOpen(false); setSearchQuery(""); setSearchResults([]); setSelectedTemplateId(null); setShowTemplatePopover(false); } }, [open]); // 모달 열릴 때 템플릿 목록 로드 useEffect(() => { if (open) { loadTemplates(); } }, [open]); const loadTemplates = async () => { setIsLoadingTemplates(true); try { const res = await getApprovalTemplates({ is_active: "Y" }); if (res.success && res.data) { setTemplates(res.data); } } catch { // 템플릿 로드 실패는 무시 (선택사항이므로) } finally { setIsLoadingTemplates(false); } }; // 템플릿 선택 시 결재자 리스트에 반영 const applyTemplate = async (templateId: number) => { try { const res = await getTemplateSteps(templateId); if (!res.success || !res.data || res.data.length === 0) { toast.error("템플릿에 설정된 결재 단계가 없습니다."); return; } const steps = res.data; const hasMultipleTypes = new Set(steps.map((s) => s.step_type || "approval")).size > 1; // step_type이 혼합이면 escalation으로 설정 if (hasMultipleTypes) { setApprovalType("escalation"); } const newApprovers: ApproverRow[] = steps.map((step) => ({ id: genId(), user_id: step.approver_user_id || "", user_name: step.approver_label || step.approver_user_id || "", position_name: step.approver_position || "", dept_name: step.approver_dept_code || "", step_type: step.step_type || "approval", step_order: step.step_order, })); setApprovers(newApprovers); setSelectedTemplateId(templateId); setShowTemplatePopover(false); toast.success("템플릿이 적용되었습니다."); } catch { toast.error("템플릿 적용 중 오류가 발생했습니다."); } }; // 사용자 검색 (디바운스) const searchUsers = useCallback(async (query: string) => { if (!query.trim() || query.trim().length < 1) { setSearchResults([]); return; } setIsSearching(true); try { const res = await getUserList({ search: query.trim(), limit: 20 }); const data = res?.data || res || []; const rawUsers: any[] = Array.isArray(data) ? data : []; const users: UserSearchResult[] = rawUsers.map((u: any) => ({ userId: u.userId || u.user_id || "", userName: u.userName || u.user_name || "", positionName: u.positionName || u.position_name || "", deptName: u.deptName || u.dept_name || "", deptCode: u.deptCode || u.dept_code || "", email: u.email || "", })); const existingIds = new Set(approvers.map((a) => a.user_id)); setSearchResults(users.filter((u) => u.userId && !existingIds.has(u.userId))); } catch { setSearchResults([]); } finally { setIsSearching(false); } }, [approvers]); useEffect(() => { if (searchTimerRef.current) clearTimeout(searchTimerRef.current); if (!searchQuery.trim()) { setSearchResults([]); return; } searchTimerRef.current = setTimeout(() => { searchUsers(searchQuery); }, 300); return () => { if (searchTimerRef.current) clearTimeout(searchTimerRef.current); }; }, [searchQuery, searchUsers]); const addApprover = (user: UserSearchResult) => { setApprovers((prev) => [ ...prev, { id: genId(), user_id: user.userId, user_name: user.userName, position_name: user.positionName || "", dept_name: user.deptName || "", }, ]); setSearchQuery(""); setSearchResults([]); setSearchOpen(false); }; const removeApprover = (id: string) => { setApprovers((prev) => prev.filter((a) => a.id !== id)); }; const moveApprover = (idx: number, direction: "up" | "down") => { setApprovers((prev) => { const next = [...prev]; const targetIdx = direction === "up" ? idx - 1 : idx + 1; if (targetIdx < 0 || targetIdx >= next.length) return prev; [next[idx], next[targetIdx]] = [next[targetIdx], next[idx]]; return next; }); }; const handleSubmit = async () => { if (!title.trim()) { setError("결재 제목을 입력해주세요."); return; } // 자기결재가 아닌 경우 결재자 필수 if (approvalType !== "self" && approvers.length === 0) { setError("결재자를 1명 이상 추가해주세요."); return; } if (!eventDetail?.targetTable) { setError("결재 대상 테이블 정보가 없습니다. 버튼 설정을 확인해주세요."); return; } setIsSubmitting(true); setError(null); // 혼합형 여부: approvers에 step_type이 설정된 경우 const hasMixedStepTypes = approvers.some((a) => a.step_type); const res = await createApprovalRequest({ title: title.trim(), description: description.trim() || undefined, target_table: eventDetail.targetTable, target_record_id: eventDetail.targetRecordId || undefined, target_record_data: eventDetail.targetRecordData, approval_mode: approvalType === "consensus" ? "parallel" : approvalMode, approval_type: approvalType, screen_id: eventDetail.screenId, button_component_id: eventDetail.buttonComponentId, approvers: approvalType === "self" ? [] : approvers.map((a, idx) => ({ approver_id: a.user_id, approver_name: a.user_name, approver_position: a.position_name || undefined, approver_dept: a.dept_name || undefined, step_type: hasMixedStepTypes ? (a.step_type || "approval") : undefined, step_order: hasMixedStepTypes ? (a.step_order ?? idx + 1) : undefined, approver_label: hasMixedStepTypes ? STEP_TYPE_LABEL[a.step_type || "approval"]?.label : approvalMode === "sequential" ? `${idx + 1}차 결재` : "동시 결재", })), }); setIsSubmitting(false); if (res.success) { toast.success("결재 요청이 완료되었습니다."); onOpenChange(false); } else { setError(res.error || res.message || "결재 요청에 실패했습니다."); } }; return ( 결재 상신 결재 방식을 선택하고 결재자를 검색하여 추가합니다.
{/* 결재 제목 */}
setTitle(e.target.value)} placeholder="결재 제목을 입력하세요" className="h-8 text-xs sm:h-10 sm:text-sm" />
{/* 결재 사유 */}