From c22b468599316954ba5a5fd5768eb2ef0bc50cd7 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 4 Mar 2026 11:19:57 +0900 Subject: [PATCH] feat: Enhance approval request modal functionality - Added user search capability with debouncing to improve performance and user experience. - Updated approver management to utilize user data, including user ID, name, position, and department. - Refactored local ID generation for approvers to a more concise function. - Integrated approval request handling in button actions, allowing for modal opening with relevant data. - Improved UI elements for better clarity and user guidance in the approval process. Made-with: Cursor --- .../app/(main)/admin/approvalMng/page.tsx | 788 ++++++++++++++++++ .../approval/ApprovalRequestModal.tsx | 528 ++++++------ .../config-panels/ButtonConfigPanel.tsx | 9 +- .../ButtonPrimaryComponent.tsx | 2 +- frontend/lib/utils/buttonActions.ts | 42 + .../lib/utils/improvedButtonActionExecutor.ts | 29 + 6 files changed, 1157 insertions(+), 241 deletions(-) create mode 100644 frontend/app/(main)/admin/approvalMng/page.tsx diff --git a/frontend/app/(main)/admin/approvalMng/page.tsx b/frontend/app/(main)/admin/approvalMng/page.tsx new file mode 100644 index 00000000..4138abda --- /dev/null +++ b/frontend/app/(main)/admin/approvalMng/page.tsx @@ -0,0 +1,788 @@ +"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 { Switch } from "@/components/ui/switch"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +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, Users, FileText, Loader2 } from "lucide-react"; +import { ScrollToTop } from "@/components/common/ScrollToTop"; +import { + type ApprovalDefinition, + type ApprovalLineTemplate, + type ApprovalLineTemplateStep, + getApprovalDefinitions, + createApprovalDefinition, + updateApprovalDefinition, + deleteApprovalDefinition, + getApprovalTemplates, + getApprovalTemplate, + createApprovalTemplate, + updateApprovalTemplate, + deleteApprovalTemplate, +} from "@/lib/api/approval"; + +// ============================================================ +// 결재 유형 관리 탭 +// ============================================================ + +function DefinitionsTab() { + const [definitions, setDefinitions] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + + const [editOpen, setEditOpen] = useState(false); + const [editingDef, setEditingDef] = useState(null); + const [formData, setFormData] = useState({ + definition_name: "", + definition_name_eng: "", + description: "", + max_steps: 3, + allow_self_approval: false, + allow_cancel: true, + is_active: "Y", + }); + + const [deleteTarget, setDeleteTarget] = useState(null); + + const fetchDefinitions = useCallback(async () => { + setLoading(true); + const res = await getApprovalDefinitions({ search: searchTerm || undefined }); + if (res.success && res.data) { + setDefinitions(res.data); + } + setLoading(false); + }, [searchTerm]); + + useEffect(() => { + fetchDefinitions(); + }, [fetchDefinitions]); + + const openCreate = () => { + setEditingDef(null); + setFormData({ + definition_name: "", + definition_name_eng: "", + description: "", + max_steps: 3, + allow_self_approval: false, + allow_cancel: true, + is_active: "Y", + }); + setEditOpen(true); + }; + + const openEdit = (def: ApprovalDefinition) => { + setEditingDef(def); + setFormData({ + definition_name: def.definition_name, + definition_name_eng: def.definition_name_eng || "", + description: def.description || "", + max_steps: def.max_steps, + allow_self_approval: def.allow_self_approval, + allow_cancel: def.allow_cancel, + is_active: def.is_active, + }); + setEditOpen(true); + }; + + const handleSave = async () => { + if (!formData.definition_name.trim()) { + toast.warning("결재 유형명을 입력해주세요."); + return; + } + + let res; + if (editingDef) { + res = await updateApprovalDefinition(editingDef.definition_id, formData); + } else { + res = await createApprovalDefinition(formData); + } + + if (res.success) { + toast.success(editingDef ? "수정되었습니다." : "등록되었습니다."); + setEditOpen(false); + fetchDefinitions(); + } else { + toast.error(res.error || "저장 실패"); + } + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + const res = await deleteApprovalDefinition(deleteTarget.definition_id); + if (res.success) { + toast.success("삭제되었습니다."); + setDeleteTarget(null); + fetchDefinitions(); + } else { + toast.error(res.error || "삭제 실패"); + } + }; + + const filtered = definitions.filter( + (d) => + d.definition_name.toLowerCase().includes(searchTerm.toLowerCase()) || + (d.description || "").toLowerCase().includes(searchTerm.toLowerCase()), + ); + + return ( +
+ {/* 검색 + 등록 */} +
+
+
+ + setSearchTerm(e.target.value)} + className="h-10 pl-10 text-sm" + /> +
+ + 총 {filtered.length} 건 + +
+ +
+ + {/* 테이블 */} + {loading ? ( +
+ +
+ ) : filtered.length === 0 ? ( +
+

등록된 결재 유형이 없습니다.

+
+ ) : ( +
+ + + + 유형명 + 설명 + 최대 단계 + 자가결재 + 회수가능 + 상태 + 관리 + + + + {filtered.map((def) => ( + + {def.definition_name} + {def.description || "-"} + {def.max_steps} + + + {def.allow_self_approval ? "허용" : "불가"} + + + + + {def.allow_cancel ? "허용" : "불가"} + + + + + {def.is_active === "Y" ? "활성" : "비활성"} + + + +
+ + +
+
+
+ ))} +
+
+
+ )} + + {/* 등록/수정 모달 */} + + + + + {editingDef ? "결재 유형 수정" : "결재 유형 등록"} + + + 결재 유형의 기본 정보를 설정합니다. + + +
+
+ + setFormData((p) => ({ ...p, definition_name: e.target.value }))} + placeholder="예: 일반 결재, 긴급 결재" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + setFormData((p) => ({ ...p, definition_name_eng: e.target.value }))} + placeholder="예: General Approval" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + setFormData((p) => ({ ...p, description: e.target.value }))} + placeholder="유형에 대한 설명" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + setFormData((p) => ({ ...p, max_steps: Number(e.target.value) }))} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + setFormData((p) => ({ ...p, allow_self_approval: v }))} + /> +
+
+ + setFormData((p) => ({ ...p, allow_cancel: v }))} + /> +
+
+ + setFormData((p) => ({ ...p, is_active: v ? "Y" : "N" }))} + /> +
+
+ + + + +
+
+ + {/* 삭제 확인 */} + setDeleteTarget(null)}> + + + 결재 유형 삭제 + + "{deleteTarget?.definition_name}"을(를) 삭제하시겠습니까? +
이 유형에 연결된 결재 요청이 있으면 삭제할 수 없습니다. +
+
+ + 취소 + + 삭제 + + +
+
+
+ ); +} + +// ============================================================ +// 결재선 템플릿 관리 탭 +// ============================================================ + +function TemplatesTab() { + 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 [formData, setFormData] = useState({ + template_name: "", + description: "", + definition_id: null as number | null, + is_active: "Y", + steps: [] as Omit[], + }); + + 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]); + + const openCreate = () => { + setEditingTpl(null); + setFormData({ + template_name: "", + description: "", + definition_id: null, + is_active: "Y", + steps: [{ step_order: 1, 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, + is_active: detail.is_active, + steps: (detail.steps || []).map((s) => ({ + step_order: s.step_order, + 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, + })), + }); + setEditOpen(true); + }; + + const addStep = () => { + setFormData((p) => ({ + ...p, + steps: [ + ...p.steps, + { + step_order: p.steps.length + 1, + approver_type: "user" as const, + 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, field: string, value: string) => { + setFormData((p) => ({ + ...p, + steps: p.steps.map((s, i) => (i === idx ? { ...s, [field]: value } : s)), + })); + }; + + const handleSave = async () => { + if (!formData.template_name.trim()) { + toast.warning("템플릿명을 입력해주세요."); + return; + } + if (formData.steps.length === 0) { + toast.warning("결재 단계를 최소 1개 추가해주세요."); + return; + } + + const payload = { + template_name: formData.template_name, + description: formData.description || undefined, + definition_id: formData.definition_id || undefined, + is_active: formData.is_active, + steps: formData.steps, + }; + + let res; + if (editingTpl) { + res = await updateApprovalTemplate(editingTpl.template_id, payload); + } else { + res = await createApprovalTemplate(payload); + } + + 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()), + ); + + return ( +
+ {/* 검색 + 등록 */} +
+
+
+ + setSearchTerm(e.target.value)} + className="h-10 pl-10 text-sm" + /> +
+ + 총 {filtered.length} 건 + +
+ +
+ + {/* 테이블 */} + {loading ? ( +
+ +
+ ) : filtered.length === 0 ? ( +
+

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

+
+ ) : ( +
+ + + + 템플릿명 + 설명 + 연결된 유형 + 단계 수 + 상태 + 관리 + + + + {filtered.map((tpl) => ( + + {tpl.template_name} + {tpl.description || "-"} + {tpl.definition_name || "-"} + + {tpl.steps?.length || 0}단계 + + + + {tpl.is_active === "Y" ? "활성" : "비활성"} + + + +
+ + +
+
+
+ ))} +
+
+
+ )} + + {/* 등록/수정 모달 */} + + + + + {editingTpl ? "결재선 템플릿 수정" : "결재선 템플릿 등록"} + + + 결재선의 기본 정보와 결재 단계를 설정합니다. + + +
+
+ + setFormData((p) => ({ ...p, template_name: e.target.value }))} + placeholder="예: 일반 3단계 결재선" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + setFormData((p) => ({ ...p, description: e.target.value }))} + placeholder="템플릿에 대한 설명" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + +
+
+ + setFormData((p) => ({ ...p, is_active: v ? "Y" : "N" }))} + /> +
+ + {/* 결재 단계 설정 */} +
+
+ + +
+ + {formData.steps.length === 0 && ( +

+ 결재 단계를 추가해주세요. +

+ )} + + {formData.steps.map((step, idx) => ( +
+
+ {step.step_order}단계 + +
+
+
+ + +
+
+ + updateStep(idx, "approver_label", e.target.value)} + placeholder="예: 팀장" + className="h-7 text-xs" + /> +
+
+ {step.approver_type === "user" && ( +
+ + updateStep(idx, "approver_user_id", e.target.value)} + placeholder="고정 결재자 ID (비워두면 요청 시 지정)" + className="h-7 text-xs" + /> +
+ )} + {step.approver_type === "position" && ( +
+ + updateStep(idx, "approver_position", e.target.value)} + placeholder="예: 부장, 이사" + className="h-7 text-xs" + /> +
+ )} + {step.approver_type === "dept" && ( +
+ + updateStep(idx, "approver_dept_code", e.target.value)} + placeholder="예: DEPT001" + className="h-7 text-xs" + /> +
+ )} +
+ ))} +
+
+ + + + +
+
+ + {/* 삭제 확인 */} + setDeleteTarget(null)}> + + + 결재선 템플릿 삭제 + + "{deleteTarget?.template_name}"을(를) 삭제하시겠습니까? + + + + 취소 + + 삭제 + + + + +
+ ); +} + +// ============================================================ +// 메인 페이지 +// ============================================================ + +export default function ApprovalManagementPage() { + return ( +
+
+ {/* 페이지 헤더 */} +
+

