feat: 신규 페이지 구현 및 HR/설정 기능 개선
신규 페이지: - 회계관리: 거래처, 예상비용, 청구서, 발주서 - 게시판: 공지사항, 자료실, 커뮤니티 - 고객센터: 문의/FAQ - 설정: 계정, 알림, 출퇴근, 팝업, 구독, 결제내역 - 리포트 (차트 시각화) - 개발자 테스트 URL 페이지 기능 개선: - HR 직원관리/휴가관리/카드관리 강화 - IntegratedListTemplateV2 확장 - AuthenticatedLayout 패딩 표준화 - 로그인 페이지 UI 개선 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
886
src/components/accounting/ExpectedExpenseManagement/index.tsx
Normal file
886
src/components/accounting/ExpectedExpenseManagement/index.tsx
Normal file
@@ -0,0 +1,886 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { format } from 'date-fns';
|
||||
import {
|
||||
Receipt,
|
||||
Calendar as CalendarIcon,
|
||||
FileText,
|
||||
} 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';
|
||||
|
||||
// ===== 테이블 행 타입 (데이터 + 그룹 헤더 + 소계) =====
|
||||
type RowType = 'data' | 'monthHeader' | 'monthSubtotal' | 'totalExpense' | 'expectedBalance' | 'finalBalance';
|
||||
|
||||
interface TableRowData extends ExpectedExpenseRecord {
|
||||
rowType: RowType;
|
||||
monthLabel?: string;
|
||||
subtotalAmount?: number;
|
||||
dataIndex?: number; // 데이터 행 순번 (헤더/소계 제외)
|
||||
}
|
||||
|
||||
// ===== Mock 데이터 생성 (다중 월 포함) =====
|
||||
const generateMockData = (): ExpectedExpenseRecord[] => {
|
||||
const transactionTypes: TransactionType[] = ['purchase', 'advance', 'suspense', 'rent', 'salary', 'insurance', 'tax', 'utilities', 'other'];
|
||||
const paymentStatuses: PaymentStatus[] = ['pending', 'partial', 'paid', 'overdue'];
|
||||
const approvalStatuses: ApprovalStatus[] = ['none', 'pending', 'approved', 'rejected'];
|
||||
const vendors = ['(주)삼성전자', '현대자동차', 'LG전자', 'SK하이닉스', '네이버', '카카오', '쿠팡', '배달의민족'];
|
||||
const accountSubjects = ['매입비용', '급여', '임차료', '공과금', '보험료', '세금과공과', '기타비용'];
|
||||
const bankAccounts = ['신한 110-123-456789', '국민 123-456-789012', '우리 1002-123-456789', '하나 123-456789-01234'];
|
||||
|
||||
const amounts = [1000000, 2500000, 500000, 3000000, 1500000, 800000, 4000000, 600000];
|
||||
|
||||
const records: ExpectedExpenseRecord[] = [];
|
||||
|
||||
// 2025년 1월 데이터 (5건)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const day = (i * 3) + 1;
|
||||
records.push({
|
||||
id: `expense-jan-${i + 1}`,
|
||||
expectedPaymentDate: format(new Date(2025, 0, day), 'yyyy-MM-dd'),
|
||||
settlementDate: format(new Date(2025, 0, day + 5), 'yyyy-MM-dd'),
|
||||
transactionType: transactionTypes[i % transactionTypes.length],
|
||||
amount: amounts[i % amounts.length],
|
||||
vendorId: `vendor-${i % vendors.length}`,
|
||||
vendorName: vendors[i % vendors.length],
|
||||
bankAccount: bankAccounts[i % bankAccounts.length],
|
||||
accountSubject: accountSubjects[i % accountSubjects.length],
|
||||
paymentStatus: paymentStatuses[i % paymentStatuses.length],
|
||||
approvalStatus: approvalStatuses[i % approvalStatuses.length],
|
||||
note: i % 2 === 0 ? '월정산' : '',
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
updatedAt: '2025-01-01T00:00:00.000Z',
|
||||
});
|
||||
}
|
||||
|
||||
// 2025년 2월 데이터 (4건)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const day = (i * 4) + 3;
|
||||
records.push({
|
||||
id: `expense-feb-${i + 1}`,
|
||||
expectedPaymentDate: format(new Date(2025, 1, day), 'yyyy-MM-dd'),
|
||||
settlementDate: format(new Date(2025, 1, day + 5), 'yyyy-MM-dd'),
|
||||
transactionType: transactionTypes[(i + 3) % transactionTypes.length],
|
||||
amount: amounts[(i + 2) % amounts.length],
|
||||
vendorId: `vendor-${(i + 2) % vendors.length}`,
|
||||
vendorName: vendors[(i + 2) % vendors.length],
|
||||
bankAccount: bankAccounts[(i + 1) % bankAccounts.length],
|
||||
accountSubject: accountSubjects[(i + 1) % accountSubjects.length],
|
||||
paymentStatus: paymentStatuses[(i + 1) % paymentStatuses.length],
|
||||
approvalStatus: approvalStatuses[(i + 1) % approvalStatuses.length],
|
||||
note: i % 3 === 0 ? '월정산' : '',
|
||||
createdAt: '2025-02-01T00:00:00.000Z',
|
||||
updatedAt: '2025-02-01T00:00:00.000Z',
|
||||
});
|
||||
}
|
||||
|
||||
// 2025년 3월 데이터 (3건)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const day = (i * 5) + 2;
|
||||
records.push({
|
||||
id: `expense-mar-${i + 1}`,
|
||||
expectedPaymentDate: format(new Date(2025, 2, day), 'yyyy-MM-dd'),
|
||||
settlementDate: format(new Date(2025, 2, day + 5), 'yyyy-MM-dd'),
|
||||
transactionType: transactionTypes[(i + 5) % transactionTypes.length],
|
||||
amount: amounts[(i + 4) % amounts.length],
|
||||
vendorId: `vendor-${(i + 4) % vendors.length}`,
|
||||
vendorName: vendors[(i + 4) % vendors.length],
|
||||
bankAccount: bankAccounts[(i + 2) % bankAccounts.length],
|
||||
accountSubject: accountSubjects[(i + 3) % accountSubjects.length],
|
||||
paymentStatus: paymentStatuses[(i + 2) % paymentStatuses.length],
|
||||
approvalStatus: approvalStatuses[(i + 2) % approvalStatuses.length],
|
||||
note: '',
|
||||
createdAt: '2025-03-01T00:00:00.000Z',
|
||||
updatedAt: '2025-03-01T00:00:00.000Z',
|
||||
});
|
||||
}
|
||||
|
||||
return records;
|
||||
};
|
||||
|
||||
// 월 추출 함수
|
||||
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() {
|
||||
const router = useRouter();
|
||||
|
||||
// ===== 상태 관리 =====
|
||||
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(1);
|
||||
const itemsPerPage = 50; // 월별 그룹핑이 있어서 더 많이 표시
|
||||
|
||||
// 예상 지급일 변경 다이얼로그
|
||||
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 [startDate, setStartDate] = useState('2025-01-01');
|
||||
const [endDate, setEndDate] = useState('2025-03-31');
|
||||
|
||||
// Mock 데이터
|
||||
const [data, setData] = useState<ExpectedExpenseRecord[]>(generateMockData);
|
||||
|
||||
// ===== 체크박스 핸들러 =====
|
||||
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))];
|
||||
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 = 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(() => {
|
||||
if (deleteTargetId) {
|
||||
setData(prev => prev.filter(item => item.id !== deleteTargetId));
|
||||
setSelectedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(deleteTargetId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
setShowDeleteDialog(false);
|
||||
setDeleteTargetId(null);
|
||||
}, [deleteTargetId]);
|
||||
|
||||
// ===== 예상 지급일 변경 핸들러 =====
|
||||
const handleOpenDateChangeDialog = useCallback(() => {
|
||||
if (selectedItems.size === 0) return;
|
||||
setNewExpectedDate(undefined);
|
||||
setShowDateChangeDialog(true);
|
||||
}, [selectedItems.size]);
|
||||
|
||||
const handleConfirmDateChange = useCallback(() => {
|
||||
if (!newExpectedDate || selectedItems.size === 0) return;
|
||||
|
||||
const newDateStr = format(newExpectedDate, 'yyyy-MM-dd');
|
||||
setData(prev => prev.map(item =>
|
||||
selectedItems.has(item.id)
|
||||
? { ...item, expectedPaymentDate: newDateStr }
|
||||
: item
|
||||
));
|
||||
setShowDateChangeDialog(false);
|
||||
setNewExpectedDate(undefined);
|
||||
setSelectedItems(new Set());
|
||||
}, [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' },
|
||||
], []);
|
||||
|
||||
// ===== 전자결재 상태 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) => {
|
||||
// 월 헤더 행 (8개 컬럼)
|
||||
if (item.rowType === 'monthHeader') {
|
||||
return (
|
||||
<TableRow key={item.id} className="bg-gray-100 hover:bg-gray-100">
|
||||
<TableCell colSpan={8} 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={3}></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={3}></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={3}></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={3}></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>
|
||||
</TableRow>
|
||||
);
|
||||
}, [selectedItems, toggleSelection]);
|
||||
|
||||
// ===== 모바일 카드 렌더링 =====
|
||||
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]);
|
||||
|
||||
// ===== 테이블 앞 컨텐츠 (액션 버튼만) =====
|
||||
// 1개 이상 선택 시 활성화 (일괄 처리용)
|
||||
const beforeTableContent = (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 예상 지급일 변경 버튼 - 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>
|
||||
</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={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: 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
120
src/components/accounting/ExpectedExpenseManagement/types.ts
Normal file
120
src/components/accounting/ExpectedExpenseManagement/types.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 지출 예상 내역서 타입 정의
|
||||
*/
|
||||
|
||||
// 거래 유형
|
||||
export type TransactionType =
|
||||
| 'purchase' // 매입
|
||||
| 'advance' // 선급금
|
||||
| 'suspense' // 가지급금
|
||||
| 'rent' // 임대료
|
||||
| 'salary' // 급여
|
||||
| 'insurance' // 보험료
|
||||
| 'tax' // 세금
|
||||
| 'utilities' // 공과금
|
||||
| 'other'; // 기타
|
||||
|
||||
// 지급 상태
|
||||
export type PaymentStatus =
|
||||
| 'pending' // 미지급
|
||||
| 'partial' // 부분지급
|
||||
| 'paid' // 지급완료
|
||||
| 'overdue'; // 연체
|
||||
|
||||
// 정렬 옵션
|
||||
export type SortOption = 'latest' | 'oldest';
|
||||
|
||||
// 전자결재 상태
|
||||
export type ApprovalStatus =
|
||||
| 'none' // 미신청
|
||||
| 'pending' // 결재대기
|
||||
| 'approved' // 결재완료
|
||||
| 'rejected'; // 반려
|
||||
|
||||
// 지출 예상 내역 레코드
|
||||
export interface ExpectedExpenseRecord {
|
||||
id: string;
|
||||
expectedPaymentDate: string; // 예상 지급일
|
||||
settlementDate: string; // 결제일
|
||||
transactionType: TransactionType; // 거래유형
|
||||
amount: number; // 지출금액
|
||||
vendorId: string; // 거래처 ID
|
||||
vendorName: string; // 거래처명
|
||||
bankAccount: string; // 계좌
|
||||
accountSubject: string; // 계정과목 (항목)
|
||||
paymentStatus: PaymentStatus; // 지급상태
|
||||
approvalStatus: ApprovalStatus; // 전자결재 상태
|
||||
note: string; // 적요/메모
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 거래유형 레이블
|
||||
export const TRANSACTION_TYPE_LABELS: Record<TransactionType, string> = {
|
||||
purchase: '매입',
|
||||
advance: '선급금',
|
||||
suspense: '가지급금',
|
||||
rent: '임대료',
|
||||
salary: '급여',
|
||||
insurance: '보험료',
|
||||
tax: '세금',
|
||||
utilities: '공과금',
|
||||
other: '기타',
|
||||
};
|
||||
|
||||
// 거래유형 필터 옵션
|
||||
export const TRANSACTION_TYPE_FILTER_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'purchase', label: '매입' },
|
||||
{ value: 'advance', label: '선급금' },
|
||||
{ value: 'suspense', label: '가지급금' },
|
||||
{ value: 'rent', label: '임대료' },
|
||||
{ value: 'salary', label: '급여' },
|
||||
{ value: 'insurance', label: '보험료' },
|
||||
{ value: 'tax', label: '세금' },
|
||||
{ value: 'utilities', label: '공과금' },
|
||||
{ value: 'other', label: '기타' },
|
||||
];
|
||||
|
||||
// 지급상태 레이블
|
||||
export const PAYMENT_STATUS_LABELS: Record<PaymentStatus, string> = {
|
||||
pending: '미지급',
|
||||
partial: '부분지급',
|
||||
paid: '지급완료',
|
||||
overdue: '연체',
|
||||
};
|
||||
|
||||
// 지급상태 필터 옵션
|
||||
export const PAYMENT_STATUS_FILTER_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'pending', label: '미지급' },
|
||||
{ value: 'partial', label: '부분지급' },
|
||||
{ value: 'paid', label: '지급완료' },
|
||||
{ value: 'overdue', label: '연체' },
|
||||
];
|
||||
|
||||
// 정렬 옵션
|
||||
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
];
|
||||
|
||||
// 계정과목 옵션
|
||||
export const ACCOUNT_SUBJECT_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'purchase', label: '매입비용' },
|
||||
{ value: 'salary', label: '급여' },
|
||||
{ value: 'rent', label: '임차료' },
|
||||
{ value: 'utilities', label: '공과금' },
|
||||
{ value: 'insurance', label: '보험료' },
|
||||
{ value: 'tax', label: '세금과공과' },
|
||||
{ value: 'other', label: '기타비용' },
|
||||
];
|
||||
|
||||
// 전자결재 상태 레이블
|
||||
export const APPROVAL_STATUS_LABELS: Record<ApprovalStatus, string> = {
|
||||
none: '미신청',
|
||||
pending: '결재대기',
|
||||
approved: '결재완료',
|
||||
rejected: '반려',
|
||||
};
|
||||
Reference in New Issue
Block a user