1273 lines
45 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|