From e662de1da41d6826792189604cfc167735dab2cd Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 5 Mar 2026 23:06:36 +0900 Subject: [PATCH] [agent-pipeline] pipe-20260305133525-uca5 round-4 --- .../app/(main)/admin/approvalBox/page.tsx | 586 ++++++++++- .../(main)/admin/approvalTemplate/page.tsx | 956 ++++++++++++++++++ .../approval/ApprovalRequestModal.tsx | 586 +++++++---- .../ApprovalStepComponent.tsx | 462 +++++++-- .../components/v2-approval-step/types.ts | 32 + .../error-context.md | 0 6 files changed, 2354 insertions(+), 268 deletions(-) create mode 100644 frontend/app/(main)/admin/approvalTemplate/page.tsx rename test-results/{e2e-browser-test-177271836-4651c-접속-후-로그인-wace-qlalfqjsgh11- => e2e-browser-test-177271953-bcfcc-접속-후-로그인-wace-qlalfqjsgh11-}/error-context.md (100%) diff --git a/frontend/app/(main)/admin/approvalBox/page.tsx b/frontend/app/(main)/admin/approvalBox/page.tsx index 2a979829..9a139014 100644 --- a/frontend/app/(main)/admin/approvalBox/page.tsx +++ b/frontend/app/(main)/admin/approvalBox/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -12,9 +12,12 @@ import { } from "@/components/ui/dialog"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; import { toast } from "sonner"; import { Loader2, Send, Inbox, CheckCircle, XCircle, Clock, Eye, + UserCog, Plus, Pencil, Trash2, Search, } from "lucide-react"; import { ScrollToTop } from "@/components/common/ScrollToTop"; import { @@ -23,9 +26,15 @@ import { getMyPendingApprovals, processApprovalLine, cancelApprovalRequest, + getProxySettings, + createProxySetting, + updateProxySetting, + deleteProxySetting, type ApprovalRequest, type ApprovalLine, + type ApprovalProxySetting, } from "@/lib/api/approval"; +import { getUserList } from "@/lib/api/user"; const STATUS_MAP: Record = { requested: { label: "요청", variant: "outline" }, @@ -378,6 +387,574 @@ function ReceivedTab() { ); } +// ============================================================ +// 대결 설정 +// ============================================================ + +interface UserSearchResult { + userId: string; + userName: string; + positionName?: string; + deptName?: string; +} + +function formatDateOnly(dateStr?: string) { + if (!dateStr) return "-"; + return new Date(dateStr).toLocaleDateString("ko-KR", { + year: "numeric", month: "2-digit", day: "2-digit", + }); +} + +function ProxyTab() { + const [proxies, setProxies] = useState([]); + const [loading, setLoading] = useState(true); + + // 등록/수정 모달 상태 + const [modalOpen, setModalOpen] = useState(false); + const [editingProxy, setEditingProxy] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + // 폼 필드 + const [formOriginalUserId, setFormOriginalUserId] = useState(""); + const [formOriginalUserLabel, setFormOriginalUserLabel] = useState(""); + const [formProxyUserId, setFormProxyUserId] = useState(""); + const [formProxyUserLabel, setFormProxyUserLabel] = useState(""); + const [formStartDate, setFormStartDate] = useState(""); + const [formEndDate, setFormEndDate] = useState(""); + const [formReason, setFormReason] = useState(""); + const [formIsActive, setFormIsActive] = useState(true); + + // 사용자 검색 상태 (원래 결재자) + const [origSearchQuery, setOrigSearchQuery] = useState(""); + const [origSearchResults, setOrigSearchResults] = useState([]); + const [origSearchOpen, setOrigSearchOpen] = useState(false); + const [origSearching, setOrigSearching] = useState(false); + const origTimerRef = useRef(null); + + // 사용자 검색 상태 (대결자) + const [proxySearchQuery, setProxySearchQuery] = useState(""); + const [proxySearchResults, setProxySearchResults] = useState([]); + const [proxySearchOpen, setProxySearchOpen] = useState(false); + const [proxySearching, setProxySearching] = useState(false); + const proxyTimerRef = useRef(null); + + // 삭제 확인 모달 + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [deletingId, setDeletingId] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + const fetchProxies = useCallback(async () => { + setLoading(true); + const res = await getProxySettings(); + if (res.success && res.data) setProxies(res.data); + setLoading(false); + }, []); + + useEffect(() => { fetchProxies(); }, [fetchProxies]); + + // 사용자 검색 공통 로직 + const searchUsers = useCallback(async ( + query: string, + setResults: (r: UserSearchResult[]) => void, + setSearching: (b: boolean) => void, + ) => { + if (!query.trim() || query.trim().length < 1) { + setResults([]); + return; + } + setSearching(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 || "", + })); + setResults(users); + } catch { + setResults([]); + } finally { + setSearching(false); + } + }, []); + + // 원래 결재자 검색 디바운스 + useEffect(() => { + if (origTimerRef.current) clearTimeout(origTimerRef.current); + if (!origSearchQuery.trim()) { setOrigSearchResults([]); return; } + origTimerRef.current = setTimeout(() => { + searchUsers(origSearchQuery, setOrigSearchResults, setOrigSearching); + }, 300); + return () => { if (origTimerRef.current) clearTimeout(origTimerRef.current); }; + }, [origSearchQuery, searchUsers]); + + // 대결자 검색 디바운스 + useEffect(() => { + if (proxyTimerRef.current) clearTimeout(proxyTimerRef.current); + if (!proxySearchQuery.trim()) { setProxySearchResults([]); return; } + proxyTimerRef.current = setTimeout(() => { + searchUsers(proxySearchQuery, setProxySearchResults, setProxySearching); + }, 300); + return () => { if (proxyTimerRef.current) clearTimeout(proxyTimerRef.current); }; + }, [proxySearchQuery, searchUsers]); + + const resetForm = () => { + setFormOriginalUserId(""); + setFormOriginalUserLabel(""); + setFormProxyUserId(""); + setFormProxyUserLabel(""); + setFormStartDate(""); + setFormEndDate(""); + setFormReason(""); + setFormIsActive(true); + setOrigSearchQuery(""); + setOrigSearchResults([]); + setOrigSearchOpen(false); + setProxySearchQuery(""); + setProxySearchResults([]); + setProxySearchOpen(false); + setEditingProxy(null); + }; + + const openCreate = () => { + resetForm(); + setModalOpen(true); + }; + + const openEdit = (proxy: ApprovalProxySetting) => { + setEditingProxy(proxy); + setFormOriginalUserId(proxy.original_user_id); + setFormOriginalUserLabel( + proxy.original_user_name + ? `${proxy.original_user_name}${proxy.original_dept_name ? ` (${proxy.original_dept_name})` : ""}` + : proxy.original_user_id + ); + setFormProxyUserId(proxy.proxy_user_id); + setFormProxyUserLabel( + proxy.proxy_user_name + ? `${proxy.proxy_user_name}${proxy.proxy_dept_name ? ` (${proxy.proxy_dept_name})` : ""}` + : proxy.proxy_user_id + ); + setFormStartDate(proxy.start_date?.split("T")[0] || ""); + setFormEndDate(proxy.end_date?.split("T")[0] || ""); + setFormReason(proxy.reason || ""); + setFormIsActive(proxy.is_active === "Y"); + setOrigSearchQuery(""); + setOrigSearchResults([]); + setOrigSearchOpen(false); + setProxySearchQuery(""); + setProxySearchResults([]); + setProxySearchOpen(false); + setModalOpen(true); + }; + + const handleSave = async () => { + if (!formOriginalUserId) { toast.error("원래 결재자를 선택해주세요."); return; } + if (!formProxyUserId) { toast.error("대결자를 선택해주세요."); return; } + if (!formStartDate) { toast.error("시작일을 입력해주세요."); return; } + if (!formEndDate) { toast.error("종료일을 입력해주세요."); return; } + if (formStartDate > formEndDate) { toast.error("종료일은 시작일 이후여야 합니다."); return; } + if (formOriginalUserId === formProxyUserId) { toast.error("원래 결재자와 대결자가 같을 수 없습니다."); return; } + + setIsSaving(true); + try { + if (editingProxy) { + const res = await updateProxySetting(editingProxy.id, { + proxy_user_id: formProxyUserId, + start_date: formStartDate, + end_date: formEndDate, + reason: formReason.trim() || undefined, + is_active: formIsActive ? "Y" : "N", + }); + if (res.success) { + toast.success("대결 설정이 수정되었습니다."); + setModalOpen(false); + resetForm(); + fetchProxies(); + } else { + toast.error(res.error || "수정 실패"); + } + } else { + const res = await createProxySetting({ + original_user_id: formOriginalUserId, + proxy_user_id: formProxyUserId, + start_date: formStartDate, + end_date: formEndDate, + reason: formReason.trim() || undefined, + }); + if (res.success) { + toast.success("대결 설정이 등록되었습니다."); + setModalOpen(false); + resetForm(); + fetchProxies(); + } else { + toast.error(res.error || "등록 실패"); + } + } + } catch { + toast.error("요청 처리 중 오류가 발생했습니다."); + } finally { + setIsSaving(false); + } + }; + + const confirmDelete = (id: number) => { + setDeletingId(id); + setDeleteConfirmOpen(true); + }; + + const handleDelete = async () => { + if (!deletingId) return; + setIsDeleting(true); + const res = await deleteProxySetting(deletingId); + setIsDeleting(false); + if (res.success) { + toast.success("대결 설정이 삭제되었습니다."); + setDeleteConfirmOpen(false); + setDeletingId(null); + fetchProxies(); + } else { + toast.error(res.error || "삭제 실패"); + } + }; + + const selectOrigUser = (user: UserSearchResult) => { + setFormOriginalUserId(user.userId); + setFormOriginalUserLabel(`${user.userName}${user.deptName ? ` (${user.deptName})` : ""}`); + setOrigSearchOpen(false); + setOrigSearchQuery(""); + setOrigSearchResults([]); + }; + + const selectProxyUser = (user: UserSearchResult) => { + setFormProxyUserId(user.userId); + setFormProxyUserLabel(`${user.userName}${user.deptName ? ` (${user.deptName})` : ""}`); + setProxySearchOpen(false); + setProxySearchQuery(""); + setProxySearchResults([]); + }; + + return ( +
+ {/* 상단 액션 */} +
+

+ 결재자가 부재 시 대결자가 대신 결재를 처리합니다. +

+ +
+ + {/* 목록 */} + {loading ? ( +
+ +
+ ) : proxies.length === 0 ? ( +
+ +

등록된 대결 설정이 없습니다.

+
+ ) : ( +
+ + + + 원래 결재자 + 대결자 + 시작일 + 종료일 + 사유 + 활성 + 관리 + + + + {proxies.map((proxy) => ( + + + {proxy.original_user_name || proxy.original_user_id} + {proxy.original_dept_name && ( + ({proxy.original_dept_name}) + )} + + + {proxy.proxy_user_name || proxy.proxy_user_id} + {proxy.proxy_dept_name && ( + ({proxy.proxy_dept_name}) + )} + + + {formatDateOnly(proxy.start_date)} + + + {formatDateOnly(proxy.end_date)} + + + {proxy.reason || "-"} + + + + {proxy.is_active === "Y" ? "활성" : "비활성"} + + + +
+ + +
+
+
+ ))} +
+
+
+ )} + + {/* 등록/수정 모달 */} + { if (!open) { resetForm(); } setModalOpen(open); }}> + + + + {editingProxy ? "대결 설정 수정" : "대결 설정 등록"} + + + 결재자 부재 시 대신 결재할 대결자를 설정합니다. + + + +
+ {/* 원래 결재자 */} +
+ + {editingProxy ? ( + + ) : ( +
+
+ + { + if (formOriginalUserId) { + setFormOriginalUserId(""); + setFormOriginalUserLabel(""); + } + setOrigSearchQuery(e.target.value); + setOrigSearchOpen(true); + }} + onFocus={() => { if (origSearchQuery.trim()) setOrigSearchOpen(true); }} + placeholder="이름 또는 ID로 검색" + className="h-8 pl-10 text-xs sm:h-10 sm:text-sm" + /> +
+ {origSearchOpen && (origSearchResults.length > 0 || origSearching) && ( +
+ {origSearching ? ( +
+ +
+ ) : ( +
+ {origSearchResults.map((user) => ( +
selectOrigUser(user)} + > + {user.userName} + + {user.userId} + {user.deptName ? ` / ${user.deptName}` : ""} + {user.positionName ? ` / ${user.positionName}` : ""} + +
+ ))} +
+ )} +
+ )} +
+ )} +
+ + {/* 대결자 */} +
+ +
+
+ + { + if (formProxyUserId) { + setFormProxyUserId(""); + setFormProxyUserLabel(""); + } + setProxySearchQuery(e.target.value); + setProxySearchOpen(true); + }} + onFocus={() => { if (proxySearchQuery.trim()) setProxySearchOpen(true); }} + placeholder="이름 또는 ID로 검색" + className="h-8 pl-10 text-xs sm:h-10 sm:text-sm" + /> +
+ {proxySearchOpen && (proxySearchResults.length > 0 || proxySearching) && ( +
+ {proxySearching ? ( +
+ +
+ ) : ( +
+ {proxySearchResults.map((user) => ( +
selectProxyUser(user)} + > + {user.userName} + + {user.userId} + {user.deptName ? ` / ${user.deptName}` : ""} + {user.positionName ? ` / ${user.positionName}` : ""} + +
+ ))} +
+ )} +
+ )} +
+
+ + {/* 시작일 / 종료일 */} +
+
+ + setFormStartDate(e.target.value)} + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + setFormEndDate(e.target.value)} + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + {/* 사유 */} +
+ +