'use client'; import { useState, useMemo, useCallback, useEffect, useTransition, useRef } from 'react'; 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { UniversalListPage, type UniversalListConfig, type StatCard, type TabOption, } from '@/components/templates/UniversalListPage'; import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; 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 { 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 [isPending, 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, setStartDate] = useState('2025-09-01'); const [endDate, setEndDate] = useState('2025-09-03'); // 다이얼로그 상태 const [markReadDialogOpen, setMarkReadDialogOpen] = useState(false); const [markUnreadDialogOpen, setMarkUnreadDialogOpen] = useState(false); // ===== 문서 상세 모달 상태 ===== const [isModalOpen, setIsModalOpen] = useState(false); const [selectedDocument, setSelectedDocument] = 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 [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(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // ===== 데이터 로드 (의존성 명시적 관리) ===== // currentPage, searchQuery, filterOption, sortOption, activeTab 변경 시 데이터 재로드 useEffect(() => { loadData(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [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((item: ReferenceRecord) => { setSelectedDocument(item); setIsModalOpen(true); }, []); // ===== ApprovalType → DocumentType 변환 ===== const getDocumentType = (approvalType: ApprovalType): DocumentType => { switch (approvalType) { case 'expense_estimate': return 'expenseEstimate'; case 'expense_report': return 'expenseReport'; default: return 'proposal'; } }; // ===== ReferenceRecord → 모달용 데이터 변환 ===== const convertToModalData = (item: ReferenceRecord): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData => { const docType = getDocumentType(item.approvalType); const drafter = { id: 'drafter-1', name: item.drafter, position: item.drafterPosition, department: item.drafterDepartment, status: 'approved' as const, }; const approvers = [{ id: 'approver-1', name: '결재자', position: '부장', department: '경영지원팀', status: 'approved' as const, }]; switch (docType) { case 'expenseEstimate': return { documentNo: item.documentNo, createdAt: item.draftDate, items: [ { id: '1', expectedPaymentDate: '2025-11-05', category: '통신 서비스', amount: 550000, vendor: 'KT', account: '국민 123-456-789012 홍길동' }, { id: '2', expectedPaymentDate: '2025-11-12', category: '인건 대행', amount: 2500000, vendor: '에이치알코리아', account: '신한 110-123-456789 (주)에이치알' }, ], totalExpense: 3050000, accountBalance: 25000000, finalDifference: 21950000, approvers, drafter, }; case 'expenseReport': return { documentNo: item.documentNo, createdAt: item.draftDate, requestDate: item.draftDate, paymentDate: item.draftDate, items: [ { id: '1', no: 1, description: '업무용 택시비', amount: 50000, note: '고객사 미팅' }, { id: '2', no: 2, description: '식대', amount: 30000, note: '팀 회식' }, ], cardInfo: '삼성카드 **** 1234', totalAmount: 80000, attachments: [], approvers, drafter, }; default: return { documentNo: item.documentNo, createdAt: item.draftDate, vendor: '거래처', vendorPaymentDate: item.draftDate, title: item.title, description: item.title, reason: '업무상 필요', estimatedCost: 1000000, attachments: [], approvers, drafter, }; } }; // ===== 통계 카드 ===== 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: '문서번호' }, { key: 'approvalType', label: '문서유형' }, { key: 'title', label: '제목' }, { key: 'drafter', label: '기안자' }, { key: 'draftDate', label: '기안일시' }, { key: 'status', label: '상태', className: 'text-center' }, ], []); // ===== 테이블 헤더 액션 (필터 + 정렬 셀렉트) ===== const tableHeaderActions = useMemo(() => (
{/* 필터 셀렉트박스 */} {/* 정렬 셀렉트박스 */}
), [filterOption, sortOption]); // ===== 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, tableHeaderActions: tableHeaderActions, // 모바일 필터 설정 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: '참조함 필터', headerActions: ({ selectedItems: selected, onClearSelection }) => ( selected.size > 0 ? (
) : null ), renderTableRow: (item, index, globalIndex, handlers) => { const { isSelected, onToggle, 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 && ( )} ), }), [ data, totalCount, totalPages, tableColumns, tabs, activeTab, statCards, startDate, endDate, tableHeaderActions, handleMarkReadClick, handleMarkUnreadClick, handleDocumentClick, markReadDialogOpen, markUnreadDialogOpen, selectedItems.size, handleMarkReadConfirm, handleMarkUnreadConfirm, selectedDocument, isModalOpen, getDocumentType, convertToModalData, ]); // 모바일 필터 변경 핸들러 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} /> ); }