'use client'; import { useState, useMemo, useCallback, useEffect, useRef, useTransition } from 'react'; import { Download, FileText, Save, Loader2, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react'; import { formatNumber } from '@/lib/utils/amount'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, TableFooter } from '@/components/ui/table'; import { MobileCard } from '@/components/organisms/MobileCard'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { PageLayout } from '@/components/organisms/PageLayout'; import { PageHeader } from '@/components/organisms/PageHeader'; import { SearchFilter } from '@/components/organisms/SearchFilter'; import type { VendorReceivables, CategoryType, SortOption, ReceivablesListResponse, MemoUpdateRequest, } from './types'; import { CATEGORY_LABELS, SORT_OPTIONS, } from './types'; import { getReceivablesList, getReceivablesSummary, updateOverdueStatus, updateMemos, exportReceivablesExcel } from './actions'; import { toast } from 'sonner'; import { filterByText } from '@/lib/utils/search'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { usePermission } from '@/hooks/usePermission'; // ===== Props 인터페이스 ===== interface ReceivablesStatusProps { highlightVendorId?: string; initialData?: ReceivablesListResponse; initialSummary?: { totalCarryForward: number; totalSales: number; totalDeposits: number; totalBills: number; totalReceivables: number; vendorCount: number; overdueVendorCount: number; }; } // ===== 연도 옵션 생성 (0 = 최근 1년) ===== const YEAR_RECENT = 0; // 최근 1년 옵션 값 const generateYearOptions = (): Array<{ value: number; label: string }> => { const currentYear = new Date().getFullYear(); const years = Array.from({ length: 5 }, (_, i) => ({ value: currentYear - i, label: `${currentYear - i}년`, })); return [{ value: YEAR_RECENT, label: '최근 1년' }, ...years]; }; // ===== 카테고리 순서 (메모 제외) ===== const categoryOrder: CategoryType[] = ['sales', 'deposit', 'bill', 'receivable']; export function ReceivablesStatus({ highlightVendorId, initialData, initialSummary }: ReceivablesStatusProps) { const { canExport } = usePermission(); // ===== Refs ===== const highlightRowRef = useRef(null); // ===== 상태 관리 ===== const [isPending, startTransition] = useTransition(); const [selectedYear, setSelectedYear] = useState(YEAR_RECENT); // 기본: 최근 1년 const [sortOption, setSortOption] = useState('overdueFirst'); // 기본: 연체 업체 우선 const [searchQuery, setSearchQuery] = useState(''); const [monthLabels, setMonthLabels] = useState(initialData?.monthLabels || []); const [data, setData] = useState(initialData?.items || []); const [originalOverdueMap, setOriginalOverdueMap] = useState>(new Map()); const [originalMemoMap, setOriginalMemoMap] = useState>(new Map()); const [summary, setSummary] = useState(initialSummary || { totalCarryForward: 0, totalSales: 0, totalDeposits: 0, totalBills: 0, totalReceivables: 0, vendorCount: 0, overdueVendorCount: 0, }); const [isLoading, setIsLoading] = useState(!initialData?.items?.length); const [expandedMemos, setExpandedMemos] = useState>(new Set()); // ===== 데이터 로드 ===== const loadData = useCallback(async () => { setIsLoading(true); try { const [listResult, summaryResult] = await Promise.all([ getReceivablesList({ year: selectedYear, search: searchQuery || undefined }), getReceivablesSummary({ year: selectedYear }), ]); if (listResult.success) { setMonthLabels(listResult.data.monthLabels); setData(listResult.data.items); // 원본 연체 상태 저장 const overdueMap = new Map(); const memoMap = new Map(); listResult.data.items.forEach(vendor => { overdueMap.set(vendor.id, vendor.isOverdue); memoMap.set(vendor.id, vendor.memo || ''); }); setOriginalOverdueMap(overdueMap); setOriginalMemoMap(memoMap); } if (summaryResult.success && summaryResult.data) { setSummary(summaryResult.data); } } catch (error) { if (isNextRedirectError(error)) throw error; console.error('Failed to load receivables:', error); } finally { setIsLoading(false); } }, [selectedYear, searchQuery]); // ===== 초기 로드 및 필터 변경시 재로드 ===== useEffect(() => { loadData(); }, [loadData]); // ===== 하이라이트된 행으로 스크롤 ===== useEffect(() => { if (highlightVendorId && highlightRowRef.current) { highlightRowRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, [highlightVendorId]); // ===== 필터링된 데이터 ===== const filteredData = useMemo(() => { return filterByText(data, searchQuery, ['vendorName']); }, [data, searchQuery]); // ===== 정렬된 데이터 ===== // 정렬에는 원본 연체 상태(originalOverdueMap)를 사용하여 토글 시 위치 이동 방지 const sortedData = useMemo(() => { const sorted = [...filteredData]; switch (sortOption) { case 'overdueFirst': // 연체 업체 우선 (원본 연체 상태 기준으로 정렬, 토글해도 위치 유지) sorted.sort((a, b) => { const aOriginalOverdue = originalOverdueMap.get(a.id) ?? a.isOverdue; const bOriginalOverdue = originalOverdueMap.get(b.id) ?? b.isOverdue; if (aOriginalOverdue !== bOriginalOverdue) { return aOriginalOverdue ? -1 : 1; } return a.vendorName.localeCompare(b.vendorName, 'ko'); }); break; case 'vendorName': // 거래처명 순 sorted.sort((a, b) => a.vendorName.localeCompare(b.vendorName, 'ko')); break; case 'totalDesc': // 금액 높은순 (미수금 합계 기준) sorted.sort((a, b) => { const aTotal = a.categories.find(c => c.category === 'receivable')?.amounts.total || 0; const bTotal = b.categories.find(c => c.category === 'receivable')?.amounts.total || 0; return bTotal - aTotal; }); break; case 'totalAsc': // 금액 낮은순 (미수금 합계 기준) sorted.sort((a, b) => { const aTotal = a.categories.find(c => c.category === 'receivable')?.amounts.total || 0; const bTotal = b.categories.find(c => c.category === 'receivable')?.amounts.total || 0; return aTotal - bTotal; }); break; } return sorted; }, [filteredData, sortOption, originalOverdueMap]); // ===== 연체 토글 핸들러 ===== const handleOverdueToggle = useCallback((vendorId: string, checked: boolean) => { setData(prev => prev.map(vendor => vendor.id === vendorId ? { ...vendor, isOverdue: checked } : vendor )); }, []); // ===== 메모 변경 핸들러 ===== const handleMemoChange = useCallback((vendorId: string, memo: string) => { setData(prev => prev.map(vendor => vendor.id === vendorId ? { ...vendor, memo } : vendor )); }, []); // ===== 메모 펼치기/접기 토글 ===== const toggleMemoExpand = useCallback((vendorId: string) => { setExpandedMemos(prev => { const newSet = new Set(prev); if (newSet.has(vendorId)) { newSet.delete(vendorId); } else { newSet.add(vendorId); } return newSet; }); }, []); // ===== 엑셀 다운로드 핸들러 ===== const handleExcelDownload = useCallback(async () => { const result = await exportReceivablesExcel({ year: selectedYear, search: searchQuery || undefined, }); if (result.success && result.data) { const url = URL.createObjectURL(result.data); const a = document.createElement('a'); a.href = url; a.download = result.filename || '채권현황.xlsx'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); toast.success('엑셀 파일이 다운로드되었습니다.'); } else { toast.error(result.error || '엑셀 다운로드에 실패했습니다.'); } }, [selectedYear, searchQuery]); // ===== 변경된 연체 항목 확인 ===== const changedOverdueItems = useMemo(() => { return data.filter(vendor => { const originalValue = originalOverdueMap.get(vendor.id); return originalValue !== undefined && originalValue !== vendor.isOverdue; }).map(vendor => ({ id: vendor.id, isOverdue: vendor.isOverdue, })); }, [data, originalOverdueMap]); // ===== 변경된 메모 항목 확인 ===== const changedMemoItems = useMemo(() => { return data.filter(vendor => { const originalMemo = originalMemoMap.get(vendor.id); return originalMemo !== undefined && originalMemo !== vendor.memo; }).map(vendor => ({ id: vendor.id, memo: vendor.memo, })); }, [data, originalMemoMap]); // ===== 총 변경 항목 수 ===== const totalChangedCount = changedOverdueItems.length + changedMemoItems.length; // ===== 저장 핸들러 ===== const handleSave = useCallback(async () => { if (totalChangedCount === 0) { toast.info('변경된 항목이 없습니다.'); return; } startTransition(async () => { const promises: Promise<{ success: boolean; error?: string }>[] = []; // 연체 상태 업데이트 if (changedOverdueItems.length > 0) { promises.push(updateOverdueStatus(changedOverdueItems)); } // 메모 업데이트 if (changedMemoItems.length > 0) { promises.push(updateMemos(changedMemoItems as MemoUpdateRequest[])); } const results = await Promise.all(promises); const allSuccess = results.every(r => r.success); if (allSuccess) { // 원본 상태 업데이트 const newOverdueMap = new Map(originalOverdueMap); changedOverdueItems.forEach(item => { newOverdueMap.set(item.id, item.isOverdue); }); setOriginalOverdueMap(newOverdueMap); const newMemoMap = new Map(originalMemoMap); changedMemoItems.forEach(item => { newMemoMap.set(item.id, item.memo); }); setOriginalMemoMap(newMemoMap); toast.success(`${totalChangedCount}건이 저장되었습니다.`); } else { const errors = results.filter(r => !r.success).map(r => r.error).join(', '); toast.error(errors || '저장에 실패했습니다.'); } }); }, [changedOverdueItems, changedMemoItems, totalChangedCount, originalOverdueMap, originalMemoMap]); // ===== 금액 포맷 ===== const formatAmount = (amount: number) => { if (amount === 0) return ''; return formatNumber(amount); }; // ===== 합계 계산 (동적 월) ===== const grandTotals = useMemo(() => { const monthCount = monthLabels.length || 12; const values = new Array(monthCount).fill(0); let total = 0; filteredData.forEach(vendor => { const receivableCat = vendor.categories.find(c => c.category === 'receivable'); if (receivableCat) { receivableCat.amounts.values.forEach((val, idx) => { if (idx < monthCount) { values[idx] += val; } }); total += receivableCat.amounts.total; } }); return { values, total }; }, [filteredData, monthLabels.length]); // ===== 월 개수 (동적) ===== const monthCount = monthLabels.length || 12; // ===== 카테고리 순서 (메모 제외 - 별도 렌더링) ===== const categoryOrder: CategoryType[] = ['sales', 'deposit', 'bill', 'receivable']; return ( {/* 페이지 헤더 */} {/* 헤더 액션 (연도 선택, 버튼) */}
{/* 연도 선택 */}
연도
{/* 정렬 선택 */}
정렬
{canExport && ( )}
{/* 검색 */} {/* 테이블 - xl 이상에서만 표시 */}
{/* 거래처/연체 - 왼쪽 고정 */} 거래처 / 연체 {/* 구분 - sm 이상에서만 왼쪽 고정 */} 구분 {/* 동적 월 레이블 - 스크롤 영역 */} {monthLabels.map((month, idx) => ( {month} ))} {/* 합계 - sm 이상에서만 오른쪽 고정 */} 합계 {isLoading ? (
데이터를 불러오는 중...
) : sortedData.length === 0 ? ( 검색 결과가 없습니다. ) : ( sortedData.map((vendor) => { const isOverdueRow = vendor.isOverdue; const isHighlighted = highlightVendorId === vendor.id; // 하이라이트 > 연체 > 기본 순으로 배경색 결정 const rowBgClass = isHighlighted ? 'bg-yellow-100' : isOverdueRow ? 'bg-red-50' : 'bg-white'; // 구분별 행 (매출, 입금, 어음, 미수금) + 메모 행 = 총 5개 행 const rows = []; // 구분별 행 렌더링 (rowSpan 대신 각 행마다 개별 거래처 셀로 sticky 안정화) categoryOrder.forEach((category, catIndex) => { const categoryData = vendor.categories.find(c => c.category === category); if (!categoryData) return; rows.push( {/* 거래처명 - 왼쪽 고정 (매 행마다 개별 셀, 첫 행만 내용 표시) */} {catIndex === 0 && (
{vendor.vendorName}
handleOverdueToggle(vendor.id, checked)} className="data-[state=checked]:bg-red-500" /> {vendor.isOverdue && ( 연체 )}
)}
{/* 구분 - sm 이상에서만 왼쪽 고정 */} {CATEGORY_LABELS[category]} {/* 월별 금액 - 스크롤 영역 */} {categoryData.amounts.values.map((amount, monthIndex) => ( {formatAmount(amount)} ))} {/* 합계 - sm 이상에서만 오른쪽 고정 */} {formatAmount(categoryData.amounts.total)}
); }); // 메모 행 추가 (마지막 행) const isMemoExpanded = expandedMemos.has(vendor.id); rows.push( {/* 거래처명 셀 (빈 셀 - 시각적 병합 유지) */} {/* 구분: 메모 + 접기/펼치기 버튼 */}
메모
{/* 메모 입력 - 모든 월 컬럼 + 합계 컬럼 병합 */}