Files
sam-react-prod/src/components/accounting/ExpectedExpenseManagement/index.tsx
kent 810a348f31 feat(WEB): 회계 관리 기능 개선
- 입금관리: API 연동 개선
- 출금관리: API 연동 개선
- 미수현황: 조회 로직 및 UI 개선
- 거래처관리: 상세 정보 표시 개선
2026-01-06 21:20:25 +09:00

1273 lines
45 KiB
TypeScript

'use client';
import { useState, useMemo, useCallback, useTransition, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { format } from 'date-fns';
import { toast } from 'sonner';
import {
Receipt,
Calendar as CalendarIcon,
FileText,
Plus,
Pencil,
Trash2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { TableRow, TableCell } from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
IntegratedListTemplateV2,
type TableColumn,
type StatCard,
} from '@/components/templates/IntegratedListTemplateV2';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
import type {
ExpectedExpenseRecord,
TransactionType,
PaymentStatus,
ApprovalStatus,
SortOption,
} from './types';
import {
TRANSACTION_TYPE_LABELS,
PAYMENT_STATUS_LABELS,
APPROVAL_STATUS_LABELS,
SORT_OPTIONS,
} from './types';
import {
deleteExpectedExpense,
deleteExpectedExpenses,
updateExpectedPaymentDate,
createExpectedExpense,
updateExpectedExpense,
getClients,
getBankAccounts,
} from './actions';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
TRANSACTION_TYPE_FILTER_OPTIONS,
PAYMENT_STATUS_FILTER_OPTIONS,
ACCOUNT_SUBJECT_OPTIONS,
} from './types';
// ===== 테이블 행 타입 (데이터 + 그룹 헤더 + 소계) =====
type RowType = 'data' | 'monthHeader' | 'monthSubtotal' | 'totalExpense' | 'expectedBalance' | 'finalBalance';
interface TableRowData extends ExpectedExpenseRecord {
rowType: RowType;
monthLabel?: string;
subtotalAmount?: number;
dataIndex?: number; // 데이터 행 순번 (헤더/소계 제외)
}
// ===== 컴포넌트 Props =====
interface ExpectedExpenseManagementProps {
initialData: ExpectedExpenseRecord[];
pagination: {
currentPage: number;
lastPage: number;
perPage: number;
total: number;
};
}
// 월 추출 함수
const getMonthKey = (dateStr: string): string => {
const date = new Date(dateStr);
return format(date, 'yyyy/MM');
};
// 월 레이블 함수
const getMonthLabel = (monthKey: string): string => {
const [year, month] = monthKey.split('/');
return `${year}${parseInt(month)}`;
};
export function ExpectedExpenseManagement({
initialData,
pagination: initialPagination,
}: ExpectedExpenseManagementProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
// ===== 상태 관리 =====
const [searchQuery, setSearchQuery] = useState('');
const [vendorFilter, setVendorFilter] = useState<string>('all');
const [sortOption, setSortOption] = useState<SortOption>('latest');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(initialPagination.currentPage);
const itemsPerPage = initialPagination.perPage;
// 예상 지급일 변경 다이얼로그
const [showDateChangeDialog, setShowDateChangeDialog] = useState(false);
const [newExpectedDate, setNewExpectedDate] = useState<Date | undefined>(undefined);
// 삭제 다이얼로그
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
// 일괄삭제 다이얼로그
const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false);
// 등록/수정 폼 다이얼로그
const [showFormDialog, setShowFormDialog] = useState(false);
const [editingItem, setEditingItem] = useState<ExpectedExpenseRecord | null>(null);
const [formData, setFormData] = useState<Partial<ExpectedExpenseRecord>>({
expectedPaymentDate: format(new Date(), 'yyyy-MM-dd'),
settlementDate: '',
transactionType: 'purchase',
amount: 0,
vendorId: '',
vendorName: '',
bankAccount: '',
accountSubject: '',
paymentStatus: 'pending',
note: '',
});
const [formExpectedDate, setFormExpectedDate] = useState<Date | undefined>(new Date());
// 거래처/계좌 옵션
const [clientOptions, setClientOptions] = useState<{ id: string; name: string }[]>([]);
const [bankAccountOptions, setBankAccountOptions] = useState<{ id: string; bankName: string; accountName: string; accountNumber: string }[]>([]);
// 날짜 범위 상태 (현재 월 기준)
const now = new Date();
const [startDate, setStartDate] = useState(format(new Date(now.getFullYear(), now.getMonth(), 1), 'yyyy-MM-dd'));
const [endDate, setEndDate] = useState(format(new Date(now.getFullYear(), now.getMonth() + 3, 0), 'yyyy-MM-dd'));
// API에서 받아온 데이터
const [data, setData] = useState<ExpectedExpenseRecord[]>(initialData);
// ===== 거래처/계좌 옵션 로드 =====
useEffect(() => {
const loadOptions = async () => {
const [clientsResult, accountsResult] = await Promise.all([
getClients(),
getBankAccounts(),
]);
if (clientsResult.success) setClientOptions(clientsResult.data);
if (accountsResult.success) setBankAccountOptions(accountsResult.data);
};
loadOptions();
}, []);
// ===== 폼 초기화 =====
const resetForm = useCallback(() => {
setFormData({
expectedPaymentDate: format(new Date(), 'yyyy-MM-dd'),
settlementDate: '',
transactionType: 'purchase',
amount: 0,
vendorId: '',
vendorName: '',
bankAccount: '',
accountSubject: '',
paymentStatus: 'pending',
note: '',
});
setFormExpectedDate(new Date());
setEditingItem(null);
}, []);
// ===== 등록 다이얼로그 열기 =====
const handleOpenCreateDialog = useCallback(() => {
resetForm();
setShowFormDialog(true);
}, [resetForm]);
// ===== 수정 다이얼로그 열기 =====
const handleOpenEditDialog = useCallback((item: ExpectedExpenseRecord) => {
setEditingItem(item);
setFormData({
expectedPaymentDate: item.expectedPaymentDate,
settlementDate: item.settlementDate,
transactionType: item.transactionType,
amount: item.amount,
vendorId: item.vendorId,
vendorName: item.vendorName,
bankAccount: item.bankAccount,
accountSubject: item.accountSubject,
paymentStatus: item.paymentStatus,
note: item.note,
});
setFormExpectedDate(item.expectedPaymentDate ? new Date(item.expectedPaymentDate) : new Date());
setShowFormDialog(true);
}, []);
// ===== 폼 제출 (등록/수정) =====
const handleFormSubmit = useCallback(async () => {
if (!formData.expectedPaymentDate || !formData.amount) {
toast.error('예상 지급일과 지출금액은 필수입니다.');
return;
}
startTransition(async () => {
if (editingItem) {
// 수정
const result = await updateExpectedExpense(editingItem.id, formData);
if (result.success && result.data) {
setData(prev => prev.map(item => item.id === editingItem.id ? result.data! : item));
toast.success('미지급비용이 수정되었습니다.');
setShowFormDialog(false);
resetForm();
} else {
toast.error(result.error || '수정에 실패했습니다.');
}
} else {
// 등록
const result = await createExpectedExpense(formData);
if (result.success && result.data) {
setData(prev => [result.data!, ...prev]);
toast.success('미지급비용이 등록되었습니다.');
setShowFormDialog(false);
resetForm();
} else {
toast.error(result.error || '등록에 실패했습니다.');
}
}
});
}, [formData, editingItem, resetForm]);
// ===== 일괄 삭제 핸들러 =====
const handleBulkDelete = useCallback(async () => {
if (selectedItems.size === 0) return;
const selectedIds = Array.from(selectedItems);
startTransition(async () => {
const result = await deleteExpectedExpenses(selectedIds);
if (result.success) {
setData(prev => prev.filter(item => !selectedItems.has(item.id)));
setSelectedItems(new Set());
toast.success(`${result.deletedCount || selectedIds.length}건이 삭제되었습니다.`);
} else {
toast.error(result.error || '일괄 삭제에 실패했습니다.');
}
setShowBulkDeleteDialog(false);
});
}, [selectedItems]);
// ===== 체크박스 핸들러 =====
const toggleSelection = useCallback((id: string) => {
setSelectedItems(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) newSet.delete(id);
else newSet.add(id);
return newSet;
});
}, []);
// ===== 거래처 필터 옵션 (데이터에서 동적 추출) =====
const vendorFilterOptions = useMemo(() => {
const vendors = [...new Set(data.map(item => item.vendorName).filter(Boolean))];
return [
{ value: 'all', label: '전체' },
...vendors.map(vendor => ({ value: vendor, label: vendor }))
];
}, [data]);
// ===== 필터링된 원본 데이터 =====
const filteredRawData = useMemo(() => {
let result = data.filter(item =>
item.vendorName.includes(searchQuery) ||
item.accountSubject.includes(searchQuery) ||
item.note.includes(searchQuery)
);
// 거래처 필터
if (vendorFilter !== 'all') {
result = result.filter(item => item.vendorName === vendorFilter);
}
// 정렬 적용
switch (sortOption) {
case 'latest':
result.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
break;
case 'oldest':
result.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
break;
default:
result.sort((a, b) => new Date(a.expectedPaymentDate).getTime() - new Date(b.expectedPaymentDate).getTime());
}
return result;
}, [data, searchQuery, vendorFilter, sortOption]);
// ===== 월별 그룹핑된 테이블 데이터 (헤더 + 데이터 + 소계 포함) =====
const tableData = useMemo((): TableRowData[] => {
const groups: { [monthKey: string]: ExpectedExpenseRecord[] } = {};
filteredRawData.forEach(item => {
const monthKey = getMonthKey(item.expectedPaymentDate);
if (!groups[monthKey]) {
groups[monthKey] = [];
}
groups[monthKey].push(item);
});
const sortedMonths = Object.keys(groups).sort();
const result: TableRowData[] = [];
let totalExpense = 0;
let dataIndex = 0; // 데이터 행 순번 추적 (SSR/CSR 일치를 위해 useMemo 내에서 계산)
sortedMonths.forEach(monthKey => {
const monthItems = groups[monthKey];
const monthSubtotal = monthItems.reduce((sum, item) => sum + item.amount, 0);
totalExpense += monthSubtotal;
// 월 헤더 추가
result.push({
id: `header-${monthKey}`,
rowType: 'monthHeader',
monthLabel: getMonthLabel(monthKey),
expectedPaymentDate: '',
settlementDate: '',
transactionType: 'other',
amount: 0,
vendorId: '',
vendorName: '',
bankAccount: '',
accountSubject: '',
paymentStatus: 'pending',
approvalStatus: 'none',
note: '',
createdAt: '',
updatedAt: '',
});
// 데이터 행들 추가 (순번 포함)
monthItems.forEach(item => {
dataIndex++;
result.push({
...item,
rowType: 'data',
dataIndex, // 미리 계산된 순번
});
});
// 월별 소계 추가
result.push({
id: `subtotal-${monthKey}`,
rowType: 'monthSubtotal',
monthLabel: getMonthLabel(monthKey),
subtotalAmount: monthSubtotal,
expectedPaymentDate: '',
settlementDate: '',
transactionType: 'other',
amount: monthSubtotal,
vendorId: '',
vendorName: '',
bankAccount: '',
accountSubject: '',
paymentStatus: 'pending',
approvalStatus: 'none',
note: '',
createdAt: '',
updatedAt: '',
});
});
// 전체 합계 행들 추가
const expectedBalance = 10000000;
const finalBalance = expectedBalance - totalExpense;
if (filteredRawData.length > 0) {
result.push({
id: 'total-expense',
rowType: 'totalExpense',
subtotalAmount: totalExpense,
expectedPaymentDate: '',
settlementDate: '',
transactionType: 'other',
amount: totalExpense,
vendorId: '',
vendorName: '',
bankAccount: '',
accountSubject: '',
paymentStatus: 'pending',
approvalStatus: 'none',
note: '',
createdAt: '',
updatedAt: '',
});
result.push({
id: 'expected-balance',
rowType: 'expectedBalance',
subtotalAmount: expectedBalance,
expectedPaymentDate: '',
settlementDate: '',
transactionType: 'other',
amount: expectedBalance,
vendorId: '',
vendorName: '',
bankAccount: '',
accountSubject: '',
paymentStatus: 'pending',
approvalStatus: 'none',
note: '',
createdAt: '',
updatedAt: '',
});
result.push({
id: 'final-balance',
rowType: 'finalBalance',
subtotalAmount: finalBalance,
expectedPaymentDate: '',
settlementDate: '',
transactionType: 'other',
amount: finalBalance,
vendorId: '',
vendorName: '',
bankAccount: '',
accountSubject: '',
paymentStatus: 'pending',
approvalStatus: 'none',
note: '',
createdAt: '',
updatedAt: '',
});
}
return result;
}, [filteredRawData]);
const totalPages = initialPagination.lastPage || Math.ceil(tableData.length / itemsPerPage);
// ===== 전체 선택 핸들러 (데이터 행만) =====
const toggleSelectAll = useCallback(() => {
const dataRows = filteredRawData;
if (selectedItems.size === dataRows.length && dataRows.length > 0) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(dataRows.map(item => item.id)));
}
}, [selectedItems.size, filteredRawData]);
// ===== 액션 핸들러 =====
// 상세페이지 없음 - 행 클릭 시 이동하지 않음
const handleDeleteClick = useCallback((id: string) => {
setDeleteTargetId(id);
setShowDeleteDialog(true);
}, []);
const handleConfirmDelete = useCallback(async () => {
if (!deleteTargetId) return;
startTransition(async () => {
const result = await deleteExpectedExpense(deleteTargetId);
if (result.success) {
setData(prev => prev.filter(item => item.id !== deleteTargetId));
setSelectedItems(prev => {
const newSet = new Set(prev);
newSet.delete(deleteTargetId);
return newSet;
});
toast.success('미지급비용이 삭제되었습니다.');
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
setShowDeleteDialog(false);
setDeleteTargetId(null);
});
}, [deleteTargetId]);
// ===== 예상 지급일 변경 핸들러 =====
const handleOpenDateChangeDialog = useCallback(() => {
if (selectedItems.size === 0) return;
setNewExpectedDate(undefined);
setShowDateChangeDialog(true);
}, [selectedItems.size]);
const handleConfirmDateChange = useCallback(async () => {
if (!newExpectedDate || selectedItems.size === 0) return;
const newDateStr = format(newExpectedDate, 'yyyy-MM-dd');
const selectedIds = Array.from(selectedItems);
startTransition(async () => {
const result = await updateExpectedPaymentDate(selectedIds, newDateStr);
if (result.success) {
setData(prev => prev.map(item =>
selectedItems.has(item.id)
? { ...item, expectedPaymentDate: newDateStr }
: item
));
toast.success(`${result.updatedCount || selectedIds.length}건의 예상 지급일이 변경되었습니다.`);
setSelectedItems(new Set());
} else {
toast.error(result.error || '예상 지급일 변경에 실패했습니다.');
}
setShowDateChangeDialog(false);
setNewExpectedDate(undefined);
});
}, [newExpectedDate, selectedItems]);
// ===== 선택된 항목 요약 =====
const selectedItemsSummary = useMemo(() => {
const selectedData = data.filter(item => selectedItems.has(item.id));
if (selectedData.length === 0) return { label: '', totalAmount: 0 };
const firstItem = selectedData[0];
const remainingCount = selectedData.length - 1;
const label = remainingCount > 0
? `${firstItem.vendorName}${remainingCount}`
: firstItem.vendorName;
const totalAmount = selectedData.reduce((sum, item) => sum + item.amount, 0);
return { label, totalAmount };
}, [data, selectedItems]);
// ===== 통계 카드 =====
const statCards: StatCard[] = useMemo(() => {
const totalExpense = filteredRawData.reduce((sum, d) => sum + d.amount, 0);
const expectedBalance = 10000000;
return [
{ label: '지출 합계', value: `${totalExpense.toLocaleString()}`, icon: Receipt, iconColor: 'text-red-500' },
{ label: '예상 잔액', value: `${expectedBalance.toLocaleString()}`, icon: Receipt, iconColor: 'text-blue-500' },
];
}, [filteredRawData]);
// ===== 테이블 컬럼 =====
// 순서: 예상 지급일, 항목, 지출금액, 거래처, 계좌, 전자결재, 작업
const tableColumns: TableColumn[] = useMemo(() => [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
{ key: 'expectedPaymentDate', label: '예상 지급일' },
{ key: 'accountSubject', label: '항목' },
{ key: 'amount', label: '지출금액', className: 'text-right' },
{ key: 'vendorName', label: '거래처' },
{ key: 'bankAccount', label: '계좌' },
{ key: 'approvalStatus', label: '전자결재', className: 'text-center' },
{ key: 'actions', label: '작업', className: 'w-[100px] text-center' },
], []);
// ===== 전자결재 상태 Badge 스타일 =====
const getApprovalStatusBadge = (status: ApprovalStatus) => {
const styles = {
none: 'border-gray-300 text-gray-600 bg-gray-50',
pending: 'border-orange-300 text-orange-600 bg-orange-50',
approved: 'border-green-300 text-green-600 bg-green-50',
rejected: 'border-red-300 text-red-600 bg-red-50',
};
return (
<Badge variant="outline" className={styles[status]}>
{APPROVAL_STATUS_LABELS[status]}
</Badge>
);
};
// ===== 테이블 행 렌더링 =====
// 컬럼 순서: 체크박스 + 번호 + 예상 지급일 + 항목 + 지출금액 + 거래처 + 계좌 + 전자결재 + 작업
const renderTableRow = useCallback((item: TableRowData, index: number, globalIndex: number) => {
// 월 헤더 행 (9개 컬럼)
if (item.rowType === 'monthHeader') {
return (
<TableRow key={item.id} className="bg-gray-100 hover:bg-gray-100">
<TableCell colSpan={9} className="py-2 font-semibold text-gray-700">
{item.monthLabel}
</TableCell>
</TableRow>
);
}
// 월별 소계 행
if (item.rowType === 'monthSubtotal') {
return (
<TableRow key={item.id} className="bg-blue-50 hover:bg-blue-50">
<TableCell className="text-center"></TableCell>
<TableCell colSpan={3} className="text-right font-semibold text-blue-700">
{item.monthLabel}
</TableCell>
<TableCell className="text-right font-bold text-blue-700">
{item.subtotalAmount?.toLocaleString()}
</TableCell>
<TableCell colSpan={4}></TableCell>
</TableRow>
);
}
// 지출 합계 행
if (item.rowType === 'totalExpense') {
return (
<TableRow key={item.id} className="bg-gray-100 hover:bg-gray-100">
<TableCell className="text-center"></TableCell>
<TableCell colSpan={3} className="text-right font-bold">
</TableCell>
<TableCell className="text-right font-bold text-red-600">
{item.subtotalAmount?.toLocaleString()}
</TableCell>
<TableCell colSpan={4}></TableCell>
</TableRow>
);
}
// 예상 잔액 행
if (item.rowType === 'expectedBalance') {
return (
<TableRow key={item.id} className="bg-gray-100 hover:bg-gray-100">
<TableCell className="text-center"></TableCell>
<TableCell colSpan={3} className="text-right font-bold">
</TableCell>
<TableCell className="text-right font-bold">
{item.subtotalAmount?.toLocaleString()}
</TableCell>
<TableCell colSpan={4}></TableCell>
</TableRow>
);
}
// 최종 잔액 행
if (item.rowType === 'finalBalance') {
return (
<TableRow key={item.id} className="bg-orange-50 hover:bg-orange-50">
<TableCell className="text-center"></TableCell>
<TableCell colSpan={3} className="text-right font-bold">
</TableCell>
<TableCell className="text-right font-bold text-orange-600">
{item.subtotalAmount?.toLocaleString()}
</TableCell>
<TableCell colSpan={4}></TableCell>
</TableRow>
);
}
// 일반 데이터 행
const isSelected = selectedItems.has(item.id);
return (
<TableRow
key={item.id}
className="hover:bg-muted/50"
>
<TableCell className="text-center">
<Checkbox checked={isSelected} onCheckedChange={() => toggleSelection(item.id)} />
</TableCell>
<TableCell className="text-center">
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-red-500 text-white text-xs font-medium">
{item.dataIndex}
</span>
</TableCell>
<TableCell>{item.expectedPaymentDate}</TableCell>
<TableCell>{item.accountSubject}</TableCell>
<TableCell className="text-right font-medium text-red-600">
{item.amount.toLocaleString()}
</TableCell>
<TableCell>{item.vendorName}</TableCell>
<TableCell>{item.bankAccount}</TableCell>
<TableCell className="text-center">
{getApprovalStatusBadge(item.approvalStatus)}
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation();
handleOpenEditDialog(item);
}}
>
<Pencil className="h-4 w-4 text-gray-500" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick(item.id);
}}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</TableCell>
</TableRow>
);
}, [selectedItems, toggleSelection, handleOpenEditDialog, handleDeleteClick]);
// ===== 모바일 카드 렌더링 =====
const renderMobileCard = useCallback((
item: TableRowData,
index: number,
globalIndex: number,
isSelected: boolean,
onToggle: () => void
) => {
// 헤더/소계/합계 행은 모바일에서 다르게 표시
if (item.rowType !== 'data') {
if (item.rowType === 'monthHeader') {
return (
<div className="bg-gray-100 p-3 rounded-lg font-semibold text-gray-700">
{item.monthLabel}
</div>
);
}
if (item.rowType === 'monthSubtotal') {
return (
<div className="bg-blue-50 p-3 rounded-lg flex justify-between">
<span className="font-semibold text-blue-700">{item.monthLabel} </span>
<span className="font-bold text-blue-700">{item.subtotalAmount?.toLocaleString()}</span>
</div>
);
}
if (item.rowType === 'totalExpense') {
return (
<div className="bg-gray-100 p-3 rounded-lg flex justify-between">
<span className="font-bold"> </span>
<span className="font-bold text-red-600">{item.subtotalAmount?.toLocaleString()}</span>
</div>
);
}
if (item.rowType === 'expectedBalance') {
return (
<div className="bg-gray-100 p-3 rounded-lg flex justify-between">
<span className="font-bold"> </span>
<span className="font-bold">{item.subtotalAmount?.toLocaleString()}</span>
</div>
);
}
if (item.rowType === 'finalBalance') {
return (
<div className="bg-orange-50 p-3 rounded-lg flex justify-between">
<span className="font-bold"> </span>
<span className="font-bold text-orange-600">{item.subtotalAmount?.toLocaleString()}</span>
</div>
);
}
return null;
}
return (
<ListMobileCard
id={item.id}
title={item.vendorName}
headerBadges={
<>
<Badge variant="outline">{TRANSACTION_TYPE_LABELS[item.transactionType]}</Badge>
<Badge variant="outline" className={
item.paymentStatus === 'paid' ? 'border-green-300 text-green-600 bg-green-50' :
item.paymentStatus === 'partial' ? 'border-blue-300 text-blue-600 bg-blue-50' :
item.paymentStatus === 'pending' ? 'border-orange-300 text-orange-600 bg-orange-50' :
'border-red-300 text-red-600 bg-red-50'
}>
{PAYMENT_STATUS_LABELS[item.paymentStatus]}
</Badge>
{getApprovalStatusBadge(item.approvalStatus)}
</>
}
isSelected={isSelected}
onToggleSelection={onToggle}
infoGrid={
<div className="grid grid-cols-2 gap-3">
<InfoField label="예상 지급일" value={item.expectedPaymentDate} />
<InfoField label="지출금액" value={`${item.amount.toLocaleString()}`} />
<InfoField label="거래처" value={item.vendorName} />
<InfoField label="계좌" value={item.bankAccount} />
</div>
}
/>
);
}, []);
// ===== 헤더 액션 (날짜 선택) =====
const headerActions = (
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
);
// ===== 테이블 헤더 액션 (거래처 필터/정렬 필터 - 탭 옆) =====
const tableHeaderActions = (
<div className="flex items-center gap-2">
{/* 거래처 필터 */}
<Select value={vendorFilter} onValueChange={setVendorFilter}>
<SelectTrigger className="w-[140px] h-8 text-sm">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
{vendorFilterOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 필터 (최신순/등록순) */}
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
<SelectTrigger className="w-[100px] h-8 text-sm">
<SelectValue placeholder="최신순" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
// ===== 선택된 항목 데이터 가져오기 =====
const getSelectedItemsData = useCallback(() => {
return data.filter(item => selectedItems.has(item.id));
}, [data, selectedItems]);
// ===== 전자결재 페이지로 이동 (선택 데이터 전달) =====
const handleElectronicApproval = useCallback(() => {
const selectedData = getSelectedItemsData();
// 선택된 항목 ID들을 쿼리 파라미터로 전달
const selectedIds = selectedData.map(item => item.id).join(',');
router.push(`/ko/approval/draft/new?type=expected-expense&items=${encodeURIComponent(selectedIds)}`);
}, [getSelectedItemsData, router]);
// ===== 테이블 앞 컨텐츠 (액션 버튼) =====
const beforeTableContent = (
<div className="flex items-center gap-2 flex-wrap">
{/* 등록 버튼 */}
<Button
size="sm"
onClick={handleOpenCreateDialog}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
{/* 예상 지급일 변경 버튼 - 1개 이상 선택 시 활성화 */}
<Button
variant="outline"
size="sm"
onClick={handleOpenDateChangeDialog}
disabled={selectedItems.size === 0}
>
<CalendarIcon className="h-4 w-4 mr-1" />
{selectedItems.size > 0 && `(${selectedItems.size})`}
</Button>
{/* 전자결재 버튼 - 1개 이상 선택 시 활성화 */}
<Button
variant="outline"
size="sm"
onClick={handleElectronicApproval}
disabled={selectedItems.size === 0}
>
<FileText className="h-4 w-4 mr-1" />
{selectedItems.size > 0 && `(${selectedItems.size})`}
</Button>
{/* 일괄삭제 버튼 - 1개 이상 선택 시 활성화 */}
<Button
variant="outline"
size="sm"
onClick={() => setShowBulkDeleteDialog(true)}
disabled={selectedItems.size === 0}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4 mr-1" />
{selectedItems.size > 0 && `(${selectedItems.size})`}
</Button>
</div>
);
return (
<>
<IntegratedListTemplateV2
title="지출 예상 내역서"
description="지출 예상 내역을 등록하고 조회합니다"
icon={Receipt}
headerActions={headerActions}
stats={statCards}
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder="거래처, 계정과목, 적요 검색..."
tableHeaderActions={tableHeaderActions}
beforeTableContent={beforeTableContent}
tableColumns={tableColumns}
data={tableData}
totalCount={initialPagination.total || filteredRawData.length}
allData={tableData}
selectedItems={selectedItems}
onToggleSelection={toggleSelection}
onToggleSelectAll={toggleSelectAll}
getItemId={(item: TableRowData) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
pagination={{
currentPage,
totalPages: totalPages || 1,
totalItems: initialPagination.total || filteredRawData.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
/>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 예상 지급일 변경 다이얼로그 */}
<Dialog open={showDateChangeDialog} onOpenChange={setShowDateChangeDialog}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
{/* 선택된 항목 리스트 */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700"> ({selectedItems.size})</label>
<div className="max-h-[200px] overflow-y-auto border rounded-lg">
{data.filter(item => selectedItems.has(item.id)).map((item, idx) => (
<div
key={item.id}
className={`flex items-center justify-between px-4 py-2 ${idx !== 0 ? 'border-t' : ''}`}
>
<div className="flex flex-col">
<span className="text-sm font-medium">{item.vendorName}</span>
<span className="text-xs text-gray-500">{item.accountSubject} {item.expectedPaymentDate}</span>
</div>
<span className="font-semibold text-red-600">{item.amount.toLocaleString()}</span>
</div>
))}
</div>
</div>
{/* 합계 */}
<div className="flex items-center justify-between p-3 bg-gray-100 rounded-lg">
<span className="text-sm font-medium text-gray-700"></span>
<span className="font-bold text-lg">{selectedItemsSummary.totalAmount.toLocaleString()}</span>
</div>
{/* 예상 지급일 선택 */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700"> </label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start text-left font-normal"
>
<CalendarIcon className="mr-2 h-4 w-4" />
{newExpectedDate ? format(newExpectedDate, 'yyyy-MM-dd') : '날짜 선택'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={newExpectedDate}
onSelect={setNewExpectedDate}
/>
</PopoverContent>
</Popover>
</div>
</div>
{/* 버튼 영역 */}
<div className="flex gap-3 justify-end">
<Button
variant="outline"
onClick={() => setShowDateChangeDialog(false)}
>
</Button>
<Button
onClick={handleConfirmDateChange}
disabled={!newExpectedDate}
>
</Button>
</div>
</DialogContent>
</Dialog>
{/* 등록/수정 폼 다이얼로그 */}
<Dialog open={showFormDialog} onOpenChange={setShowFormDialog}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>{editingItem ? '미지급비용 수정' : '미지급비용 등록'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
{/* 예상 지급일 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label> *</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start text-left font-normal"
>
<CalendarIcon className="mr-2 h-4 w-4" />
{formExpectedDate ? format(formExpectedDate, 'yyyy-MM-dd') : '날짜 선택'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={formExpectedDate}
onSelect={(date) => {
setFormExpectedDate(date);
if (date) {
setFormData(prev => ({ ...prev, expectedPaymentDate: format(date, 'yyyy-MM-dd') }));
}
}}
/>
</PopoverContent>
</Popover>
</div>
{/* 거래유형 */}
<div className="space-y-2">
<Label></Label>
<Select
value={formData.transactionType}
onValueChange={(value) => setFormData(prev => ({ ...prev, transactionType: value as TransactionType }))}
>
<SelectTrigger>
<SelectValue placeholder="거래유형 선택" />
</SelectTrigger>
<SelectContent>
{TRANSACTION_TYPE_FILTER_OPTIONS.filter(opt => opt.value !== 'all').map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 거래처 / 금액 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Select
value={formData.vendorId}
onValueChange={(value) => {
const selected = clientOptions.find(c => c.id === value);
setFormData(prev => ({
...prev,
vendorId: value,
vendorName: selected?.name || '',
}));
}}
>
<SelectTrigger>
<SelectValue placeholder="거래처 선택" />
</SelectTrigger>
<SelectContent>
{clientOptions.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> *</Label>
<Input
type="number"
value={formData.amount || ''}
onChange={(e) => setFormData(prev => ({ ...prev, amount: Number(e.target.value) }))}
placeholder="금액 입력"
/>
</div>
</div>
{/* 계좌 / 계정과목 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Select
value={formData.bankAccount}
onValueChange={(value) => setFormData(prev => ({ ...prev, bankAccount: value }))}
>
<SelectTrigger>
<SelectValue placeholder="계좌 선택" />
</SelectTrigger>
<SelectContent>
{bankAccountOptions.map((option) => (
<SelectItem key={option.id} value={`${option.bankName} ${option.accountNumber}`}>
{option.bankName} {option.accountNumber} ({option.accountName})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Select
value={formData.accountSubject}
onValueChange={(value) => setFormData(prev => ({ ...prev, accountSubject: value }))}
>
<SelectTrigger>
<SelectValue placeholder="계정과목 선택" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_SUBJECT_OPTIONS.filter(opt => opt.value !== 'all').map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 결제상태 */}
<div className="space-y-2">
<Label></Label>
<Select
value={formData.paymentStatus}
onValueChange={(value) => setFormData(prev => ({ ...prev, paymentStatus: value as PaymentStatus }))}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="결제상태 선택" />
</SelectTrigger>
<SelectContent>
{PAYMENT_STATUS_FILTER_OPTIONS.filter(opt => opt.value !== 'all').map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 비고 */}
<div className="space-y-2">
<Label></Label>
<Textarea
value={formData.note || ''}
onChange={(e) => setFormData(prev => ({ ...prev, note: e.target.value }))}
placeholder="비고 입력"
rows={3}
/>
</div>
</div>
{/* 버튼 영역 */}
<div className="flex gap-3 justify-end">
<Button
variant="outline"
onClick={() => {
setShowFormDialog(false);
resetForm();
}}
>
</Button>
<Button
onClick={handleFormSubmit}
disabled={isPending}
>
{isPending ? '처리중...' : (editingItem ? '수정' : '등록')}
</Button>
</div>
</DialogContent>
</Dialog>
{/* 일괄삭제 확인 다이얼로그 */}
<AlertDialog open={showBulkDeleteDialog} onOpenChange={setShowBulkDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{selectedItems.size} ? .
</AlertDialogDescription>
</AlertDialogHeader>
<div className="max-h-[200px] overflow-y-auto border rounded-lg my-4">
{data.filter(item => selectedItems.has(item.id)).map((item, idx) => (
<div
key={item.id}
className={`flex items-center justify-between px-4 py-2 ${idx !== 0 ? 'border-t' : ''}`}
>
<div className="flex flex-col">
<span className="text-sm font-medium">{item.vendorName}</span>
<span className="text-xs text-gray-500">{item.accountSubject} {item.expectedPaymentDate}</span>
</div>
<span className="font-semibold text-red-600">{item.amount.toLocaleString()}</span>
</div>
))}
</div>
<div className="flex items-center justify-between p-3 bg-gray-100 rounded-lg">
<span className="text-sm font-medium text-gray-700"></span>
<span className="font-bold text-lg text-red-600">{selectedItemsSummary.totalAmount.toLocaleString()}</span>
</div>
<AlertDialogFooter className="mt-4">
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleBulkDelete}
className="bg-red-600 hover:bg-red-700"
disabled={isPending}
>
{isPending ? '삭제중...' : '삭제'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}