'use client'; import { useState, useMemo, useCallback, useEffect, useTransition, useRef } from 'react'; import { useDateRange } from '@/hooks'; import { Files, Eye, EyeOff, BookOpen, } from 'lucide-react'; import { toast } from 'sonner'; import { getReferences, getReferenceSummary, markAsReadBulk, markAsUnreadBulk, } 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 { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { UniversalListPage, type UniversalListConfig, type StatCard, 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, DynamicDocumentData } from '@/components/approval/DocumentDetail/types'; import { getFieldLabels, filterVisibleFields, getFormName } from '@/components/approval/DocumentDetail/field-labels'; import { getApprovalById } from '@/components/approval/DocumentCreate/actions'; import { DEDICATED_FORM_CODES } from '@/components/approval/DocumentCreate/types'; import type { ReferenceTabType, ReferenceRecord, SortOption, FilterOption, ApprovalType, } from './types'; import { REFERENCE_TAB_LABELS, SORT_OPTIONS, FILTER_OPTIONS, APPROVAL_TYPE_LABELS, READ_STATUS_LABELS, READ_STATUS_COLORS, } from './types'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; // ===== 통계 타입 ===== interface ReferenceSummary { all: number; read: number; unread: number; } export function ReferenceBox() { const [, startTransition] = useTransition(); // ===== 상태 관리 ===== const [activeTab, setActiveTab] = useState('all'); 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, endDate, setStartDate, setEndDate } = useDateRange('currentYear'); // 다이얼로그 상태 const [markReadDialogOpen, setMarkReadDialogOpen] = useState(false); const [markUnreadDialogOpen, setMarkUnreadDialogOpen] = useState(false); // ===== 문서 상세 모달 상태 ===== const [isModalOpen, setIsModalOpen] = useState(false); const [isModalLoading, setIsModalLoading] = useState(false); const [selectedDocument, setSelectedDocument] = useState(null); const [modalData, setModalData] = useState(null); const [modalDocType, setModalDocType] = useState('proposal'); // 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 [summary, setSummary] = useState(null); // ===== 데이터 로드 ===== 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' }; } })(); // 탭에 따른 is_read 파라미터 const isReadParam = activeTab === 'all' ? undefined : activeTab === 'read'; const result = await getReferences({ page: currentPage, per_page: itemsPerPage, search: searchQuery || undefined, is_read: isReadParam, approval_type: 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 references:', error); toast.error('참조함 목록을 불러오는데 실패했습니다.'); } finally { setIsLoading(false); isInitialLoadDone.current = true; } }, [currentPage, itemsPerPage, searchQuery, filterOption, sortOption, activeTab]); // ===== 통계 로드 ===== const loadSummary = useCallback(async () => { try { const result = await getReferenceSummary(); setSummary(result); } catch (error) { if (isNextRedirectError(error)) throw error; console.error('Failed to load summary:', error); } }, []); // ===== 초기 로드 ===== // 마운트 시 1회만 실행 (summary 로드) useEffect(() => { loadSummary(); }, []); // ===== 데이터 로드 (의존성 명시적 관리) ===== // currentPage, searchQuery, filterOption, sortOption, activeTab 변경 시 데이터 재로드 useEffect(() => { loadData(); }, [currentPage, searchQuery, filterOption, sortOption, activeTab]); // ===== 검색어/필터/탭 변경 시 페이지 초기화 ===== // ref로 이전 값 추적하여 불필요한 상태 변경 방지 (무한 루프 방지) const prevSearchRef = useRef(searchQuery); const prevFilterRef = useRef(filterOption); const prevSortRef = useRef(sortOption); const prevTabRef = useRef(activeTab); useEffect(() => { const searchChanged = prevSearchRef.current !== searchQuery; const filterChanged = prevFilterRef.current !== filterOption; const sortChanged = prevSortRef.current !== sortOption; const tabChanged = prevTabRef.current !== activeTab; if (searchChanged || filterChanged || sortChanged || tabChanged) { // 페이지가 1이 아닐 때만 리셋 (불필요한 상태 변경 방지) if (currentPage !== 1) { setCurrentPage(1); } prevSearchRef.current = searchQuery; prevFilterRef.current = filterOption; prevSortRef.current = sortOption; prevTabRef.current = activeTab; } }, [searchQuery, filterOption, sortOption, activeTab, currentPage]); // ===== 탭 변경 핸들러 ===== const handleTabChange = useCallback((value: string) => { setActiveTab(value as ReferenceTabType); setSelectedItems(new Set()); setSearchQuery(''); }, []); // ===== 체크박스 핸들러 ===== 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]); // ===== 통계 데이터 (API summary 사용) ===== const stats = useMemo(() => { return { all: summary?.all ?? 0, read: summary?.read ?? 0, unread: summary?.unread ?? 0, }; }, [summary]); // ===== 열람/미열람 처리 핸들러 ===== const handleMarkReadClick = useCallback(() => { if (selectedItems.size === 0) return; setMarkReadDialogOpen(true); }, [selectedItems.size]); const handleMarkReadConfirm = useCallback(async () => { const ids = Array.from(selectedItems); startTransition(async () => { try { const result = await markAsReadBulk(ids); if (result.success) { toast.success('열람 처리 완료', { description: '열람 처리가 완료되었습니다.', }); setSelectedItems(new Set()); loadData(); loadSummary(); } else { toast.error(result.error || '열람 처리에 실패했습니다.'); } } catch (error) { if (isNextRedirectError(error)) throw error; console.error('Mark read error:', error); toast.error('열람 처리 중 오류가 발생했습니다.'); } }); setMarkReadDialogOpen(false); }, [selectedItems, loadData, loadSummary]); const handleMarkUnreadClick = useCallback(() => { if (selectedItems.size === 0) return; setMarkUnreadDialogOpen(true); }, [selectedItems.size]); const handleMarkUnreadConfirm = useCallback(async () => { const ids = Array.from(selectedItems); startTransition(async () => { try { const result = await markAsUnreadBulk(ids); if (result.success) { toast.success('미열람 처리 완료', { description: '미열람 처리가 완료되었습니다.', }); setSelectedItems(new Set()); loadData(); loadSummary(); } else { toast.error(result.error || '미열람 처리에 실패했습니다.'); } } catch (error) { if (isNextRedirectError(error)) throw error; console.error('Mark unread error:', error); toast.error('미열람 처리 중 오류가 발생했습니다.'); } }); setMarkUnreadDialogOpen(false); }, [selectedItems, loadData, loadSummary]); // ===== 문서 클릭/상세 보기 핸들러 ===== const handleDocumentClick = useCallback(async (item: ReferenceRecord) => { setSelectedDocument(item); setIsModalLoading(true); setIsModalOpen(true); try { const result = await getApprovalById(parseInt(item.id)); if (result.success && result.data) { const formData = result.data; const docTypeCode = formData.basicInfo.documentType; 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: index === 0 ? ('approved' as const) : ('none' as const), })); // 전용 양식 또는 동적 양식 → DynamicDocumentData const isBuiltin = ['proposal', 'expenseReport', 'expense_report', 'expenseEstimate', 'expense_estimate'].includes(docTypeCode); if (!isBuiltin) { const dedicatedDataMap: Record = { officialDocument: formData.officialDocumentData, resignation: formData.resignationData, employmentCert: formData.employmentCertData, careerCert: formData.careerCertData, appointmentCert: formData.appointmentCertData, sealUsage: formData.sealUsageData, leaveNotice1st: formData.leaveNotice1stData, leaveNotice2nd: formData.leaveNotice2ndData, powerOfAttorney: formData.powerOfAttorneyData, boardMinutes: formData.boardMinutesData, quotation: formData.quotationData, }; const dedicatedData = dedicatedDataMap[docTypeCode]; const fields = dedicatedData ? filterVisibleFields(dedicatedData as Record) : (formData.dynamicFormData || {}); setModalDocType('dynamic'); setModalData({ documentNo: formData.basicInfo.documentNo, createdAt: formData.basicInfo.draftDate, formName: formData.basicInfo.formName || getFormName(docTypeCode), fields, fieldLabels: getFieldLabels(docTypeCode), approvers, drafter, }); } else if (docTypeCode === 'expenseEstimate' || docTypeCode === 'expense_estimate') { setModalDocType('expenseEstimate'); setModalData({ documentNo: formData.basicInfo.documentNo, createdAt: formData.basicInfo.draftDate, items: formData.expenseEstimateData?.items.map(i => ({ id: i.id, expectedPaymentDate: i.expectedPaymentDate, category: i.category, amount: i.amount, vendor: i.vendor, account: i.memo || '', })) || [], totalExpense: formData.expenseEstimateData?.totalExpense || 0, accountBalance: formData.expenseEstimateData?.accountBalance || 0, finalDifference: formData.expenseEstimateData?.finalDifference || 0, approvers, drafter, }); } else if (docTypeCode === 'expenseReport' || docTypeCode === 'expense_report') { setModalDocType('expenseReport'); setModalData({ documentNo: formData.basicInfo.documentNo, createdAt: formData.basicInfo.draftDate, requestDate: formData.expenseReportData?.requestDate || '', paymentDate: formData.expenseReportData?.paymentDate || '', items: formData.expenseReportData?.items.map((i, idx) => ({ id: i.id, no: idx + 1, description: i.description, amount: i.amount, note: i.note, })) || [], cardInfo: formData.expenseReportData?.cardId || '-', totalAmount: formData.expenseReportData?.totalAmount || 0, attachments: [], approvers, drafter, }); } else { // 품의서 setModalDocType('proposal'); const uploadedFileUrls = (formData.proposalData?.uploadedFiles || []).map(f => `/api/proxy/files/${f.id}/download` ); setModalData({ 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, }); } } 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 statCards: StatCard[] = useMemo(() => [ { label: '전체', value: `${stats.all}건`, icon: Files, iconColor: 'text-blue-500' }, { label: '열람', value: `${stats.read}건`, icon: Eye, iconColor: 'text-green-500' }, { label: '미열람', value: `${stats.unread}건`, icon: EyeOff, iconColor: 'text-red-500' }, ], [stats]); // ===== 탭 옵션 (열람/미열람 토글 버튼 형태) ===== const tabs: TabOption[] = useMemo(() => [ { value: 'all', label: REFERENCE_TAB_LABELS.all, count: stats.all, color: 'blue' }, { value: 'read', label: REFERENCE_TAB_LABELS.read, count: stats.read, color: 'green' }, { value: 'unread', label: REFERENCE_TAB_LABELS.unread, count: stats.unread, color: 'red' }, ], [stats]); // ===== 테이블 컬럼 ===== // 문서번호, 문서유형, 제목, 기안자, 기안일시, 상태 const tableColumns = useMemo(() => [ { key: 'no', label: '번호', className: 'w-[60px] text-center' }, { key: 'documentNo', label: '문서번호', copyable: true }, { key: 'approvalType', label: '문서유형', copyable: true }, { key: 'title', label: '제목', copyable: true }, { key: 'drafter', label: '기안자', copyable: true }, { key: 'draftDate', label: '기안일시', copyable: true }, { key: 'status', label: '상태', className: 'text-center' }, ], []); // ===== UniversalListPage 설정 ===== const referenceBoxConfig: UniversalListConfig = useMemo(() => ({ title: '참조함', description: '참조로 지정된 문서를 확인합니다.', icon: BookOpen, basePath: '/approval/reference', idField: 'id', actions: { getList: async () => ({ success: true, data: data, totalCount: totalCount, totalPages: totalPages, }), }, columns: tableColumns, tabs: tabs, defaultTab: activeTab, computeStats: () => statCards, // 검색창 (공통 컴포넌트에서 자동 생성) hideSearch: true, searchValue: searchQuery, onSearchChange: setSearchQuery, dateRangeSelector: { enabled: true, showPresets: false, startDate, endDate, onStartDateChange: setStartDate, onEndDateChange: setEndDate, }, searchPlaceholder: '제목, 기안자, 부서 검색...', searchFilter: (item: ReferenceRecord, 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: '참조함 필터', selectionActions: () => ( <> ), renderTableRow: (item, index, globalIndex, handlers) => { const { isSelected, onToggle, onRowClick: _onRowClick } = handlers; return ( handleDocumentClick(item)} > e.stopPropagation()}> {globalIndex} {item.documentNo} {APPROVAL_TYPE_LABELS[item.approvalType]} {item.title} {item.drafter} {item.draftDate} {READ_STATUS_LABELS[item.readStatus]} ); }, renderMobileCard: (item, index, globalIndex, handlers) => { const { isSelected, onToggle } = handlers; return ( {APPROVAL_TYPE_LABELS[item.approvalType]} {READ_STATUS_LABELS[item.readStatus]} } isSelected={isSelected} onToggleSelection={onToggle} infoGrid={
} actions={
{item.readStatus === 'unread' ? ( ) : ( )}
} /> ); }, renderDialogs: () => ( <> {/* 열람 처리 확인 다이얼로그 */} {/* 미열람 처리 확인 다이얼로그 */} {/* 문서 상세 모달 */} {selectedDocument && modalData && ( { setIsModalOpen(open); if (!open) setModalData(null); }} documentType={modalDocType} data={modalData} mode="reference" /> )} ), }), [ data, totalCount, totalPages, tableColumns, tabs, activeTab, statCards, startDate, endDate, filterOption, sortOption, handleMarkReadClick, handleMarkUnreadClick, handleDocumentClick, markReadDialogOpen, markUnreadDialogOpen, selectedItems.size, handleMarkReadConfirm, handleMarkUnreadConfirm, selectedDocument, isModalOpen, modalData, modalDocType, ]); // 모바일 필터 변경 핸들러 const handleMobileFilterChange = useCallback((filters: Record) => { if (filters.approvalType) { setFilterOption(filters.approvalType as FilterOption); } if (filters.sort) { setSortOption(filters.sort as SortOption); } }, []); return ( config={referenceBoxConfig} initialData={data} initialTotalCount={totalCount} externalPagination={{ currentPage, totalPages, totalItems: totalCount, itemsPerPage, onPageChange: setCurrentPage, }} externalSelection={{ selectedItems, onToggleSelection: toggleSelection, onToggleSelectAll: toggleSelectAll, getItemId: (item) => item.id, }} onTabChange={handleTabChange} onSearchChange={setSearchQuery} onFilterChange={handleMobileFilterChange} externalIsLoading={isLoading} /> ); }