feat(WEB): 회계 관리 기능 개선

- 입금관리: API 연동 개선
- 출금관리: API 연동 개선
- 미수현황: 조회 로직 및 UI 개선
- 거래처관리: 상세 정보 표시 개선
This commit is contained in:
2026-01-06 21:20:25 +09:00
parent 4b1a3abf05
commit 810a348f31
12 changed files with 527 additions and 205 deletions

View File

@@ -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<DepositRecord>): Record<string, un
const result: Record<string, unknown> = {};
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;
}

View File

@@ -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
{/* 입금자명 */}
<TableCell>{item.depositorName}</TableCell>
{/* 입금금액 */}
<TableCell className="text-right font-medium">{item.depositAmount.toLocaleString()}</TableCell>
<TableCell className="text-right font-medium">{(item.depositAmount ?? 0).toLocaleString()}</TableCell>
{/* 거래처 */}
<TableCell className={isVendorUnset ? 'text-red-500 font-medium' : ''}>
{item.vendorName || '미설정'}
@@ -358,7 +358,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
infoGrid={
<div className="grid grid-cols-2 gap-3">
<InfoField label="입금일" value={item.depositDate} />
<InfoField label="입금액" value={`${item.depositAmount.toLocaleString()}`} />
<InfoField label="입금액" value={`${(item.depositAmount ?? 0).toLocaleString()}`} />
<InfoField label="입금계좌" value={item.accountName} />
<InfoField label="거래처" value={item.vendorName || '-'} />
</div>
@@ -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]);

View File

@@ -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 }))

View File

@@ -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();

View File

