'use client'; import { useState, useMemo, useCallback, useEffect, useTransition } from 'react'; import { useRouter } from 'next/navigation'; import { FileText, Send, Trash2, Plus, Pencil, } from 'lucide-react'; import { toast } from 'sonner'; import { getDrafts, getDraftsSummary, getDraftById, deleteDraft, deleteDrafts, submitDraft, submitDrafts, } from './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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { IntegratedListTemplateV2, type TableColumn, type StatCard, } from '@/components/templates/IntegratedListTemplateV2'; import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard'; import { 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, APPROVER_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 [selectedItems, setSelectedItems] = useState>(new Set()); 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 toggleSelection = useCallback((id: string) => { setSelectedItems(prev => { const newSet = new Set(prev); if (newSet.has(id)) newSet.delete(id); else newSet.add(id); return newSet; }); }, []); // ===== 전체 선택 핸들러 ===== const toggleSelectAll = useCallback(() => { if (selectedItems.size === data.length && data.length > 0) { setSelectedItems(new Set()); } else { setSelectedItems(new Set(data.map(item => item.id))); } }, [selectedItems.size, data]); // ===== 액션 핸들러 ===== const handleSubmit = useCallback(async () => { 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}건의 문서를 상신했습니다.`); setSelectedItems(new Set()); loadData(); loadSummary(); } else { toast.error(result.error || '상신에 실패했습니다.'); } } catch (error) { if (isNextRedirectError(error)) throw error; console.error('Submit error:', error); toast.error('상신 중 오류가 발생했습니다.'); } }); }, [selectedItems, loadData, loadSummary]); const handleDelete = useCallback(async () => { 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}건의 문서를 삭제했습니다.`); setSelectedItems(new Set()); loadData(); loadSummary(); } else { toast.error(result.error || '삭제에 실패했습니다.'); } } catch (error) { if (isNextRedirectError(error)) throw error; console.error('Delete error:', error); toast.error('삭제 중 오류가 발생했습니다.'); } }); }, [selectedItems, 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]); // ===== 문서 클릭/수정 핸들러 (조건부 로직) ===== // 임시저장 → 문서 작성 페이지 (수정 모드) // 그 외 → 문서 상세 모달 (상세 API 호출하여 content 포함된 데이터 가져옴) const handleDocumentClick = useCallback(async (item: DraftRecord) => { if (item.status === 'draft') { // 임시저장 상태 → 문서 작성 페이지로 이동 (수정 모드) router.push(`/ko/approval/draft/new?id=${item.id}&mode=edit`); } else { // 그 외 상태 → 문서 상세 API 호출 후 모달 열기 // 목록 API에서는 content가 포함되지 않을 수 있으므로 상세 조회 필요 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(() => { console.log('[DraftBox] handleModalCopy 호출됨, selectedDocument:', selectedDocument); if (selectedDocument) { const copyUrl = `/ko/approval/draft/new?copyFrom=${selectedDocument.id}`; console.log('[DraftBox] 복제 URL로 이동:', copyUrl); router.push(copyUrl); setIsModalOpen(false); } else { console.log('[DraftBox] selectedDocument가 없음'); } }, [selectedDocument, router]); const handleModalApprove = useCallback(() => { // 기안함에서는 본인 문서에 대한 승인 기능 없음 (결재함에서만 가능) toast.info('기안자는 본인 문서를 승인할 수 없습니다.'); setIsModalOpen(false); }, []); const handleModalReject = useCallback(() => { // 기안함에서는 본인 문서에 대한 반려 기능 없음 (결재함에서만 가능) toast.info('기안자는 본인 문서를 반려할 수 없습니다.'); setIsModalOpen(false); }, []); // ===== 모달에서 상신 핸들러 ===== 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]); // ===== DraftRecord → 모달용 데이터 변환 ===== const getDocumentType = (item: DraftRecord): DocumentType => { // documentTypeCode 우선 사용, 없으면 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': { // API content 구조: { items, totalExpense, accountBalance, finalDifference } 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 || '', // memo를 account로 매핑 })), totalExpense: (content.totalExpense as number) || 0, accountBalance: (content.accountBalance as number) || 0, finalDifference: (content.finalDifference as number) || 0, approvers, drafter, }; } case 'expenseReport': { // API content 구조: { requestDate, paymentDate, items, cardId, totalAmount } 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: { // proposal (품의서) // API content 구조: { vendor, vendorPaymentDate, title, description, reason, estimatedCost, files } const files = (content.files as Array<{ id: number; name: string; url?: string }>) || []; // Next.js 프록시 URL 사용 (인증된 요청 프록시) const attachmentUrls = files.map(f => `/api/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, }; } } }; // ===== 통계 카드 (API summary 사용) ===== const statCards: StatCard[] = useMemo(() => { // API summary가 있으면 사용, 없으면 현재 데이터 기준으로 계산 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' }, ]; }, [summary]); // ===== 테이블 컬럼 (스크린샷 기준) ===== const tableColumns: TableColumn[] = useMemo(() => [ { 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' }, ], []); // ===== 결재자 텍스트 포맷 (예: "강미영 외 2명") ===== 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}명`; }; // ===== 테이블 행 렌더링 (스크린샷 기준: 수정/삭제) ===== const renderTableRow = useCallback((item: DraftRecord, index: number, globalIndex: number) => { const isSelected = selectedItems.has(item.id); return ( handleDocumentClick(item)} > e.stopPropagation()}> toggleSelection(item.id)} /> {globalIndex} {item.documentNo} {item.documentType} {item.title} {formatApprovers(item.approvers)} {item.draftDate} {DOCUMENT_STATUS_LABELS[item.status]} e.stopPropagation()}> {/* 임시저장 상태일 때만 수정/삭제 버튼 표시 */} {isSelected && item.status === 'draft' && (
)}
); }, [selectedItems, toggleSelection, formatApprovers, handleDocumentClick, handleDeleteSingle]); // ===== 모바일 카드 렌더링 ===== const renderMobileCard = useCallback(( item: DraftRecord, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void ) => { 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)} /> ); }, [handleDocumentClick, handleDeleteSingle]); // ===== 헤더 액션 (DateRangeSelector + 버튼들) ===== const headerActions = ( <>
{selectedItems.size > 0 && ( <> )}
); // ===== 테이블 헤더 액션 (필터 + 정렬 셀렉트) ===== const tableHeaderActions = (
{/* 필터 셀렉트박스 */} {/* 정렬 셀렉트박스 */}
); return ( <> item.id} renderTableRow={renderTableRow} renderMobileCard={renderMobileCard} isLoading={isLoading || isPending} pagination={{ currentPage, totalPages, totalItems: totalCount, itemsPerPage, onPageChange: setCurrentPage, }} /> {/* 문서 상세 모달 */} {selectedDocument && ( )} ); }