diff --git a/src/components/accounting/DepositManagement/actions.ts b/src/components/accounting/DepositManagement/actions.ts index a686de11..3609b57f 100644 --- a/src/components/accounting/DepositManagement/actions.ts +++ b/src/components/accounting/DepositManagement/actions.ts @@ -8,16 +8,20 @@ interface DepositApiData { id: number; tenant_id: number; deposit_date: string; - deposit_amount: number | string; - account_name: string; - depositor_name: string; - note: string | null; - deposit_type: string; - vendor_id: number | null; - vendor_name: string | null; - status: string; + amount: number | string; // API 실제 필드명 + client_id: number | null; // API 실제 필드명 + client_name: string | null; // API 실제 필드명 + bank_account_id: number | null; + payment_method: string | null; // API 실제 필드명 (결제수단) + account_code: string | null; // API 실제 필드명 (계정과목) + description: string | null; // API 실제 필드명 + reference_type: string | null; + reference_id: number | null; created_at: string; updated_at: string; + // 관계 데이터 + client?: { id: number; name: string } | null; + bank_account?: { id: number; bank_name: string; account_name: string } | null; } interface PaginationMeta { @@ -32,16 +36,16 @@ function transformApiToFrontend(apiData: DepositApiData): DepositRecord { return { id: String(apiData.id), depositDate: apiData.deposit_date, - depositAmount: typeof apiData.deposit_amount === 'string' - ? parseFloat(apiData.deposit_amount) - : apiData.deposit_amount, - accountName: apiData.account_name || '', - depositorName: apiData.depositor_name || '', - note: apiData.note || '', - depositType: (apiData.deposit_type || 'unset') as DepositType, - vendorId: apiData.vendor_id ? String(apiData.vendor_id) : '', - vendorName: apiData.vendor_name || '', - status: (apiData.status || 'inputWaiting') as DepositStatus, + depositAmount: typeof apiData.amount === 'string' + ? parseFloat(apiData.amount) + : (apiData.amount ?? 0), + accountName: apiData.bank_account?.account_name || '', + depositorName: apiData.client_name || apiData.client?.name || '', + note: apiData.description || '', + depositType: (apiData.account_code || 'unset') as DepositType, + vendorId: apiData.client_id ? String(apiData.client_id) : '', + vendorName: apiData.client?.name || apiData.client_name || '', + status: 'inputWaiting' as DepositStatus, // API에 status 필드 없음 - 기본값 createdAt: apiData.created_at, updatedAt: apiData.updated_at, }; @@ -52,14 +56,12 @@ function transformFrontendToApi(data: Partial): Record = {}; if (data.depositDate !== undefined) result.deposit_date = data.depositDate; - if (data.depositAmount !== undefined) result.deposit_amount = data.depositAmount; - if (data.accountName !== undefined) result.account_name = data.accountName; - if (data.depositorName !== undefined) result.depositor_name = data.depositorName; - if (data.note !== undefined) result.note = data.note || null; - if (data.depositType !== undefined) result.deposit_type = data.depositType; - if (data.vendorId !== undefined) result.vendor_id = data.vendorId ? parseInt(data.vendorId, 10) : null; - if (data.vendorName !== undefined) result.vendor_name = data.vendorName || null; - if (data.status !== undefined) result.status = data.status; + if (data.depositAmount !== undefined) result.amount = data.depositAmount; + if (data.depositorName !== undefined) result.client_name = data.depositorName; + if (data.note !== undefined) result.description = data.note || null; + if (data.depositType !== undefined) result.account_code = data.depositType; + if (data.vendorId !== undefined) result.client_id = data.vendorId ? parseInt(data.vendorId, 10) : null; + // accountName, vendorName은 관계 데이터이므로 직접 저장하지 않음 return result; } diff --git a/src/components/accounting/DepositManagement/index.tsx b/src/components/accounting/DepositManagement/index.tsx index c36eab20..ee4427df 100644 --- a/src/components/accounting/DepositManagement/index.tsx +++ b/src/components/accounting/DepositManagement/index.tsx @@ -145,10 +145,10 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan result.sort((a, b) => new Date(a.depositDate).getTime() - new Date(b.depositDate).getTime()); break; case 'amountHigh': - result.sort((a, b) => b.depositAmount - a.depositAmount); + result.sort((a, b) => (b.depositAmount ?? 0) - (a.depositAmount ?? 0)); break; case 'amountLow': - result.sort((a, b) => a.depositAmount - b.depositAmount); + result.sort((a, b) => (a.depositAmount ?? 0) - (b.depositAmount ?? 0)); break; } @@ -230,7 +230,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan // ===== 통계 카드 (총 입금, 당월 입금, 거래처 미설정, 입금유형 미설정) ===== const statCards: StatCard[] = useMemo(() => { - const totalDeposit = data.reduce((sum, d) => sum + d.depositAmount, 0); + const totalDeposit = data.reduce((sum, d) => sum + (d.depositAmount ?? 0), 0); // 당월 입금 const currentMonth = new Date().getMonth(); @@ -240,7 +240,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan const date = new Date(d.depositDate); return date.getMonth() === currentMonth && date.getFullYear() === currentYear; }) - .reduce((sum, d) => sum + d.depositAmount, 0); + .reduce((sum, d) => sum + (d.depositAmount ?? 0), 0); // 거래처 미설정 건수 const vendorUnsetCount = data.filter(d => !d.vendorName).length; @@ -290,7 +290,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan {/* 입금자명 */} {item.depositorName} {/* 입금금액 */} - {item.depositAmount.toLocaleString()} + {(item.depositAmount ?? 0).toLocaleString()} {/* 거래처 */} {item.vendorName || '미설정'} @@ -358,7 +358,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan infoGrid={
- +
@@ -513,7 +513,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan // ===== 테이블 합계 계산 ===== const tableTotals = useMemo(() => { - const totalAmount = filteredData.reduce((sum, item) => sum + item.depositAmount, 0); + const totalAmount = filteredData.reduce((sum, item) => sum + (item.depositAmount ?? 0), 0); return { totalAmount }; }, [filteredData]); diff --git a/src/components/accounting/ExpectedExpenseManagement/index.tsx b/src/components/accounting/ExpectedExpenseManagement/index.tsx index 073deb9f..aac4c8a7 100644 --- a/src/components/accounting/ExpectedExpenseManagement/index.tsx +++ b/src/components/accounting/ExpectedExpenseManagement/index.tsx @@ -288,7 +288,7 @@ export function ExpectedExpenseManagement({ // ===== 거래처 필터 옵션 (데이터에서 동적 추출) ===== const vendorFilterOptions = useMemo(() => { - const vendors = [...new Set(data.map(item => item.vendorName))]; + const vendors = [...new Set(data.map(item => item.vendorName).filter(Boolean))]; return [ { value: 'all', label: '전체' }, ...vendors.map(vendor => ({ value: vendor, label: vendor })) diff --git a/src/components/accounting/ReceivablesStatus/actions.ts b/src/components/accounting/ReceivablesStatus/actions.ts index 1a89c4d6..add6d554 100644 --- a/src/components/accounting/ReceivablesStatus/actions.ts +++ b/src/components/accounting/ReceivablesStatus/actions.ts @@ -2,12 +2,15 @@ import { cookies } from 'next/headers'; import { serverFetch } from '@/lib/api/fetch-wrapper'; -import type { VendorReceivables, CategoryType, MonthlyAmount } from './types'; +import type { VendorReceivables, CategoryType, MonthlyAmount, ReceivablesListResponse, MemoUpdateRequest } from './types'; // ===== API 응답 타입 ===== interface CategoryAmountApi { category: CategoryType; - amounts: MonthlyAmount; + amounts: { + values: number[]; + total: number; + }; } interface VendorReceivablesApi { @@ -15,11 +18,19 @@ interface VendorReceivablesApi { vendor_id: number; vendor_name: string; is_overdue: boolean; - overdue_months: number[]; + memo: string; + carry_forward_balance: number; + month_labels: string[]; categories: CategoryAmountApi[]; } +interface ReceivablesListApiResponse { + month_labels: string[]; + items: VendorReceivablesApi[]; +} + interface ReceivablesSummaryApi { + total_carry_forward: number; total_sales: number; total_deposits: number; total_bills: number; @@ -34,10 +45,15 @@ function transformItem(item: VendorReceivablesApi): VendorReceivables { id: item.id, vendorName: item.vendor_name, isOverdue: item.is_overdue, - overdueMonths: item.overdue_months, + memo: item.memo || '', + carryForwardBalance: item.carry_forward_balance || 0, + monthLabels: item.month_labels || [], categories: item.categories.map(cat => ({ category: cat.category, - amounts: cat.amounts, + amounts: { + values: cat.amounts.values, + total: cat.amounts.total, + }, })), }; } @@ -49,32 +65,42 @@ export async function getReceivablesList(params?: { hasReceivable?: boolean; }): Promise<{ success: boolean; - data: VendorReceivables[]; + data: ReceivablesListResponse; error?: string; }> { try { const searchParams = new URLSearchParams(); - if (params?.year) searchParams.set('year', String(params.year)); + // year=0은 "최근 1년" 옵션 - recent_year 파라미터 사용 + // 명시적으로 year가 숫자이고 0인지 확인 (undefined와 구분) + const yearValue = params?.year; + if (typeof yearValue === 'number') { + if (yearValue === 0) { + searchParams.set('recent_year', 'true'); + } else { + searchParams.set('year', String(yearValue)); + } + } if (params?.search) searchParams.set('search', params.search); if (params?.hasReceivable !== undefined) { searchParams.set('has_receivable', params.hasReceivable ? 'true' : 'false'); } const queryString = searchParams.toString(); + console.log('[ReceivablesActions] getReceivablesList - year:', yearValue, 'queryString:', queryString); const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/receivables${queryString ? `?${queryString}` : ''}`; const { response, error } = await serverFetch(url, { method: 'GET' }); if (error) { - return { success: false, data: [], error: error.message }; + return { success: false, data: { monthLabels: [], items: [] }, error: error.message }; } if (!response?.ok) { console.warn('[ReceivablesActions] GET receivables error:', response?.status); return { success: false, - data: [], + data: { monthLabels: [], items: [] }, error: `API 오류: ${response?.status}`, }; } @@ -84,22 +110,26 @@ export async function getReceivablesList(params?: { if (!result.success) { return { success: false, - data: [], + data: { monthLabels: [], items: [] }, error: result.message || '채권 현황 조회에 실패했습니다.', }; } - const items = (result.data || []).map(transformItem); + const apiData: ReceivablesListApiResponse = result.data; + const items = (apiData.items || []).map(transformItem); return { success: true, - data: items, + data: { + monthLabels: apiData.month_labels || [], + items, + }, }; } catch (error) { console.error('[ReceivablesActions] getReceivablesList error:', error); return { success: false, - data: [], + data: { monthLabels: [], items: [] }, error: '서버 오류가 발생했습니다.', }; } @@ -111,6 +141,7 @@ export async function getReceivablesSummary(params?: { }): Promise<{ success: boolean; data?: { + totalCarryForward: number; totalSales: number; totalDeposits: number; totalBills: number; @@ -123,9 +154,18 @@ export async function getReceivablesSummary(params?: { try { const searchParams = new URLSearchParams(); - if (params?.year) searchParams.set('year', String(params.year)); + // year=0은 "최근 1년" 옵션 - recent_year 파라미터 사용 + const yearValue = params?.year; + if (typeof yearValue === 'number') { + if (yearValue === 0) { + searchParams.set('recent_year', 'true'); + } else { + searchParams.set('year', String(yearValue)); + } + } const queryString = searchParams.toString(); + console.log('[ReceivablesActions] getReceivablesSummary - year:', yearValue, 'queryString:', queryString); const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/receivables/summary${queryString ? `?${queryString}` : ''}`; const { response, error } = await serverFetch(url, { method: 'GET' }); @@ -156,6 +196,7 @@ export async function getReceivablesSummary(params?: { return { success: true, data: { + totalCarryForward: apiSummary.total_carry_forward, totalSales: apiSummary.total_sales, totalDeposits: apiSummary.total_deposits, totalBills: apiSummary.total_bills, @@ -188,7 +229,7 @@ export async function updateOverdueStatus( method: 'PUT', body: JSON.stringify({ updates: updates.map(item => ({ - id: item.id, + id: parseInt(item.id, 10), is_overdue: item.isOverdue, })), }), @@ -228,6 +269,61 @@ export async function updateOverdueStatus( } } +// ===== 메모 일괄 업데이트 ===== +export async function updateMemos( + memos: MemoUpdateRequest[] +): Promise<{ + success: boolean; + updatedCount?: number; + error?: string; +}> { + try { + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/receivables/memos`; + + const { response, error } = await serverFetch(url, { + method: 'PUT', + body: JSON.stringify({ + memos: memos.map(item => ({ + id: parseInt(item.id, 10), + memo: item.memo, + })), + }), + }); + + if (error) { + return { success: false, error: error.message }; + } + + if (!response?.ok) { + console.warn('[ReceivablesActions] PUT memos error:', response?.status); + return { + success: false, + error: `API 오류: ${response?.status}`, + }; + } + + const result = await response.json(); + + if (!result.success) { + return { + success: false, + error: result.message || '메모 업데이트에 실패했습니다.', + }; + } + + return { + success: true, + updatedCount: result.data?.updated_count || memos.length, + }; + } catch (error) { + console.error('[ReceivablesActions] updateMemos error:', error); + return { + success: false, + error: '서버 오류가 발생했습니다.', + }; + } +} + // ===== 엑셀 다운로드 ===== export async function exportReceivablesExcel(params?: { year?: number; @@ -249,7 +345,15 @@ export async function exportReceivablesExcel(params?: { }; const searchParams = new URLSearchParams(); - if (params?.year) searchParams.set('year', String(params.year)); + // year=0은 "최근 1년" 옵션 - recent_year 파라미터 사용 + const yearValue = params?.year; + if (typeof yearValue === 'number') { + if (yearValue === 0) { + searchParams.set('recent_year', 'true'); + } else { + searchParams.set('year', String(yearValue)); + } + } if (params?.search) searchParams.set('search', params.search); const queryString = searchParams.toString(); diff --git a/src/components/accounting/ReceivablesStatus/index.tsx b/src/components/accounting/ReceivablesStatus/index.tsx index 3629b9b2..7884bd72 100644 --- a/src/components/accounting/ReceivablesStatus/index.tsx +++ b/src/components/accounting/ReceivablesStatus/index.tsx @@ -4,6 +4,7 @@ import { useState, useMemo, useCallback, useEffect, useRef, useTransition } from import { Download, FileText, Save, Loader2, RefreshCw } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; +import { Input } from '@/components/ui/input'; import { Card, CardContent } from '@/components/ui/card'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, TableFooter } from '@/components/ui/table'; import { @@ -19,21 +20,23 @@ import { SearchFilter } from '@/components/organisms/SearchFilter'; import type { VendorReceivables, CategoryType, - MonthlyAmount, + SortOption, + ReceivablesListResponse, + MemoUpdateRequest, } from './types'; import { CATEGORY_LABELS, - MONTH_LABELS, - MONTH_KEYS, + SORT_OPTIONS, } from './types'; -import { getReceivablesList, getReceivablesSummary, updateOverdueStatus, exportReceivablesExcel } from './actions'; +import { getReceivablesList, getReceivablesSummary, updateOverdueStatus, updateMemos, exportReceivablesExcel } from './actions'; import { toast } from 'sonner'; // ===== Props 인터페이스 ===== interface ReceivablesStatusProps { highlightVendorId?: string; - initialData?: VendorReceivables[]; + initialData?: ReceivablesListResponse; initialSummary?: { + totalCarryForward: number; totalSales: number; totalDeposits: number; totalBills: number; @@ -43,23 +46,35 @@ interface ReceivablesStatusProps { }; } -// ===== 연도 옵션 생성 ===== -const generateYearOptions = (): number[] => { +// ===== 연도 옵션 생성 (0 = 최근 1년) ===== +const YEAR_RECENT = 0; // 최근 1년 옵션 값 +const generateYearOptions = (): Array<{ value: number; label: string }> => { const currentYear = new Date().getFullYear(); - return Array.from({ length: 5 }, (_, i) => currentYear - i); + const years = Array.from({ length: 5 }, (_, i) => ({ + value: currentYear - i, + label: `${currentYear - i}년`, + })); + return [{ value: YEAR_RECENT, label: '최근 1년' }, ...years]; }; -export function ReceivablesStatus({ highlightVendorId, initialData = [], initialSummary }: ReceivablesStatusProps) { +// ===== 카테고리 순서 (메모 제외) ===== +const categoryOrder: CategoryType[] = ['sales', 'deposit', 'bill', 'receivable']; + +export function ReceivablesStatus({ highlightVendorId, initialData, initialSummary }: ReceivablesStatusProps) { // ===== Refs ===== const highlightRowRef = useRef(null); // ===== 상태 관리 ===== const [isPending, startTransition] = useTransition(); - const [selectedYear, setSelectedYear] = useState(new Date().getFullYear()); + const [selectedYear, setSelectedYear] = useState(YEAR_RECENT); // 기본: 최근 1년 + const [sortOption, setSortOption] = useState('overdueFirst'); // 기본: 연체 업체 우선 const [searchQuery, setSearchQuery] = useState(''); - const [data, setData] = useState(initialData); + 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, @@ -67,7 +82,7 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial vendorCount: 0, overdueVendorCount: 0, }); - const [isLoading, setIsLoading] = useState(!initialData.length); + const [isLoading, setIsLoading] = useState(!initialData?.items?.length); // ===== 데이터 로드 ===== const loadData = useCallback(async () => { @@ -79,13 +94,17 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial ]); if (listResult.success) { - setData(listResult.data); + setMonthLabels(listResult.data.monthLabels); + setData(listResult.data.items); // 원본 연체 상태 저장 const overdueMap = new Map(); - listResult.data.forEach(vendor => { + 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) { @@ -118,6 +137,48 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial ); }, [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 => @@ -125,6 +186,13 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial )); }, []); + // ===== 메모 변경 핸들러 ===== + const handleMemoChange = useCallback((vendorId: string, memo: string) => { + setData(prev => prev.map(vendor => + vendor.id === vendorId ? { ...vendor, memo } : vendor + )); + }, []); + // ===== 엑셀 다운로드 핸들러 ===== const handleExcelDownload = useCallback(async () => { const result = await exportReceivablesExcel({ @@ -147,8 +215,8 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial } }, [selectedYear, searchQuery]); - // ===== 변경된 항목 확인 ===== - const changedItems = useMemo(() => { + // ===== 변경된 연체 항목 확인 ===== + const changedOverdueItems = useMemo(() => { return data.filter(vendor => { const originalValue = originalOverdueMap.get(vendor.id); return originalValue !== undefined && originalValue !== vendor.isOverdue; @@ -158,28 +226,64 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial })); }, [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 (changedItems.length === 0) { + if (totalChangedCount === 0) { toast.info('변경된 항목이 없습니다.'); return; } startTransition(async () => { - const result = await updateOverdueStatus(changedItems); - if (result.success) { + 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); - changedItems.forEach(item => { + changedOverdueItems.forEach(item => { newOverdueMap.set(item.id, item.isOverdue); }); setOriginalOverdueMap(newOverdueMap); - toast.success(`${result.updatedCount || changedItems.length}건의 연체 상태가 저장되었습니다.`); + + const newMemoMap = new Map(originalMemoMap); + changedMemoItems.forEach(item => { + newMemoMap.set(item.id, item.memo); + }); + setOriginalMemoMap(newMemoMap); + + toast.success(`${totalChangedCount}건이 저장되었습니다.`); } else { - toast.error(result.error || '저장에 실패했습니다.'); + const errors = results.filter(r => !r.success).map(r => r.error).join(', '); + toast.error(errors || '저장에 실패했습니다.'); } }); - }, [changedItems, originalOverdueMap]); + }, [changedOverdueItems, changedMemoItems, totalChangedCount, originalOverdueMap, originalMemoMap]); // ===== 금액 포맷 ===== const formatAmount = (amount: number) => { @@ -187,34 +291,29 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial return amount.toLocaleString(); }; - // ===== 연체 여부 확인 ===== - const isOverdueCell = (vendor: VendorReceivables, monthIndex: number) => { - return vendor.isOverdue && vendor.overdueMonths?.includes(monthIndex + 1); - }; - - // ===== 합계 계산 ===== + // ===== 합계 계산 (동적 월) ===== const grandTotals = useMemo(() => { - const totals: MonthlyAmount = { - month1: 0, month2: 0, month3: 0, month4: 0, month5: 0, month6: 0, - month7: 0, month8: 0, month9: 0, month10: 0, month11: 0, month12: 0, - total: 0, - }; + 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) { - MONTH_KEYS.forEach(key => { - totals[key] += receivableCat.amounts[key]; + receivableCat.amounts.values.forEach((val, idx) => { + if (idx < monthCount) { + values[idx] += val; + } }); - totals.total += receivableCat.amounts.total; + total += receivableCat.amounts.total; } }); - return totals; - }, [filteredData]); + return { values, total }; + }, [filteredData, monthLabels.length]); - // ===== 카테고리 순서 ===== - const categoryOrder: CategoryType[] = ['sales', 'deposit', 'bill', 'receivable', 'memo']; + // ===== 월 개수 (동적) ===== + const monthCount = monthLabels.length || 12; return ( @@ -230,6 +329,7 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial
+ {/* 연도 선택 */}
연도 +
+ + {/* 정렬 선택 */} +
+ 정렬 + handleMemoChange(vendor.id, e.target.value)} + placeholder="거래처 메모를 입력하세요..." + className="w-full h-8 text-sm" + /> + + + ); + + return rows; + }) )} @@ -429,9 +568,9 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial 미수금 {/* 월별 합계 - 스크롤 영역 */} - {MONTH_KEYS.map((monthKey) => ( - - {formatAmount(grandTotals[monthKey])} + {grandTotals.values.map((amount, idx) => ( + + {formatAmount(amount)} ))} {/* 총합계 - 오른쪽 고정 */} diff --git a/src/components/accounting/ReceivablesStatus/types.ts b/src/components/accounting/ReceivablesStatus/types.ts index 24f41c06..7fa06251 100644 --- a/src/components/accounting/ReceivablesStatus/types.ts +++ b/src/components/accounting/ReceivablesStatus/types.ts @@ -1,30 +1,22 @@ /** * 미수금 현황 타입 정의 + * - 동적 월 지원 (최근 1년, 특정 연도) + * - 이월잔액 및 누적 미수금 계산 + * - 거래처 메모 (월별 아닌 거래처 단위) */ /** - * 월별 금액 데이터 + * 월별 금액 데이터 (동적 월 지원) */ export interface MonthlyAmount { - month1: number; - month2: number; - month3: number; - month4: number; - month5: number; - month6: number; - month7: number; - month8: number; - month9: number; - month10: number; - month11: number; - month12: number; - total: number; + values: number[]; // 월별 금액 배열 (12개 또는 동적 개수) + total: number; // 합계 } /** - * 구분 타입 (매출, 입금, 어음, 미수금, 메모) + * 구분 타입 (매출, 입금, 어음, 미수금) */ -export type CategoryType = 'sales' | 'deposit' | 'bill' | 'receivable' | 'memo'; +export type CategoryType = 'sales' | 'deposit' | 'bill' | 'receivable'; /** * 구분 레이블 @@ -34,7 +26,6 @@ export const CATEGORY_LABELS: Record = { deposit: '입금', bill: '어음', receivable: '미수금', - memo: '메모', }; /** @@ -52,30 +43,36 @@ export interface VendorReceivables { id: string; vendorName: string; isOverdue: boolean; // 연체 토글 상태 - overdueMonths: number[]; // 연체 월 (1-12) + memo: string; // 거래처 메모 (월별 아닌 거래처 단위) + carryForwardBalance: number; // 이월잔액 + monthLabels: string[]; // 동적 월 레이블 (ex: ['25.02', '25.03', ...]) categories: CategoryData[]; } +/** + * API 응답 데이터 구조 + */ +export interface ReceivablesListResponse { + monthLabels: string[]; // 공통 월 레이블 (헤더용) + items: VendorReceivables[]; +} + /** * 정렬 옵션 */ -export type SortOption = 'vendorName' | 'totalDesc' | 'totalAsc'; +export type SortOption = 'overdueFirst' | 'vendorName' | 'totalDesc' | 'totalAsc'; export const SORT_OPTIONS: Array<{ value: SortOption; label: string }> = [ + { value: 'overdueFirst', label: '연체 업체 우선' }, { value: 'vendorName', label: '거래처명 순' }, { value: 'totalDesc', label: '금액 높은순' }, { value: 'totalAsc', label: '금액 낮은순' }, ]; /** - * 월 레이블 (1월~12월) + * 메모 업데이트 요청 타입 */ -export const MONTH_LABELS = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월']; - -/** - * 월별 데이터 키 - */ -export const MONTH_KEYS: (keyof MonthlyAmount)[] = [ - 'month1', 'month2', 'month3', 'month4', 'month5', 'month6', - 'month7', 'month8', 'month9', 'month10', 'month11', 'month12', -]; \ No newline at end of file +export interface MemoUpdateRequest { + id: string; + memo: string; +} \ No newline at end of file diff --git a/src/components/accounting/VendorManagement/VendorDetail.tsx b/src/components/accounting/VendorManagement/VendorDetail.tsx index 93e5f5e6..4318add1 100644 --- a/src/components/accounting/VendorManagement/VendorDetail.tsx +++ b/src/components/accounting/VendorManagement/VendorDetail.tsx @@ -93,6 +93,7 @@ const getEmptyVendor = (): Omit => ({ overdueAmount: 0, overdueDays: 0, unpaidAmount: 0, + badDebtAmount: 0, badDebtStatus: 'none', overdueToggle: false, badDebtToggle: false, diff --git a/src/components/accounting/VendorManagement/actions.ts b/src/components/accounting/VendorManagement/actions.ts index fcf76534..eba265f0 100644 --- a/src/components/accounting/VendorManagement/actions.ts +++ b/src/components/accounting/VendorManagement/actions.ts @@ -19,8 +19,6 @@ import type { ApiResponse, PaginatedResponse, VendorCategory, - CLIENT_TYPE_TO_CATEGORY, - CATEGORY_TO_CLIENT_TYPE, BadDebtStatus, } from './types'; @@ -96,7 +94,7 @@ function transformApiToFrontend(apiData: ClientApiData): Vendor { unpaidAmount: 0, badDebtAmount: badDebtTotal, // bad_debts 테이블 기반 악성채권 합계 badDebtStatus, - overdueToggle: false, + overdueToggle: apiData.is_overdue ?? false, // API의 is_overdue 값 사용 badDebtToggle: hasBadDebt, // 메모 @@ -137,6 +135,7 @@ function transformFrontendToApi(data: Partial): Record business_type: data.businessType || null, business_item: data.businessCategory || null, bad_debt: data.badDebtToggle || false, + is_overdue: data.overdueToggle ?? false, // 연체 토글 상태 전송 memo: data.memos?.[0]?.content || null, is_active: true, }; diff --git a/src/components/accounting/VendorManagement/index.ts b/src/components/accounting/VendorManagement/index.ts index 01567738..ab5e4119 100644 --- a/src/components/accounting/VendorManagement/index.ts +++ b/src/components/accounting/VendorManagement/index.ts @@ -4,6 +4,7 @@ // 클라이언트 컴포넌트 export { VendorManagementClient } from './VendorManagementClient'; +export { VendorManagementClient as VendorManagement } from './VendorManagementClient'; // 상세/수정 컴포넌트 export { VendorDetail } from './VendorDetail'; diff --git a/src/components/accounting/VendorManagement/types.ts b/src/components/accounting/VendorManagement/types.ts index ac1ef753..b51e8bab 100644 --- a/src/components/accounting/VendorManagement/types.ts +++ b/src/components/accounting/VendorManagement/types.ts @@ -198,6 +198,7 @@ export interface Vendor { overdueAmount: number; // 연체금액 overdueDays: number; // 연체일수 unpaidAmount: number; // 미지급 + badDebtAmount: number; // 악성채권 금액 badDebtStatus: BadDebtStatus; // 악성채권 상태 overdueToggle: boolean; // 연체 토글 badDebtToggle: boolean; // 악성채권 토글 @@ -215,4 +216,63 @@ export const VENDOR_CATEGORY_COLORS: Record = { sales: 'bg-green-100 text-green-800', purchase: 'bg-orange-100 text-orange-800', both: 'bg-blue-100 text-blue-800', +}; + +// ===== API 데이터 타입 ===== +export interface ClientApiData { + id: number; + tenant_id?: number; + client_code?: string; + name?: string; + client_type?: 'SALES' | 'PURCHASE' | 'BOTH'; + contact_person?: string; + phone?: string; + mobile?: string; + fax?: string; + email?: string; + address?: string; + manager_name?: string; + manager_tel?: string; + system_manager?: string; + purchase_payment_day?: string; + sales_payment_day?: string; + business_no?: string; + business_type?: string; + business_item?: string; + account_id?: string; + memo?: string; + is_active?: boolean; + bad_debt?: boolean; + is_overdue?: boolean; + has_bad_debt?: boolean; + bad_debt_total?: number; + outstanding_amount?: number; + created_at: string; + updated_at: string; +} + +export interface ApiResponse { + success: boolean; + message?: string; + data?: T; +} + +export interface PaginatedResponse { + data: T[]; + total: number; + page?: number; + size?: number; +} + +// ===== API 타입 매핑 상수 ===== +export const CLIENT_TYPE_TO_CATEGORY: Record = { + SALES: 'sales', + PURCHASE: 'purchase', + BOTH: 'both', +}; + +export const CATEGORY_TO_CLIENT_TYPE: Record = { + sales: 'SALES', + purchase: 'PURCHASE', + both: 'BOTH', }; \ No newline at end of file diff --git a/src/components/accounting/WithdrawalManagement/actions.ts b/src/components/accounting/WithdrawalManagement/actions.ts index 6924fb35..801a1fd1 100644 --- a/src/components/accounting/WithdrawalManagement/actions.ts +++ b/src/components/accounting/WithdrawalManagement/actions.ts @@ -8,15 +8,24 @@ interface WithdrawalApiData { id: number; tenant_id: number; withdrawal_date: string; - withdrawal_amount: number | string; - account_name: string; - recipient_name: string; - note: string | null; - withdrawal_type: string; - vendor_id: number | null; - vendor_name: string | null; + used_at: string | null; + amount: number | string; // API 실제 필드명 + client_id: number | null; // API 실제 필드명 + client_name: string | null; // API 실제 필드명 + merchant_name: string | null; // API 실제 필드명 (가맹점명) + bank_account_id: number | null; + card_id: number | null; + payment_method: string | null; // API 실제 필드명 (결제수단) + account_code: string | null; // API 실제 필드명 (계정과목) + description: string | null; // API 실제 필드명 + reference_type: string | null; + reference_id: number | null; created_at: string; updated_at: string; + // 관계 데이터 + client?: { id: number; name: string } | null; + bank_account?: { id: number; bank_name: string; account_name: string } | null; + card?: { id: number; card_name: string } | null; } interface PaginationMeta { @@ -31,15 +40,15 @@ function transformApiToFrontend(apiData: WithdrawalApiData): WithdrawalRecord { return { id: String(apiData.id), withdrawalDate: apiData.withdrawal_date, - withdrawalAmount: typeof apiData.withdrawal_amount === 'string' - ? parseFloat(apiData.withdrawal_amount) - : apiData.withdrawal_amount, - accountName: apiData.account_name || '', - recipientName: apiData.recipient_name || '', - note: apiData.note || '', - withdrawalType: (apiData.withdrawal_type || 'unset') as WithdrawalType, - vendorId: apiData.vendor_id ? String(apiData.vendor_id) : '', - vendorName: apiData.vendor_name || '', + withdrawalAmount: typeof apiData.amount === 'string' + ? parseFloat(apiData.amount) + : (apiData.amount ?? 0), + accountName: apiData.bank_account?.account_name || '', + recipientName: apiData.merchant_name || apiData.client_name || apiData.client?.name || '', + note: apiData.description || '', + withdrawalType: (apiData.account_code || 'unset') as WithdrawalType, + vendorId: apiData.client_id ? String(apiData.client_id) : '', + vendorName: apiData.client?.name || apiData.client_name || '', createdAt: apiData.created_at, updatedAt: apiData.updated_at, }; @@ -50,13 +59,12 @@ function transformFrontendToApi(data: Partial): Record = {}; if (data.withdrawalDate !== undefined) result.withdrawal_date = data.withdrawalDate; - if (data.withdrawalAmount !== undefined) result.withdrawal_amount = data.withdrawalAmount; - if (data.accountName !== undefined) result.account_name = data.accountName; - if (data.recipientName !== undefined) result.recipient_name = data.recipientName; - if (data.note !== undefined) result.note = data.note || null; - if (data.withdrawalType !== undefined) result.withdrawal_type = data.withdrawalType; - if (data.vendorId !== undefined) result.vendor_id = data.vendorId ? parseInt(data.vendorId, 10) : null; - if (data.vendorName !== undefined) result.vendor_name = data.vendorName || null; + if (data.withdrawalAmount !== undefined) result.amount = data.withdrawalAmount; + if (data.recipientName !== undefined) result.client_name = data.recipientName; + if (data.note !== undefined) result.description = data.note || null; + if (data.withdrawalType !== undefined) result.account_code = data.withdrawalType; + if (data.vendorId !== undefined) result.client_id = data.vendorId ? parseInt(data.vendorId, 10) : null; + // accountName, vendorName은 관계 데이터이므로 직접 저장하지 않음 return result; } @@ -130,13 +138,24 @@ export async function getWithdrawals(params?: { }; } - const withdrawals = (result.data || []).map(transformApiToFrontend); - const meta: PaginationMeta = result.meta || { - current_page: 1, - last_page: 1, - per_page: 20, - total: withdrawals.length, - }; + // API 응답 구조 처리: { data: { data: [...], current_page: ... } } 또는 { data: [...], meta: {...} } + const isPaginatedResponse = result.data && typeof result.data === 'object' && 'data' in result.data && Array.isArray(result.data.data); + const rawData = isPaginatedResponse ? result.data.data : (Array.isArray(result.data) ? result.data : []); + const withdrawals = rawData.map(transformApiToFrontend); + + const meta: PaginationMeta = isPaginatedResponse + ? { + current_page: result.data.current_page || 1, + last_page: result.data.last_page || 1, + per_page: result.data.per_page || 20, + total: result.data.total || withdrawals.length, + } + : result.meta || { + current_page: 1, + last_page: 1, + per_page: 20, + total: withdrawals.length, + }; return { success: true, diff --git a/src/components/accounting/WithdrawalManagement/index.tsx b/src/components/accounting/WithdrawalManagement/index.tsx index b02d7ad5..e81cbdf1 100644 --- a/src/components/accounting/WithdrawalManagement/index.tsx +++ b/src/components/accounting/WithdrawalManagement/index.tsx @@ -228,7 +228,7 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra // ===== 통계 카드 (총 출금, 당월 출금, 거래처 미설정, 출금유형 미설정) ===== const statCards: StatCard[] = useMemo(() => { - const totalWithdrawal = data.reduce((sum, d) => sum + d.withdrawalAmount, 0); + const totalWithdrawal = data.reduce((sum, d) => sum + (d.withdrawalAmount ?? 0), 0); // 당월 출금 const currentMonth = new Date().getMonth(); @@ -238,7 +238,7 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra const date = new Date(d.withdrawalDate); return date.getMonth() === currentMonth && date.getFullYear() === currentYear; }) - .reduce((sum, d) => sum + d.withdrawalAmount, 0); + .reduce((sum, d) => sum + (d.withdrawalAmount ?? 0), 0); // 거래처 미설정 건수 const vendorUnsetCount = data.filter(d => !d.vendorName).length; @@ -288,7 +288,7 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra {/* 수취인명 */} {item.recipientName} {/* 출금금액 */} - {item.withdrawalAmount.toLocaleString()} + {(item.withdrawalAmount ?? 0).toLocaleString()} {/* 거래처 */} {item.vendorName || '미설정'} @@ -352,9 +352,9 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra onToggleSelection={onToggle} infoGrid={
- - - + + +
} @@ -507,7 +507,7 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra // ===== 테이블 합계 계산 ===== const tableTotals = useMemo(() => { - const totalAmount = filteredData.reduce((sum, item) => sum + item.withdrawalAmount, 0); + const totalAmount = filteredData.reduce((sum, item) => sum + (item.withdrawalAmount ?? 0), 0); return { totalAmount }; }, [filteredData]);