@@ -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<HTMLTableRowElement>(null);
// ===== 상태 관리 =====
const [isPending, startTransition] = useTransition();
const [selectedYear, setSelectedYear] = useState<number>(new Date().getFullYear());
const [selectedYear, setSelectedYear] = useState<number>(YEAR_RECENT); // 기본: 최근 1년
const [sortOption, setSortOption] = useState<SortOption>('overdueFirst'); // 기본: 연체 업체 우선
const [searchQuery, setSearchQuery] = useState('');
const [data, setData] = useState<VendorReceivables[]>(initialData);
const [monthLabels, setMonthLabels] = useState<string[]>(initialData?.monthLabels || []);
const [data, setData] = useState<VendorReceivables[]>(initialData?.items || []);
const [originalOverdueMap, setOriginalOverdueMap] = useState<Map<string, boolean>>(new Map());
const [originalMemoMap, setOriginalMemoMap] = useState<Map<string, string>>(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<string, boolean>();
listResult.data.forEach(vendor => {
const memoMap = new Map<string, string>();
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 (
<PageLayout>
@@ -230,6 +329,7 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{/* 연도 선택 */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700"></span>
<Select
@@ -240,9 +340,29 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial
<SelectValue placeholder="연도 선택" />
</SelectTrigger>
<SelectContent>
{generateYearOptions().map((year) => (
<SelectItem key={year} value={String(year)}>
{year}
{generateYearOptions().map((option) => (
<SelectItem key={option.value} value={String(option.value)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 정렬 선택 */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700"></span>
<Select
value={sortOption}
onValueChange={(value) => setSortOption(value as SortOption)}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="정렬 선택" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
@@ -274,7 +394,7 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial
<Button
size="sm"
onClick={handleSave}
disabled={isPending || changedItems.length === 0}
disabled={isPending || totalChangedCount === 0}
className="bg-orange-500 hover:bg-orange-600 disabled:opacity-50"
>
{isPending ? (
@@ -282,7 +402,7 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial
) : (
<Save className="mr-2 h-4 w-4" />
)}
{changedItems.length > 0 && `(${changedItems.length})`}
{totalChangedCount > 0 && `(${totalChangedCount})`}
</Button>
</div>
</div>
@@ -316,9 +436,9 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial
<TableHead className="w-[70px] min-w-[70px] text-center sticky left-[120px] z-20 bg-white border-r border-gray-200">
</TableHead>
{/* 1월~12월 - 스크롤 영역 */}
{MONTH_LABELS.map((month) => (
<TableHead key={month} className="w-[80px] min-w-[80px] text-right">
{/* 동적 월 레이블 - 스크롤 영역 */}
{monthLabels.map((month, idx) => (
<TableHead key={`${month}-${idx}`} className="w-[80px] min-w-[80px] text-right">
{month}
</TableHead>
))}
@@ -331,41 +451,45 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={15} className="h-24 text-center">
<TableCell colSpan={monthCount + 3} className="h-24 text-center">
<div className="flex items-center justify-center gap-2">
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
<span className="text-gray-500"> ...</span>
</div>
</TableCell>
</TableRow>
) : filteredData.length === 0 ? (
) : sortedData.length === 0 ? (
<TableRow>
<TableCell colSpan={15} className="h-24 text-center">
<TableCell colSpan={monthCount + 3} className="h-24 text-center">
.
</TableCell>
</TableRow>
) : (
filteredData.map((vendor) => (
categoryOrder.map((category, catIndex) => {
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 = [];
// 구분별 행 렌더링
categoryOrder.forEach((category, catIndex) => {
const categoryData = vendor.categories.find(c => c.category === category);
if (!categoryData) return null;
if (!categoryData) return;
const isOverdueRow = vendor.isOverdue;
const isHighlighted = highlightVendorId === vendor.id;
// 하이라이트 > 연체 > 기본 순으로 배경색 결정
const rowBgClass = isHighlighted
? 'bg-yellow-100'
: isOverdueRow
? 'bg-red-50'
: 'bg-white';
return (
rows.push(
<TableRow
key={`${vendor.id}-${category}`}
ref={catIndex === 0 && isHighlighted ? highlightRowRef : undefined}
className={`${catIndex === 0 ? 'border-t-2 border-gray-300' : ''} ${isHighlighted ? 'ring-2 ring-yellow-400 ring-inset' : ''}`}
>
{/* 거래처명 + 연체 토글 - 왼쪽 고정 */}
{/* 거래처명 + 연체 토글 - 왼쪽 고정 (첫 번째 카테고리에만) */}
{catIndex === 0 && (
<TableCell
rowSpan={5}
@@ -393,21 +517,14 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial
</TableCell>
{/* 월별 금액 - 스크롤 영역 */}
{MONTH_KEYS.map((monthKey, monthIndex) => {
const amount = categoryData.amounts[monthKey] || 0;
const isOverdue = isOverdueCell(vendor, monthIndex);
return (
<TableCell
key={monthKey}
className={`text-right text-sm border-r border-gray-200 ${
isOverdue ? 'bg-red-100 text-red-700' : ''
}`}
>
{formatAmount(amount)}
</TableCell>
);
})}
{categoryData.amounts.values.map((amount, monthIndex) => (
<TableCell
key={`${vendor.id}-${category}-${monthIndex}`}
className="text-right text-sm border-r border-gray-200"
>
{formatAmount(amount)}
</TableCell>
))}
{/* 합계 - 오른쪽 고정 */}
<TableCell className={`text-right font-medium text-sm sticky right-0 z-10 border-l border-gray-200 ${rowBgClass}`}>
@@ -415,8 +532,30 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial
</TableCell>
</TableRow>
);
})
))
});
// 메모 행 추가 (마지막 행)
rows.push(
<TableRow key={`${vendor.id}-memo`}>
{/* 구분: 메모 */}
<TableCell className={`text-center border-r border-gray-200 text-sm sticky left-[120px] z-10 ${rowBgClass}`}>
</TableCell>
{/* 메모 입력 - 모든 월 컬럼 + 합계 컬럼 병합 */}
<TableCell colSpan={monthCount + 1} className="p-1">
<Input
value={vendor.memo}
onChange={(e) => handleMemoChange(vendor.id, e.target.value)}
placeholder="거래처 메모를 입력하세요..."
className="w-full h-8 text-sm"
/>
</TableCell>
</TableRow>
);
return rows;
})
)}
</TableBody>
<TableFooter>
@@ -429,9 +568,9 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial
</TableCell>
{/* 월별 합계 - 스크롤 영역 */}
{MONTH_KEYS.map((monthKey) => (
<TableCell key={monthKey} className="text-right text-sm border-r border-gray-200">
{formatAmount(grandTotals[monthKey])}
{grandTotals.values.map((amount, idx) => (
<TableCell key={`total-${idx}`} className="text-right text-sm border-r border-gray-200">
{formatAmount(amount)}
</TableCell>
))}
{/* 총합계 - 오른쪽 고정 */}

View File

@@ -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<CategoryType, string> = {
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',
];
export interface MemoUpdateRequest {
id: string;
memo: string;
}

View File

@@ -93,6 +93,7 @@ const getEmptyVendor = (): Omit<Vendor, 'id' | 'createdAt' | 'updatedAt'> => ({
overdueAmount: 0,
overdueDays: 0,
unpaidAmount: 0,
badDebtAmount: 0,
badDebtStatus: 'none',
overdueToggle: false,
badDebtToggle: false,

View File

@@ -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<Vendor>): Record<string, unknown>
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,
};

View File

@@ -4,6 +4,7 @@
// 클라이언트 컴포넌트
export { VendorManagementClient } from './VendorManagementClient';
export { VendorManagementClient as VendorManagement } from './VendorManagementClient';
// 상세/수정 컴포넌트
export { VendorDetail } from './VendorDetail';

View File

@@ -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<VendorCategory, string> = {
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<T> {
success: boolean;
message?: string;
data?: T;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page?: number;
size?: number;
}
// ===== API 타입 매핑 상수 =====
export const CLIENT_TYPE_TO_CATEGORY: Record<string, VendorCategory> = {
SALES: 'sales',
PURCHASE: 'purchase',
BOTH: 'both',
};
export const CATEGORY_TO_CLIENT_TYPE: Record<VendorCategory, string> = {
sales: 'SALES',
purchase: 'PURCHASE',
both: 'BOTH',
};

View File

@@ -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<WithdrawalRecord>): Record<string,
const result: Record<string, unknown> = {};
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,

View File

@@ -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
{/* 수취인명 */}
<TableCell>{item.recipientName}</TableCell>
{/* 출금금액 */}
<TableCell className="text-right font-medium">{item.withdrawalAmount.toLocaleString()}</TableCell>
<TableCell className="text-right font-medium">{(item.withdrawalAmount ?? 0).toLocaleString()}</TableCell>
{/* 거래처 */}
<TableCell className={isVendorUnset ? 'text-red-500 font-medium' : ''}>
{item.vendorName || '미설정'}
@@ -352,9 +352,9 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
onToggleSelection={onToggle}
infoGrid={
<div className="grid grid-cols-2 gap-3">
<InfoField label="출금일" value={item.withdrawalDate} />
<InfoField label="출금액" value={`${item.withdrawalAmount.toLocaleString()}`} />
<InfoField label="출금계좌" value={item.accountName} />
<InfoField label="출금일" value={item.withdrawalDate || '-'} />
<InfoField label="출금액" value={`${(item.withdrawalAmount ?? 0).toLocaleString()}`} />
<InfoField label="출금계좌" value={item.accountName || '-'} />
<InfoField label="거래처" value={item.vendorName || '-'} />
</div>
}
@@ -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]);