feat(WEB): 회계 관리 기능 개선
- 입금관리: API 연동 개선 - 출금관리: API 연동 개선 - 미수현황: 조회 로직 및 UI 개선 - 거래처관리: 상세 정보 표시 개선
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
{/* 총합계 - 오른쪽 고정 */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
// 클라이언트 컴포넌트
|
||||
export { VendorManagementClient } from './VendorManagementClient';
|
||||
export { VendorManagementClient as VendorManagement } from './VendorManagementClient';
|
||||
|
||||
// 상세/수정 컴포넌트
|
||||
export { VendorDetail } from './VendorDetail';
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user