'use client'; /** * 카드 사용내역 (기획서 D1.5) * * UniversalListPage 기반 마이그레이션 (BankTransactionInquiry 패턴) * * 테이블 15 데이터 컬럼: * 사용일시, 카드사, 카드번호, 카드명, 공제, 사업자번호, * 가맹점명, 증빙/판매자상호, 내역, 합계금액, 공급가액, 세액, 계정과목, 분개, 숨김 */ import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { format, startOfMonth, endOfMonth } from 'date-fns'; import { CreditCard, Save, Download, Eye, EyeOff, Plus, Loader2, RotateCcw, RefreshCw, } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { MobileCard } from '@/components/organisms/MobileCard'; import { UniversalListPage, type UniversalListConfig, type SelectionHandlers, type RowClickHandlers, type StatCard, } from '@/components/templates/UniversalListPage'; import type { CardTransaction, InlineEditData, SortOption } from './types'; import { SORT_OPTIONS, DEDUCTION_OPTIONS, ACCOUNT_SUBJECT_OPTIONS, } from './types'; import { getCardTransactionList, getCardTransactionSummary, bulkSaveInlineEdits, hideTransaction, unhideTransaction, getHiddenTransactions, } from './actions'; import { ManualInputModal } from './ManualInputModal'; import { JournalEntryModal } from './JournalEntryModal'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { formatNumber } from '@/lib/utils/amount'; import { filterByEnum } from '@/lib/utils/search'; // ===== 테이블 컬럼 정의 (체크박스/No. 제외 15개) ===== const tableColumns = [ { key: 'rowNumber', label: 'No.', className: 'text-center w-[50px]' }, { key: 'usedAt', label: '사용일시', className: 'min-w-[130px]' }, { key: 'cardCompany', label: '카드사', className: 'min-w-[80px]' }, { key: 'card', label: '카드번호', className: 'min-w-[100px]' }, { key: 'cardName', label: '카드명', className: 'min-w-[80px]' }, { key: 'deductionType', label: '공제', className: 'min-w-[95px]', sortable: false }, { key: 'businessNumber', label: '사업자번호', className: 'min-w-[110px]' }, { key: 'merchantName', label: '가맹점명', className: 'min-w-[100px]' }, { key: 'vendorName', label: '증빙/판매자상호', className: 'min-w-[130px]', sortable: false }, { key: 'description', label: '내역', className: 'min-w-[120px]', sortable: false }, { key: 'totalAmount', label: '합계금액', className: 'min-w-[100px] text-right' }, { key: 'supplyAmount', label: '공급가액', className: 'min-w-[110px] text-right', sortable: false }, { key: 'taxAmount', label: '세액', className: 'min-w-[90px] text-right', sortable: false }, { key: 'accountSubject', label: '계정과목', className: 'min-w-[100px]', sortable: false }, { key: 'journalEntry', label: '분개', className: 'w-16 text-center', sortable: false }, { key: 'hide', label: '숨김', className: 'w-16 text-center', sortable: false }, ]; export function CardTransactionInquiry() { // ===== 데이터 상태 ===== const [data, setData] = useState([]); const [hiddenData, setHiddenData] = useState([]); const [summary, setSummary] = useState({ previousMonthTotal: 0, currentMonthTotal: 0, totalCount: 0 }); const [pagination, setPagination] = useState({ currentPage: 1, lastPage: 1, perPage: 20, total: 0 }); const [isLoading, setIsLoading] = useState(true); const isInitialLoadDone = useRef(false); const isLocalHiddenModified = useRef(false); // 로컬 숨김/복원 수행 시 API 리로드 방지 // ===== 필터 상태 ===== const [startDate, setStartDate] = useState(() => format(startOfMonth(new Date()), 'yyyy-MM-dd')); const [endDate, setEndDate] = useState(() => format(endOfMonth(new Date()), 'yyyy-MM-dd')); const [searchQuery, setSearchQuery] = useState(''); const [sortOption, setSortOption] = useState('latest'); const [cardFilter, setCardFilter] = useState('all'); const [currentPage, setCurrentPage] = useState(1); // ===== 인라인 편집 상태 ===== const [inlineEdits, setInlineEdits] = useState>({}); // ===== UI 상태 ===== const [showHiddenSection, setShowHiddenSection] = useState(false); const [showManualInput, setShowManualInput] = useState(false); const [showJournalEntry, setShowJournalEntry] = useState(false); const [journalTransaction, setJournalTransaction] = useState(null); const [isSaving, setIsSaving] = useState(false); // ===== 데이터 로드 ===== const loadData = useCallback(async () => { if (!isInitialLoadDone.current) setIsLoading(true); try { const sortMapping: Record = { latest: { sortBy: 'used_at', sortDir: 'desc' }, oldest: { sortBy: 'used_at', sortDir: 'asc' }, amountHigh: { sortBy: 'amount', sortDir: 'desc' }, amountLow: { sortBy: 'amount', sortDir: 'asc' }, }; const sortParams = sortMapping[sortOption]; const [listResult, summaryResult] = await Promise.all([ getCardTransactionList({ page: currentPage, perPage: 20, startDate, endDate, search: searchQuery || undefined, sortBy: sortParams.sortBy, sortDir: sortParams.sortDir, }), getCardTransactionSummary({ startDate, endDate }), ]); if (listResult.success) { setData(listResult.data); setPagination(listResult.pagination); } if (summaryResult.success && summaryResult.data) { setSummary({ previousMonthTotal: summaryResult.data.previousMonthTotal, currentMonthTotal: summaryResult.data.currentMonthTotal, totalCount: summaryResult.data.totalCount, }); } } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[CardTransactionInquiry] loadData error:', error); } finally { setIsLoading(false); isInitialLoadDone.current = true; } }, [currentPage, startDate, endDate, searchQuery, sortOption]); useEffect(() => { loadData(); }, [loadData]); // ===== 숨김 거래 로드 ===== const loadHiddenData = useCallback(async () => { // 로컬 숨김/복원 수행 후에는 API 리로드 스킵 (목데이터가 로컬 변경을 덮어쓰는 것 방지) if (isLocalHiddenModified.current) return; try { const result = await getHiddenTransactions({ startDate, endDate }); if (result.success) setHiddenData(result.data); } catch (error) { if (isNextRedirectError(error)) throw error; } }, [startDate, endDate]); useEffect(() => { if (showHiddenSection) loadHiddenData(); }, [showHiddenSection, loadHiddenData]); // ===== 카드 필터 옵션 ===== const cardOptions = useMemo(() => { const uniqueCards = [...new Set(data.map(d => d.cardName))]; return [ { value: 'all', label: '전체' }, ...uniqueCards.map(card => ({ value: card, label: card })), ]; }, [data]); // ===== 필터링된 데이터 ===== const filteredData = useMemo(() => { return filterByEnum(data, 'cardName', cardFilter); }, [data, cardFilter]); // ===== 인라인 편집 핸들러 ===== const getEditValue = useCallback((id: string, key: keyof InlineEditData, original: T): T => { const edited = inlineEdits[id]?.[key]; return (edited !== undefined ? edited : original) as T; }, [inlineEdits]); const handleInlineEdit = useCallback((id: string, key: keyof InlineEditData, value: string | number) => { setInlineEdits(prev => ({ ...prev, [id]: { ...prev[id], [key]: value }, })); }, []); // ===== 저장 핸들러 ===== const handleSave = useCallback(async () => { if (Object.keys(inlineEdits).length === 0) { toast.info('변경된 항목이 없습니다.'); return; } setIsSaving(true); try { const result = await bulkSaveInlineEdits(inlineEdits); if (result.success) { toast.success('저장되었습니다.'); setInlineEdits({}); await loadData(); } else { toast.error(result.error || '저장에 실패했습니다.'); } } catch { toast.error('저장 중 오류가 발생했습니다.'); } finally { setIsSaving(false); } }, [inlineEdits, loadData]); // ===== 숨김/복원/분개 핸들러 ===== const handleHide = useCallback(async (id: string) => { // API 호출 시도, 실패 시(목데이터) 클라이언트 사이드 처리 try { const result = await hideTransaction(id); if (result.success) { toast.success('숨김 처리되었습니다.'); setShowHiddenSection(true); await loadData(); await loadHiddenData(); return; } } catch { /* API 실패 → 로컬 폴백 */ } // 로컬 폴백: data → hiddenData 이동 const item = data.find(d => d.id === id); if (item) { isLocalHiddenModified.current = true; // API 리로드 방지 setData(prev => prev.filter(d => d.id !== id)); setHiddenData(prev => [...prev, { ...item, isHidden: true, hiddenAt: new Date().toISOString().slice(0, 16).replace('T', ' ') }]); setShowHiddenSection(true); toast.success('숨김 처리되었습니다.'); } }, [data, loadData, loadHiddenData]); const handleUnhide = useCallback(async (id: string) => { // API 호출 시도, 실패 시(목데이터) 클라이언트 사이드 처리 try { const result = await unhideTransaction(id); if (result.success) { toast.success('복원되었습니다.'); await loadData(); await loadHiddenData(); return; } } catch { /* API 실패 → 로컬 폴백 */ } // 로컬 폴백: hiddenData → data 복원 const item = hiddenData.find(d => d.id === id); if (item) { isLocalHiddenModified.current = true; // API 리로드 방지 setHiddenData(prev => prev.filter(d => d.id !== id)); setData(prev => [...prev, { ...item, isHidden: false, hiddenAt: undefined }]); toast.success('복원되었습니다.'); } }, [hiddenData, loadData, loadHiddenData]); const handleJournalEntry = useCallback((item: CardTransaction) => { setJournalTransaction(item); setShowJournalEntry(true); }, []); const handleExcelDownload = useCallback(() => { toast.info('엑셀 다운로드 기능은 백엔드 연동 후 활성화됩니다.'); }, []); // ===== UniversalListPage Config ===== const config: UniversalListConfig = useMemo( () => ({ title: '카드 사용내역', description: '카드 사용내역을 조회하고 관리합니다', icon: CreditCard, basePath: '/accounting/card-transactions', idField: 'id', actions: { getList: async () => ({ success: true, data: filteredData, totalCount: pagination.total, }), }, columns: tableColumns, clientSideFiltering: false, itemsPerPage: 20, showCheckbox: true, showRowNumber: true, // 검색 searchPlaceholder: '카드명, 가맹점명, 내역 검색...', onSearchChange: setSearchQuery, searchFilter: (item: CardTransaction, search: string) => { const s = search.toLowerCase(); return ( item.cardName?.toLowerCase().includes(s) || item.merchantName?.toLowerCase().includes(s) || item.description?.toLowerCase().includes(s) || false ); }, // 날짜 선택기 (이번달~D-5월 프리셋) dateRangeSelector: { enabled: true, showPresets: true, presets: ['thisMonth', 'lastMonth', 'twoMonthsAgo', 'threeMonthsAgo', 'fourMonthsAgo', 'fiveMonthsAgo'], presetLabels: { thisMonth: '이번달', lastMonth: '지난달', twoMonthsAgo: 'D-2월', threeMonthsAgo: 'D-3월', fourMonthsAgo: 'D-4월', fiveMonthsAgo: 'D-5월', }, startDate, endDate, onStartDateChange: setStartDate, onEndDateChange: setEndDate, }, // 헤더 액션: 숨김보기 + 저장 + 엑셀 다운로드 + 수기 입력 headerActions: () => (
), // 테이블 헤더 액션: 총 N건 + 새로고침 + 카드필터 + 정렬 tableHeaderActions: () => (
총 {pagination.total}건
), // 범례 (수기 카드 / 연동 카드) tableFooter: (
수기 카드 연동 카드
), // 통계 카드 3개 computeStats: (): StatCard[] => [ { label: '전월', value: `${formatNumber(summary.previousMonthTotal)}원`, icon: CreditCard, iconColor: 'text-gray-500', }, { label: '당월', value: `${formatNumber(summary.currentMonthTotal)}원`, icon: CreditCard, iconColor: 'text-blue-500', }, { label: '건수', value: `${summary.totalCount}건`, icon: CreditCard, iconColor: 'text-orange-500', }, ], // 상세 보기 없음 (인라인 편집 방식) detailMode: 'none', // 테이블 행 렌더링 (17컬럼: 체크박스 + No. + 15데이터) renderTableRow: ( item: CardTransaction, _index: number, globalIndex: number, handlers: SelectionHandlers & RowClickHandlers ) => { return ( {/* 체크박스 */} e.stopPropagation()}> handlers.onToggle()} /> {/* No. */} {globalIndex} {/* 사용일시 */} {item.usedAt} {/* 카드사 (수기/연동 색상 표시) */}
{item.cardCompany}
{/* 카드번호 */} {item.card} {/* 카드명 */} {item.cardName} {/* 공제 (인라인 Select) */} e.stopPropagation()}> {/* 사업자번호 */} {item.businessNumber} {/* 가맹점명 */} {item.merchantName} {/* 증빙/판매자상호 (인라인 Input) */} e.stopPropagation()}> handleInlineEdit(item.id, 'vendorName', e.target.value)} placeholder="증빙/판매자상호" className="h-7 text-xs w-[120px]" /> {/* 내역 (인라인 Input) */} e.stopPropagation()}> handleInlineEdit(item.id, 'description', e.target.value)} placeholder="내역" className="h-7 text-xs w-[110px]" /> {/* 합계금액 */} {formatNumber(item.totalAmount)} {/* 공급가액 (인라인 숫자 Input) */} e.stopPropagation()}> handleInlineEdit(item.id, 'supplyAmount', Number(e.target.value) || 0)} className="h-7 text-xs w-[100px] text-right" /> {/* 세액 (인라인 숫자 Input) */} e.stopPropagation()}> handleInlineEdit(item.id, 'taxAmount', Number(e.target.value) || 0)} className="h-7 text-xs w-[80px] text-right" /> {/* 계정과목 (인라인 Select) */} e.stopPropagation()}> {/* 분개 버튼 */} e.stopPropagation()}> {/* 숨김 버튼 */} e.stopPropagation()}>
); }, // 모바일 카드 렌더링 renderMobileCard: ( item: CardTransaction, _index: number, _globalIndex: number, handlers: SelectionHandlers & RowClickHandlers ) => { return ( ); }, // 숨김 거래 섹션 (ReactNode로 직접 전달 - 함수 평가 경로 우회) afterTableContent: showHiddenSection ? (

숨김 처리된 거래 ({hiddenData.length}건)

{hiddenData.length === 0 ? (

숨김 처리된 거래가 없습니다.

) : (
No. 사용일시 카드사 카드번호 카드명 사업자번호 가맹점명 합계금액 숨김일시 복원 {hiddenData.map((item, index) => ( {index + 1} {item.usedAt}
{item.cardCompany}
{item.card} {item.cardName} {item.businessNumber} {item.merchantName} {formatNumber(item.totalAmount)} {item.hiddenAt || '-'}
))}
)}
) : undefined, // 다이얼로그 (모달) renderDialogs: () => ( <> ), }), [ filteredData, pagination, summary, cardFilter, cardOptions, sortOption, startDate, endDate, isLoading, isSaving, inlineEdits, showHiddenSection, hiddenData, showManualInput, showJournalEntry, journalTransaction, handleSave, handleExcelDownload, handleHide, handleJournalEntry, handleUnhide, handleInlineEdit, getEditValue, loadData, ] ); return ( ); }