'use client'; import { useState, useMemo, useCallback, useEffect, useTransition } from 'react'; import { useRouter } from 'next/navigation'; import { FileText, Send, Trash2, Plus, Pencil, Bell, } from 'lucide-react'; import { toast } from 'sonner'; import { getDrafts, getDraftsSummary, getDraftById, deleteDraft, deleteDrafts, submitDraft, submitDrafts, } from './actions'; import { sendApprovalNotification } from '@/lib/actions/fcm'; 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { UniversalListPage, type UniversalListConfig, } 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, } from '@/components/approval/DocumentDetail/types'; import type { DraftRecord, DocumentStatus, Approver, SortOption, FilterOption, } from './types'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { SORT_OPTIONS, FILTER_OPTIONS, DOCUMENT_STATUS_LABELS, DOCUMENT_STATUS_COLORS, } from './types'; // ===== 통계 타입 ===== interface DraftsSummary { total: number; draft: number; pending: number; approved: number; rejected: number; } export function DraftBox() { const router = useRouter(); const [isPending, startTransition] = useTransition(); // ===== 상태 관리 ===== const [searchQuery, setSearchQuery] = useState(''); const [filterOption, setFilterOption] = useState('all'); const [sortOption, setSortOption] = useState('latest'); const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 20; // 날짜 범위 상태 const [startDate, setStartDate] = useState('2025-01-01'); const [endDate, setEndDate] = useState('2025-12-31'); // API 데이터 const [data, setData] = useState([]); const [totalCount, setTotalCount] = useState(0); const [totalPages, setTotalPages] = useState(1); const [isLoading, setIsLoading] = useState(true); // 통계 데이터 const [summary, setSummary] = useState(null); // ===== 문서 상세 모달 상태 ===== const [isModalOpen, setIsModalOpen] = useState(false); const [selectedDocument, setSelectedDocument] = useState(null); // ===== 데이터 로드 ===== const loadData = useCallback(async () => { 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 'titleAsc': return { sort_by: 'title', sort_dir: 'asc' }; case 'titleDesc': return { sort_by: 'title', sort_dir: 'desc' }; default: return { sort_by: 'created_at', sort_dir: 'desc' }; } })(); const result = await getDrafts({ page: currentPage, per_page: itemsPerPage, search: searchQuery || undefined, status: filterOption !== 'all' ? filterOption : undefined, ...sortConfig, }); setData(result.data); setTotalCount(result.total); setTotalPages(result.lastPage); } catch (error) { if (isNextRedirectError(error)) throw error; console.error('Failed to load drafts:', error); toast.error('기안함 목록을 불러오는데 실패했습니다.'); } finally { setIsLoading(false); } }, [currentPage, itemsPerPage, searchQuery, filterOption, sortOption]); // ===== 통계 로드 ===== const loadSummary = useCallback(async () => { try { const result = await getDraftsSummary(); setSummary(result); } catch (error) { if (isNextRedirectError(error)) throw error; console.error('Failed to load summary:', error); } }, []); // ===== 초기 로드 및 필터 변경 시 데이터 재로드 ===== useEffect(() => { loadData(); }, [loadData]); useEffect(() => { loadSummary(); }, [loadSummary]); // ===== 검색어 변경 시 페이지 초기화 ===== useEffect(() => { setCurrentPage(1); }, [searchQuery, filterOption, sortOption]); // ===== 액션 핸들러 ===== const handleSubmit = useCallback( async (selectedItems: Set) => { const ids = Array.from(selectedItems); if (ids.length === 0) return; startTransition(async () => { try { const result = await submitDrafts(ids); if (result.success) { toast.success(`${ids.length}건의 문서를 상신했습니다.`); loadData(); loadSummary(); } else { toast.error(result.error || '상신에 실패했습니다.'); } } catch (error) { if (isNextRedirectError(error)) throw error; console.error('Submit error:', error); toast.error('상신 중 오류가 발생했습니다.'); } }); }, [loadData, loadSummary] ); const handleDelete = useCallback( async (selectedItems: Set) => { const ids = Array.from(selectedItems); if (ids.length === 0) return; startTransition(async () => { try { const result = await deleteDrafts(ids); if (result.success) { toast.success(`${ids.length}건의 문서를 삭제했습니다.`); loadData(); loadSummary(); } else { toast.error(result.error || '삭제에 실패했습니다.'); } } catch (error) { if (isNextRedirectError(error)) throw error; console.error('Delete error:', error); toast.error('삭제 중 오류가 발생했습니다.'); } }); }, [loadData, loadSummary] ); const handleDeleteSingle = useCallback( async (id: string) => { startTransition(async () => { try { const result = await deleteDraft(id); if (result.success) { toast.success('문서를 삭제했습니다.'); loadData(); loadSummary(); } else { toast.error(result.error || '삭제에 실패했습니다.'); } } catch (error) { if (isNextRedirectError(error)) throw error; console.error('Delete error:', error); toast.error('삭제 중 오류가 발생했습니다.'); } }); }, [loadData, loadSummary] ); const handleNewDocument = useCallback(() => { router.push('/ko/approval/draft/new'); }, [router]); const handleSendNotification = useCallback(async () => { startTransition(async () => { try { const result = await sendApprovalNotification(); if (result.success) { toast.success(`결재 알림을 발송했습니다. (${result.sentCount || 0}건)`); } else { toast.error(result.error || '알림 발송에 실패했습니다.'); } } catch (error) { if (isNextRedirectError(error)) throw error; console.error('Notification error:', error); toast.error('알림 발송 중 오류가 발생했습니다.'); } }); }, []); // ===== 문서 클릭 핸들러 ===== const handleDocumentClick = useCallback( async (item: DraftRecord) => { if (item.status === 'draft') { router.push(`/ko/approval/draft/new?id=${item.id}&mode=edit`); } else { const detailData = await getDraftById(item.id); if (detailData) { setSelectedDocument(detailData); } else { setSelectedDocument(item); } setIsModalOpen(true); } }, [router] ); const handleModalEdit = useCallback(() => { if (selectedDocument) { router.push(`/ko/approval/draft/new?id=${selectedDocument.id}&mode=edit`); setIsModalOpen(false); } }, [selectedDocument, router]); const handleModalCopy = useCallback(() => { if (selectedDocument) { router.push(`/ko/approval/draft/new?copyFrom=${selectedDocument.id}`); setIsModalOpen(false); } }, [selectedDocument, router]); const handleModalSubmit = useCallback(async () => { if (!selectedDocument) return; startTransition(async () => { try { const result = await submitDraft(selectedDocument.id); if (result.success) { toast.success('문서를 상신했습니다.'); setIsModalOpen(false); setSelectedDocument(null); loadData(); loadSummary(); } else { toast.error(result.error || '상신에 실패했습니다.'); } } catch (error) { if (isNextRedirectError(error)) throw error; console.error('Submit error:', error); toast.error('상신 중 오류가 발생했습니다.'); } }); }, [selectedDocument, loadData, loadSummary]); // ===== 문서 타입 판별 ===== const getDocumentType = (item: DraftRecord): DocumentType => { if (item.documentTypeCode) { if (item.documentTypeCode === 'expenseEstimate') return 'expenseEstimate'; if (item.documentTypeCode === 'expenseReport') return 'expenseReport'; if (item.documentTypeCode === 'proposal') return 'proposal'; } if (item.documentType.includes('지출') && item.documentType.includes('예상')) return 'expenseEstimate'; if (item.documentType.includes('지출')) return 'expenseReport'; return 'proposal'; }; // ===== 모달용 데이터 변환 ===== const convertToModalData = ( item: DraftRecord ): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData => { const docType = getDocumentType(item); const content = item.content || {}; const drafter = { id: 'drafter-1', name: item.drafter, position: item.drafterPosition || '', department: item.drafterDepartment || '', status: 'approved' as const, }; const approvers = item.approvers.map((a) => ({ id: a.id, name: a.name, position: a.position, department: a.department, status: a.status, approvedAt: a.approvedAt, })); switch (docType) { case 'expenseEstimate': { const items = (content.items as Array<{ id: string; checked?: boolean; expectedPaymentDate: string; category: string; amount: number; vendor: string; memo?: string; }>) || []; return { documentNo: item.documentNo, createdAt: item.draftDate, items: items.map((i, idx) => ({ id: i.id || String(idx + 1), expectedPaymentDate: i.expectedPaymentDate || '', category: i.category || '', amount: i.amount || 0, vendor: i.vendor || '', account: i.memo || '', })), totalExpense: (content.totalExpense as number) || 0, accountBalance: (content.accountBalance as number) || 0, finalDifference: (content.finalDifference as number) || 0, approvers, drafter, }; } case 'expenseReport': { const items = (content.items as Array<{ id: string; description: string; amount: number; note?: string; }>) || []; return { documentNo: item.documentNo, createdAt: item.draftDate, requestDate: (content.requestDate as string) || item.draftDate, paymentDate: (content.paymentDate as string) || item.draftDate, items: items.map((i, idx) => ({ id: i.id || String(idx + 1), no: idx + 1, description: i.description || '', amount: i.amount || 0, note: i.note || '', })), cardInfo: (content.cardId as string) || '', totalAmount: (content.totalAmount as number) || 0, attachments: [], approvers, drafter, }; } default: { const files = (content.files as Array<{ id: number; name: string; url?: string }>) || []; const attachmentUrls = files.map((f) => `/api/proxy/files/${f.id}/download`); return { documentNo: item.documentNo, createdAt: item.draftDate, vendor: (content.vendor as string) || '', vendorPaymentDate: (content.vendorPaymentDate as string) || '', title: (content.title as string) || item.title, description: (content.description as string) || '', reason: (content.reason as string) || '', estimatedCost: (content.estimatedCost as number) || 0, attachments: attachmentUrls, approvers, drafter, }; } } }; // ===== 결재자 텍스트 포맷 ===== const formatApprovers = (approvers: Approver[]): string => { if (approvers.length === 0) return '-'; if (approvers.length === 1) return approvers[0].name; return `${approvers[0].name} 외 ${approvers.length - 1}명`; }; // ===== UniversalListPage 설정 ===== const draftBoxConfig: UniversalListConfig = useMemo( () => ({ title: '기안함', description: '작성한 결재 문서를 관리합니다', icon: FileText, basePath: '/approval/draft', 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: 'documentType', label: '문서유형' }, { key: 'title', label: '제목' }, { key: 'approvers', label: '결재자' }, { key: 'draftDate', label: '기안일시' }, { key: 'status', label: '상태', className: 'text-center' }, { key: 'actions', label: '작업', className: 'text-center' }, ], dateRangeSelector: { enabled: true, showPresets: false, startDate, endDate, onStartDateChange: setStartDate, onEndDateChange: setEndDate, }, createButton: { label: '문서 작성', icon: Plus, onClick: handleNewDocument, }, searchPlaceholder: '문서번호, 제목, 기안자 검색...', itemsPerPage: itemsPerPage, // 모바일 필터 설정 filterConfig: [ { key: 'status', label: '상태', type: 'single', options: FILTER_OPTIONS.filter((o) => o.value !== 'all'), }, { key: 'sort', label: '정렬', type: 'single', options: SORT_OPTIONS, }, ], initialFilters: { status: filterOption, sort: sortOption, }, filterTitle: '기안함 필터', computeStats: () => { const inProgressCount = summary ? summary.pending : 0; const approvedCount = summary?.approved ?? 0; const rejectedCount = summary?.rejected ?? 0; const draftCount = summary?.draft ?? 0; return [ { label: '진행', value: `${inProgressCount}건`, icon: FileText, iconColor: 'text-blue-500', }, { label: '완료', value: `${approvedCount}건`, icon: FileText, iconColor: 'text-green-500', }, { label: '반려', value: `${rejectedCount}건`, icon: FileText, iconColor: 'text-red-500', }, { label: '임시 저장', value: `${draftCount}건`, icon: FileText, iconColor: 'text-gray-500', }, ]; }, headerActions: ({ selectedItems, onClearSelection }) => (
{selectedItems.size > 0 && ( <> )}
), tableHeaderActions: (
), renderTableRow: (item, index, globalIndex, handlers) => { const { isSelected, onToggle, onRowClick } = handlers; return ( handleDocumentClick(item)} > e.stopPropagation()}> {globalIndex} {item.documentNo} {item.documentType} {item.title} {formatApprovers(item.approvers)} {item.draftDate} {DOCUMENT_STATUS_LABELS[item.status]} e.stopPropagation()}> {isSelected && item.status === 'draft' && (
)}
); }, renderMobileCard: (item, index, globalIndex, handlers) => { const { isSelected, onToggle } = handlers; return ( {item.documentType} {DOCUMENT_STATUS_LABELS[item.status]} } isSelected={isSelected} onToggleSelection={onToggle} infoGrid={
a.name).join(' → ') || '-'} />
} actions={ isSelected && item.status === 'draft' ? (
) : undefined } onClick={() => handleDocumentClick(item)} /> ); }, renderDialogs: () => selectedDocument && ( ), }), [ data, totalCount, totalPages, startDate, endDate, handleNewDocument, summary, handleSubmit, handleDelete, handleSendNotification, filterOption, sortOption, handleDocumentClick, handleDeleteSingle, formatApprovers, selectedDocument, isModalOpen, handleModalEdit, handleModalCopy, handleModalSubmit, ] ); // 모바일 필터 변경 핸들러 const handleFilterChange = useCallback((filters: Record) => { if (filters.status) { setFilterOption(filters.status as FilterOption); } if (filters.sort) { setSortOption(filters.sort as SortOption); } }, []); return ( config={draftBoxConfig} initialData={data} initialTotalCount={totalCount} externalPagination={{ currentPage, totalPages, totalItems: totalCount, itemsPerPage, onPageChange: setCurrentPage, }} onSearchChange={setSearchQuery} onFilterChange={handleFilterChange} externalIsLoading={isLoading} /> ); }