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:
byeongcheolryu
2025-12-19 19:12:34 +09:00
parent d742c0ce26
commit c6b605200d
213 changed files with 32644 additions and 775 deletions

View 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>
</>
);
}

View 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: '반려',
};