'use client'; import { useState, useMemo, useCallback, useEffect, useTransition, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { useDateRange } from '@/hooks'; import { FileCheck, Check, X, Clock, FileX, Files, } from 'lucide-react'; import { toast } from 'sonner'; import { getInbox, getInboxSummary, approveDocument, rejectDocument, approveDocumentsBulk, rejectDocumentsBulk, getDocumentApprovalById, } from './actions'; import { getApprovalById } from '@/components/approval/DocumentCreate/actions'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Badge } from '@/components/ui/badge'; import { TableRow, TableCell } from '@/components/ui/table'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { UniversalListPage, type UniversalListConfig, type TabOption, } from '@/components/templates/UniversalListPage'; import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard'; // import { DocumentDetailModal } from '@/components/approval/DocumentDetail'; import { DocumentDetailModalV2 as DocumentDetailModal } from '@/components/approval/DocumentDetail'; import type { DocumentType, ProposalDocumentData, ExpenseReportDocumentData, ExpenseEstimateDocumentData, LinkedDocumentData, } from '@/components/approval/DocumentDetail/types'; import type { ApprovalTabType, ApprovalRecord, ApprovalType, SortOption, FilterOption, } from './types'; import { APPROVAL_TAB_LABELS, SORT_OPTIONS, FILTER_OPTIONS, APPROVAL_TYPE_LABELS, APPROVAL_STATUS_LABELS, APPROVAL_STATUS_COLORS, } from './types'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { usePermission } from '@/hooks/usePermission'; import { InspectionReportModal } from '@/components/production/WorkOrders/documents/InspectionReportModal'; // ===== 통계 타입 ===== interface InboxSummary { total: number; pending: number; approved: number; rejected: number; } export function ApprovalBox() { const router = useRouter(); const [isPending, startTransition] = useTransition(); const { canApprove } = usePermission(); // ===== 상태 관리 ===== const [activeTab, setActiveTab] = useState('all'); const [searchQuery, setSearchQuery] = useState(''); const [filterOption, setFilterOption] = useState('all'); const [sortOption, setSortOption] = useState('latest'); const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 20; // 날짜 범위 상태 const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear'); // 다이얼로그 상태 const [approveDialogOpen, setApproveDialogOpen] = useState(false); const [rejectDialogOpen, setRejectDialogOpen] = useState(false); const [rejectComment, setRejectComment] = useState(''); const [pendingSelectedItems, setPendingSelectedItems] = useState>(new Set()); const [pendingClearSelection, setPendingClearSelection] = useState<(() => void) | null>(null); // ===== 문서 상세 모달 상태 ===== const [isModalOpen, setIsModalOpen] = useState(false); const [selectedDocument, setSelectedDocument] = useState(null); const [modalData, setModalData] = useState(null); const [isModalLoading, setIsModalLoading] = useState(false); // ===== 검사성적서 모달 상태 (work_order 연결 문서용) ===== const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false); const [inspectionWorkOrderId, setInspectionWorkOrderId] = useState(null); // API 데이터 const [data, setData] = useState([]); const [totalCount, setTotalCount] = useState(0); const [totalPages, setTotalPages] = useState(1); const [isLoading, setIsLoading] = useState(true); const isInitialLoadDone = useRef(false); // 통계 데이터 const [fixedStats, setFixedStats] = useState({ all: 0, pending: 0, approved: 0, rejected: 0 }); // ===== 데이터 로드 ===== const loadData = useCallback(async () => { if (!isInitialLoadDone.current) { setIsLoading(true); } try { const sortConfig: { sort_by: string; sort_dir: 'asc' | 'desc' } = (() => { switch (sortOption) { case 'latest': return { sort_by: 'created_at', sort_dir: 'desc' }; case 'oldest': return { sort_by: 'created_at', sort_dir: 'asc' }; case 'draftDateAsc': return { sort_by: 'created_at', sort_dir: 'asc' }; case 'draftDateDesc': return { sort_by: 'created_at', sort_dir: 'desc' }; default: return { sort_by: 'created_at', sort_dir: 'desc' }; } })(); const result = await getInbox({ page: currentPage, per_page: itemsPerPage, search: searchQuery || undefined, status: activeTab !== 'all' ? activeTab : undefined, approval_type: filterOption !== 'all' ? filterOption : undefined, start_date: startDate || undefined, end_date: endDate || undefined, ...sortConfig, }); setData(result.data); setTotalCount(result.total); setTotalPages(result.lastPage); } catch (error) { if (isNextRedirectError(error)) throw error; console.error('Failed to load inbox:', error); toast.error('결재함 목록을 불러오는데 실패했습니다.'); } finally { setIsLoading(false); isInitialLoadDone.current = true; } }, [currentPage, itemsPerPage, searchQuery, filterOption, sortOption, activeTab, startDate, endDate]); // ===== 초기 로드 ===== useEffect(() => { loadData(); }, [loadData]); // ===== 검색어/필터/탭 변경 시 페이지 초기화 ===== useEffect(() => { setCurrentPage(1); }, [searchQuery, filterOption, sortOption, activeTab]); // ===== 탭 변경 핸들러 ===== const handleTabChange = useCallback((value: string) => { setActiveTab(value as ApprovalTabType); setSearchQuery(''); }, []); // ===== 전체 탭일 때만 통계 업데이트 ===== useEffect(() => { if (activeTab === 'all' && data.length > 0) { const pending = data.filter((item) => item.status === 'pending').length; const approved = data.filter((item) => item.status === 'approved').length; const rejected = data.filter((item) => item.status === 'rejected').length; setFixedStats({ all: totalCount, pending, approved, rejected, }); } }, [data, totalCount, activeTab]); // ===== 승인/반려 핸들러 ===== const handleApproveClick = useCallback( (selectedItems: Set, onClearSelection: () => void) => { if (selectedItems.size === 0) return; setPendingSelectedItems(selectedItems); setPendingClearSelection(() => onClearSelection); setApproveDialogOpen(true); }, [] ); const handleApproveConfirm = useCallback(async () => { const ids = Array.from(pendingSelectedItems); startTransition(async () => { try { const result = await approveDocumentsBulk(ids); if (result.success) { toast.success('승인 완료', { description: '결재 승인이 완료되었습니다.', }); pendingClearSelection?.(); loadData(); } else { toast.error(result.error || '승인 처리에 실패했습니다.'); } } catch (error) { if (isNextRedirectError(error)) throw error; console.error('Approve error:', error); toast.error('승인 처리 중 오류가 발생했습니다.'); } }); setApproveDialogOpen(false); setPendingSelectedItems(new Set()); setPendingClearSelection(null); }, [pendingSelectedItems, pendingClearSelection, loadData]); const handleRejectClick = useCallback( (selectedItems: Set, onClearSelection: () => void) => { if (selectedItems.size === 0) return; setPendingSelectedItems(selectedItems); setPendingClearSelection(() => onClearSelection); setRejectComment(''); setRejectDialogOpen(true); }, [] ); const handleRejectConfirm = useCallback(async () => { if (!rejectComment.trim()) { toast.error('반려 사유를 입력해주세요.'); return; } const ids = Array.from(pendingSelectedItems); startTransition(async () => { try { const result = await rejectDocumentsBulk(ids, rejectComment); if (result.success) { toast.success('반려 완료', { description: '결재 반려가 완료되었습니다.', }); pendingClearSelection?.(); setRejectComment(''); loadData(); } else { toast.error(result.error || '반려 처리에 실패했습니다.'); } } catch (error) { if (isNextRedirectError(error)) throw error; console.error('Reject error:', error); toast.error('반려 처리 중 오류가 발생했습니다.'); } }); setRejectDialogOpen(false); setPendingSelectedItems(new Set()); setPendingClearSelection(null); }, [pendingSelectedItems, rejectComment, pendingClearSelection, loadData]); // ===== 문서 클릭 핸들러 ===== const handleDocumentClick = useCallback(async (item: ApprovalRecord) => { setSelectedDocument(item); setIsModalLoading(true); setIsModalOpen(true); try { // 문서 결재(document) 타입은 별도 API로 연결 문서 데이터 조회 if (item.approvalType === 'document') { const result = await getDocumentApprovalById(parseInt(item.id)); if (result.success && result.data) { // work_order 연결 문서 → InspectionReportModal로 열기 if (result.data.workOrderId) { setIsModalOpen(false); setIsModalLoading(false); setInspectionWorkOrderId(String(result.data.workOrderId)); setIsInspectionModalOpen(true); return; } setModalData(result.data as LinkedDocumentData); } else { toast.error(result.error || '문서 조회에 실패했습니다.'); setIsModalOpen(false); } return; } // 기존 결재 문서 타입 (품의서, 지출결의서, 지출예상내역서) const result = await getApprovalById(parseInt(item.id)); if (result.success && result.data) { const formData = result.data; const docType = getDocumentType(item.approvalType); // 기안자 정보 const drafter = { id: 'drafter-1', name: formData.basicInfo.drafter, position: formData.basicInfo.drafterPosition || '', department: formData.basicInfo.drafterDepartment || '', status: 'approved' as const, }; // 결재자 정보 const approvers = formData.approvalLine.map((person, index) => ({ id: person.id, name: person.name, position: person.position, department: person.department, status: item.status === 'approved' ? ('approved' as const) : item.status === 'rejected' ? ('rejected' as const) : index === 0 ? ('pending' as const) : ('none' as const), })); let convertedData: ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData; switch (docType) { case 'expenseEstimate': convertedData = { documentNo: formData.basicInfo.documentNo, createdAt: formData.basicInfo.draftDate, items: formData.expenseEstimateData?.items.map(item => ({ id: item.id, expectedPaymentDate: item.expectedPaymentDate, category: item.category, amount: item.amount, vendor: item.vendor, account: item.memo || '', })) || [], totalExpense: formData.expenseEstimateData?.totalExpense || 0, accountBalance: formData.expenseEstimateData?.accountBalance || 0, finalDifference: formData.expenseEstimateData?.finalDifference || 0, approvers, drafter, }; break; case 'expenseReport': convertedData = { documentNo: formData.basicInfo.documentNo, createdAt: formData.basicInfo.draftDate, requestDate: formData.expenseReportData?.requestDate || '', paymentDate: formData.expenseReportData?.paymentDate || '', items: formData.expenseReportData?.items.map((item, index) => ({ id: item.id, no: index + 1, description: item.description, amount: item.amount, note: item.note, })) || [], cardInfo: formData.expenseReportData?.cardId || '-', totalAmount: formData.expenseReportData?.totalAmount || 0, attachments: formData.expenseReportData?.uploadedFiles?.map(f => f.name) || [], approvers, drafter, }; break; default: // 품의서 const uploadedFileUrls = (formData.proposalData?.uploadedFiles || []).map(f => `/api/proxy/files/${f.id}/download` ); convertedData = { documentNo: formData.basicInfo.documentNo, createdAt: formData.basicInfo.draftDate, vendor: formData.proposalData?.vendor || '-', vendorPaymentDate: formData.proposalData?.vendorPaymentDate || '', title: formData.proposalData?.title || item.title, description: formData.proposalData?.description || '-', reason: formData.proposalData?.reason || '-', estimatedCost: formData.proposalData?.estimatedCost || 0, attachments: uploadedFileUrls, approvers, drafter, }; break; } setModalData(convertedData); } else { toast.error(result.error || '문서 조회에 실패했습니다.'); setIsModalOpen(false); } } catch (error) { if (isNextRedirectError(error)) throw error; console.error('Failed to load document:', error); toast.error('문서를 불러오는데 실패했습니다.'); setIsModalOpen(false); } finally { setIsModalLoading(false); } }, []); const handleModalEdit = useCallback(() => { if (selectedDocument) { router.push(`/ko/approval/draft/new?id=${selectedDocument.id}&mode=edit`); setIsModalOpen(false); } }, [selectedDocument, router]); const handleModalCopy = useCallback(() => { toast.info('문서 복제 기능은 준비 중입니다.'); setIsModalOpen(false); }, []); const handleModalApprove = useCallback(async () => { if (!selectedDocument?.id) return; const result = await approveDocument(selectedDocument.id); if (result.success) { toast.success('문서가 승인되었습니다.'); loadData(); } else { toast.error(result.error || '승인에 실패했습니다.'); } setIsModalOpen(false); }, [selectedDocument, loadData]); const handleModalReject = useCallback(async () => { if (!selectedDocument?.id) return; const result = await rejectDocument(selectedDocument.id, '반려'); if (result.success) { toast.success('문서가 반려되었습니다.'); loadData(); } else { toast.error(result.error || '반려에 실패했습니다.'); } setIsModalOpen(false); }, [selectedDocument, loadData]); // ===== 문서 타입 변환 ===== const getDocumentType = (approvalType: ApprovalType): DocumentType => { switch (approvalType) { case 'expense_estimate': return 'expenseEstimate'; case 'expense_report': return 'expenseReport'; case 'document': return 'document'; default: return 'proposal'; } }; // ===== 탭 옵션 ===== const tabs: TabOption[] = useMemo( () => [ { value: 'all', label: APPROVAL_TAB_LABELS.all, count: fixedStats.all, color: 'blue', }, { value: 'pending', label: APPROVAL_TAB_LABELS.pending, count: fixedStats.pending, color: 'yellow', }, { value: 'approved', label: APPROVAL_TAB_LABELS.approved, count: fixedStats.approved, color: 'green', }, { value: 'rejected', label: APPROVAL_TAB_LABELS.rejected, count: fixedStats.rejected, color: 'red', }, ], [fixedStats] ); // ===== UniversalListPage 설정 ===== const approvalBoxConfig: UniversalListConfig = useMemo( () => ({ title: '결재함', description: '결재 문서를 관리합니다', icon: FileCheck, basePath: '/approval/inbox', idField: 'id', actions: { getList: async () => ({ success: true, data: data, totalCount: totalCount, totalPages: totalPages, }), }, columns: [ { key: 'no', label: '번호', className: 'w-[60px] text-center' }, { key: 'documentNo', label: '문서번호' }, { key: 'approvalType', label: '문서유형' }, { key: 'title', label: '제목' }, { key: 'drafter', label: '기안자' }, { key: 'approver', label: '결재자' }, { key: 'draftDate', label: '기안일시' }, { key: 'status', label: '상태', className: 'text-center' }, ], tabs: tabs, defaultTab: activeTab, // 검색창 (공통 컴포넌트에서 자동 생성) hideSearch: true, searchValue: searchQuery, onSearchChange: setSearchQuery, dateRangeSelector: { enabled: true, showPresets: true, startDate, endDate, onStartDateChange: setStartDate, onEndDateChange: setEndDate, }, searchPlaceholder: '제목, 기안자, 부서 검색...', searchFilter: (item: ApprovalRecord, search: string) => { const s = search.toLowerCase(); return ( item.title?.toLowerCase().includes(s) || item.drafter?.toLowerCase().includes(s) || item.drafterDepartment?.toLowerCase().includes(s) || false ); }, itemsPerPage: itemsPerPage, // 모바일 필터 설정 filterConfig: [ { key: 'approvalType', label: '문서유형', type: 'single', options: FILTER_OPTIONS.filter((o) => o.value !== 'all'), }, { key: 'sort', label: '정렬', type: 'single', options: SORT_OPTIONS, }, ], initialFilters: { approvalType: filterOption, sort: sortOption, }, filterTitle: '결재함 필터', computeStats: () => [ { label: '전체결재', value: `${fixedStats.all}건`, icon: Files, iconColor: 'text-blue-500', }, { label: '미결재', value: `${fixedStats.pending}건`, icon: Clock, iconColor: 'text-yellow-500', }, { label: '결재완료', value: `${fixedStats.approved}건`, icon: FileCheck, iconColor: 'text-green-500', }, { label: '결재반려', value: `${fixedStats.rejected}건`, icon: FileX, iconColor: 'text-red-500', }, ], selectionActions: ({ selectedItems, onClearSelection }) => canApprove ? ( <> ) : null, tableHeaderActions: (
), renderTableRow: (item, index, globalIndex, handlers) => { const { isSelected, onToggle } = handlers; return ( handleDocumentClick(item)} > e.stopPropagation()}> {globalIndex} {item.documentNo} {APPROVAL_TYPE_LABELS[item.approvalType]} {item.title} {item.drafter} {item.approver || '-'} {item.draftDate} {APPROVAL_STATUS_LABELS[item.status]} ); }, renderMobileCard: (item, index, globalIndex, handlers) => { const { isSelected, onToggle } = handlers; return ( {APPROVAL_TYPE_LABELS[item.approvalType]} {APPROVAL_STATUS_LABELS[item.status]} } isSelected={isSelected} onToggleSelection={onToggle} infoGrid={
} actions={ item.status === 'pending' && isSelected && canApprove ? (
) : undefined } onClick={() => handleDocumentClick(item)} /> ); }, renderDialogs: () => ( <> {/* 승인 확인 다이얼로그 */} {/* 반려 확인 다이얼로그 */} 결재 반려 {pendingSelectedItems.size}건의 결재를 반려합니다. 반려 사유를 입력해주세요.