결재 관리

+

+ 결재 유형과 결재선 템플릿을 관리합니다. +

+
+ + {/* 탭 */} + + + + + 결재 유형 + + + + 결재선 템플릿 + + + + + + + + + + + +
+ + +
+ ); +} diff --git a/frontend/components/approval/ApprovalRequestModal.tsx b/frontend/components/approval/ApprovalRequestModal.tsx index 83dd4772..4aab8cec 100644 --- a/frontend/components/approval/ApprovalRequestModal.tsx +++ b/frontend/components/approval/ApprovalRequestModal.tsx @@ -1,32 +1,30 @@ "use client"; -import React, { useState, useEffect, useCallback } from "react"; -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; -import { Plus, X, Loader2 } from "lucide-react"; -import { - getApprovalDefinitions, - getApprovalTemplates, - createApprovalRequest, - type ApprovalDefinition, - type ApprovalLineTemplate, -} from "@/lib/api/approval"; +import { Badge } from "@/components/ui/badge"; +import { Plus, X, Loader2, Search, GripVertical, Users, ArrowDown, Layers } from "lucide-react"; +import { toast } from "sonner"; +import { createApprovalRequest } from "@/lib/api/approval"; +import { getUserList } from "@/lib/api/user"; + +// 결재 방식 +type ApprovalMode = "sequential" | "parallel"; -// 결재자 행 타입 interface ApproverRow { - id: string; // 로컬 임시 ID - approver_id: string; - approver_name: string; - approver_position: string; - approver_dept: string; - approver_label: string; + id: string; + user_id: string; + user_name: string; + position_name: string; + dept_name: string; } -// 모달 열기 이벤트로 전달되는 데이터 export interface ApprovalModalEventDetail { targetTable: string; targetRecordId: string; @@ -42,8 +40,17 @@ interface ApprovalRequestModalProps { eventDetail?: ApprovalModalEventDetail | null; } -function generateLocalId(): string { - return `row_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; +interface UserSearchResult { + user_id: string; + user_name: string; + position_name?: string; + dept_name?: string; + dept_code?: string; + email?: string; +} + +function genId(): string { + return `a_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; } export const ApprovalRequestModal: React.FC = ({ @@ -53,107 +60,97 @@ export const ApprovalRequestModal: React.FC = ({ }) => { const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); - const [selectedDefinitionId, setSelectedDefinitionId] = useState(""); - const [selectedTemplateId, setSelectedTemplateId] = useState(""); + const [approvalMode, setApprovalMode] = useState("sequential"); const [approvers, setApprovers] = useState([]); - const [definitions, setDefinitions] = useState([]); - const [templates, setTemplates] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); - const [isLoadingDefs, setIsLoadingDefs] = useState(false); - const [isLoadingTemplates, setIsLoadingTemplates] = useState(false); const [error, setError] = useState(null); - // 결재 유형 목록 로딩 - useEffect(() => { - if (!open) return; - const load = async () => { - setIsLoadingDefs(true); - const res = await getApprovalDefinitions({ is_active: "Y" }); - if (res.success && res.data) setDefinitions(res.data); - setIsLoadingDefs(false); - }; - load(); - }, [open]); - - // 결재 유형 변경 시 템플릿 목록 로딩 - useEffect(() => { - if (!selectedDefinitionId) { - setTemplates([]); - setSelectedTemplateId(""); - return; - } - const load = async () => { - setIsLoadingTemplates(true); - const res = await getApprovalTemplates({ - definition_id: Number(selectedDefinitionId), - is_active: "Y", - }); - if (res.success && res.data) setTemplates(res.data); - setIsLoadingTemplates(false); - }; - load(); - }, [selectedDefinitionId]); - - // 템플릿 선택 시 결재선 자동 세팅 - useEffect(() => { - if (!selectedTemplateId) return; - const template = templates.find((t) => String(t.template_id) === selectedTemplateId); - if (!template?.steps) return; - - const rows: ApproverRow[] = template.steps - .sort((a, b) => a.step_order - b.step_order) - .map((step) => ({ - id: generateLocalId(), - approver_id: step.approver_user_id || "", - approver_name: step.approver_label || "", - approver_position: step.approver_position || "", - approver_dept: step.approver_dept_code || "", - approver_label: step.approver_label || `${step.step_order}차 결재`, - })); - setApprovers(rows); - }, [selectedTemplateId, templates]); - - // eventDetail에서 definitionId 자동 세팅 - useEffect(() => { - if (open && eventDetail?.definitionId) { - setSelectedDefinitionId(String(eventDetail.definitionId)); - } - }, [open, eventDetail]); + // 사용자 검색 상태 + 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(""); - setSelectedDefinitionId(""); - setSelectedTemplateId(""); + setApprovalMode("sequential"); setApprovers([]); setError(null); + setSearchOpen(false); + setSearchQuery(""); + setSearchResults([]); } }, [open]); - const handleAddApprover = () => { + // 사용자 검색 (디바운스) + 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 users: UserSearchResult[] = Array.isArray(data) ? data : []; + // 이미 추가된 결재자 제외 + const existingIds = new Set(approvers.map((a) => a.user_id)); + setSearchResults(users.filter((u) => !existingIds.has(u.user_id))); + } 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: generateLocalId(), - approver_id: "", - approver_name: "", - approver_position: "", - approver_dept: "", - approver_label: `${prev.length + 1}차 결재`, + id: genId(), + user_id: user.user_id, + user_name: user.user_name, + position_name: user.position_name || "", + dept_name: user.dept_name || "", }, ]); + setSearchQuery(""); + setSearchResults([]); + setSearchOpen(false); }; - const handleRemoveApprover = (id: string) => { + const removeApprover = (id: string) => { setApprovers((prev) => prev.filter((a) => a.id !== id)); }; - const handleApproverChange = (id: string, field: keyof ApproverRow, value: string) => { - setApprovers((prev) => - prev.map((a) => (a.id === id ? { ...a, [field]: value } : a)) - ); + 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 () => { @@ -165,14 +162,8 @@ export const ApprovalRequestModal: React.FC = ({ setError("결재자를 1명 이상 추가해주세요."); return; } - const emptyApprover = approvers.find((a) => !a.approver_id.trim()); - if (emptyApprover) { - setError("모든 결재자의 사번/ID를 입력해주세요."); - return; - } - if (!eventDetail?.targetTable || !eventDetail?.targetRecordId) { - setError("결재 대상 정보가 없습니다. 버튼 설정을 확인해주세요."); + setError("결재 대상 정보가 없습니다. 레코드를 선택 후 다시 시도해주세요."); return; } @@ -182,24 +173,30 @@ export const ApprovalRequestModal: React.FC = ({ const res = await createApprovalRequest({ title: title.trim(), description: description.trim() || undefined, - definition_id: selectedDefinitionId ? Number(selectedDefinitionId) : undefined, target_table: eventDetail.targetTable, target_record_id: eventDetail.targetRecordId, - target_record_data: eventDetail.targetRecordData, + target_record_data: { + ...eventDetail.targetRecordData, + approval_mode: approvalMode, + }, screen_id: eventDetail.screenId, button_component_id: eventDetail.buttonComponentId, - approvers: approvers.map((a) => ({ - approver_id: a.approver_id.trim(), - approver_name: a.approver_name.trim() || undefined, - approver_position: a.approver_position.trim() || undefined, - approver_dept: a.approver_dept.trim() || undefined, - approver_label: a.approver_label.trim() || undefined, + approvers: 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, + approver_label: + approvalMode === "sequential" + ? `${idx + 1}차 결재` + : "동시 결재", })), }); setIsSubmitting(false); if (res.success) { + toast.success("결재 요청이 완료되었습니다."); onOpenChange(false); } else { setError(res.error || res.message || "결재 요청에 실패했습니다."); @@ -210,13 +207,13 @@ export const ApprovalRequestModal: React.FC = ({ - 결재 요청 + 결재 상신 - 결재자를 지정하고 결재를 요청합니다. + 결재 방식을 선택하고 결재자를 검색하여 추가합니다. -
+
{/* 결재 제목 */}