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,319 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import {
Banknote,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import type { WithdrawalRecord, WithdrawalType, Vendor } from './types';
import { WITHDRAWAL_TYPE_SELECTOR_OPTIONS } from './types';
// ===== Props =====
interface WithdrawalDetailProps {
withdrawalId: string;
mode: 'view' | 'edit';
}
// ===== Mock 거래처 데이터 =====
const MOCK_VENDORS: Vendor[] = [
{ id: 'v1', name: '(주)삼성전자', email: 'samsung@example.com' },
{ id: 'v2', name: '현대자동차', email: 'hyundai@example.com' },
{ id: 'v3', name: 'LG전자', email: 'lg@example.com' },
{ id: 'v4', name: 'SK하이닉스', email: 'skhynix@example.com' },
{ id: 'v5', name: '네이버', email: 'naver@example.com' },
];
// ===== 적요 옵션 =====
const NOTE_OPTIONS = [
{ value: '', label: '선택' },
{ value: 'note1', label: '적요 1' },
{ value: 'note2', label: '적요 2' },
{ value: 'note3', label: '적요 3' },
];
// ===== Mock 상세 데이터 조회 =====
const fetchWithdrawalDetail = (id: string): WithdrawalRecord | null => {
// 실제로는 API 호출
return {
id,
withdrawalDate: '2025-12-12',
withdrawalAmount: 100000000,
accountName: '국민 1234 (계좌명)',
recipientName: '수취인명',
note: '',
withdrawalType: 'unset',
vendorId: '',
vendorName: '',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
};
export function WithdrawalDetail({ withdrawalId, mode }: WithdrawalDetailProps) {
const router = useRouter();
const isViewMode = mode === 'view';
// ===== 폼 상태 =====
const [withdrawalDate, setWithdrawalDate] = useState('');
const [accountName, setAccountName] = useState('');
const [recipientName, setRecipientName] = useState('');
const [withdrawalAmount, setWithdrawalAmount] = useState(0);
const [note, setNote] = useState('');
const [vendorId, setVendorId] = useState('');
const [withdrawalType, setWithdrawalType] = useState<WithdrawalType>('unset');
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// ===== 데이터 로드 =====
useEffect(() => {
if (withdrawalId) {
const data = fetchWithdrawalDetail(withdrawalId);
if (data) {
setWithdrawalDate(data.withdrawalDate);
setAccountName(data.accountName);
setRecipientName(data.recipientName);
setWithdrawalAmount(data.withdrawalAmount);
setNote(data.note);
setVendorId(data.vendorId);
setWithdrawalType(data.withdrawalType);
}
}
}, [withdrawalId]);
// ===== 저장 핸들러 =====
const handleSave = useCallback(() => {
console.log('저장:', {
withdrawalId,
withdrawalDate,
accountName,
recipientName,
withdrawalAmount,
note,
vendorId,
withdrawalType,
});
router.push('/ko/accounting/withdrawals');
}, [withdrawalId, withdrawalDate, accountName, recipientName, withdrawalAmount, note, vendorId, withdrawalType, router]);
// ===== 취소 핸들러 =====
const handleCancel = useCallback(() => {
router.push(`/ko/accounting/withdrawals/${withdrawalId}`);
}, [router, withdrawalId]);
// ===== 목록으로 이동 =====
const handleBack = useCallback(() => {
router.push('/ko/accounting/withdrawals');
}, [router]);
// ===== 수정 모드로 이동 =====
const handleEdit = useCallback(() => {
router.push(`/ko/accounting/withdrawals/${withdrawalId}?mode=edit`);
}, [router, withdrawalId]);
// ===== 삭제 핸들러 =====
const handleDelete = useCallback(() => {
console.log('삭제:', withdrawalId);
setShowDeleteDialog(false);
router.push('/ko/accounting/withdrawals');
}, [withdrawalId, router]);
return (
<PageLayout>
{/* 페이지 헤더 */}
<PageHeader
title={isViewMode ? '출금 상세' : '출금 수정'}
description="출금 상세 내역을 등록합니다"
icon={Banknote}
/>
{/* 헤더 액션 버튼 */}
<div className="flex items-center justify-end gap-2 mb-6">
{/* view 모드: [목록] [삭제] [수정] */}
{isViewMode ? (
<>
<Button variant="outline" onClick={handleBack}>
</Button>
<Button
variant="outline"
className="text-red-500 border-red-200 hover:bg-red-50"
onClick={() => setShowDeleteDialog(true)}
>
</Button>
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
</Button>
</>
) : (
/* edit 모드: [취소] [저장] */
<>
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600">
</Button>
</>
)}
</div>
{/* 기본 정보 섹션 */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 출금일 */}
<div className="space-y-2">
<Label htmlFor="withdrawalDate"></Label>
<Input
id="withdrawalDate"
type="date"
value={withdrawalDate}
readOnly
disabled
className="bg-gray-50"
/>
</div>
{/* 출금계좌 */}
<div className="space-y-2">
<Label htmlFor="accountName"></Label>
<Input
id="accountName"
value={accountName}
readOnly
disabled
className="bg-gray-50"
/>
</div>
{/* 수취인명 */}
<div className="space-y-2">
<Label htmlFor="recipientName"></Label>
<Input
id="recipientName"
value={recipientName}
readOnly
disabled
className="bg-gray-50"
/>
</div>
{/* 출금금액 */}
<div className="space-y-2">
<Label htmlFor="withdrawalAmount"></Label>
<Input
id="withdrawalAmount"
value={withdrawalAmount.toLocaleString()}
readOnly
disabled
className="bg-gray-50"
/>
</div>
{/* 적요 */}
<div className="space-y-2">
<Label htmlFor="note"></Label>
<Select value={note} onValueChange={setNote} disabled={isViewMode}>
<SelectTrigger>
<SelectValue placeholder="선택 ▼" />
</SelectTrigger>
<SelectContent>
{NOTE_OPTIONS.map((option) => (
<SelectItem key={option.value || 'empty'} value={option.value || 'empty'}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 거래처 */}
<div className="space-y-2">
<Label htmlFor="vendorId">
<span className="text-red-500">*</span>
</Label>
<Select value={vendorId} onValueChange={setVendorId} disabled={isViewMode}>
<SelectTrigger>
<SelectValue placeholder="선택 ▼" />
</SelectTrigger>
<SelectContent>
{MOCK_VENDORS.map((vendor) => (
<SelectItem key={vendor.id} value={vendor.id}>
{vendor.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 출금 유형 */}
<div className="space-y-2 md:col-span-2">
<Label htmlFor="withdrawalType">
<span className="text-red-500">*</span>
</Label>
<Select value={withdrawalType} onValueChange={(v) => setWithdrawalType(v as WithdrawalType)} disabled={isViewMode}>
<SelectTrigger className="md:w-1/2">
<SelectValue placeholder="선택 ▼" />
</SelectTrigger>
<SelectContent>
{WITHDRAWAL_TYPE_SELECTOR_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* ===== 삭제 확인 다이얼로그 ===== */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-red-500 hover:bg-red-600"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageLayout>
);
}

View File

@@ -0,0 +1,605 @@
'use client';
import { useState, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { format } from 'date-fns';
import {
Banknote,
Pencil,
Save,
Trash2,
RefreshCw,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
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 {
WithdrawalRecord,
WithdrawalType,
SortOption,
} from './types';
import {
SORT_OPTIONS,
WITHDRAWAL_TYPE_LABELS,
WITHDRAWAL_TYPE_FILTER_OPTIONS,
ACCOUNT_SUBJECT_OPTIONS,
} from './types';
// ===== Mock 데이터 생성 =====
const generateMockData = (): WithdrawalRecord[] => {
const withdrawalTypes: WithdrawalType[] = ['unset', 'purchasePayment', 'advance', 'suspense', 'rent', 'interestExpense', 'depositPayment', 'loanRepayment', 'dividendPayment', 'vatPayment', 'salary', 'insurance', 'tax', 'utilities', 'expenses', 'other'];
const vendors = ['(주)삼성전자', '현대자동차', 'LG전자', 'SK하이닉스', '네이버', '카카오', '쿠팡', '배달의민족'];
const accountNames = ['국민은행 1234', '신한은행 5678', '우리은행 9012', '하나은행 3456'];
const recipientNames = ['홍길동', '김철수', '이영희', '박민수', '최지영'];
const amounts = [1000000, 2500000, 500000, 3000000, 1500000, 800000, 4000000, 10000000];
return Array.from({ length: 50 }, (_, i) => {
const withdrawalAmount = amounts[i % amounts.length] + (i * 100000);
return {
id: `withdrawal-${i + 1}`,
withdrawalDate: format(new Date(2025, 8, (i % 17) + 1), 'yyyy-MM-dd'),
withdrawalAmount,
accountName: accountNames[i % accountNames.length],
recipientName: recipientNames[i % recipientNames.length],
note: i % 3 === 0 ? '출금 내역' : '',
withdrawalType: withdrawalTypes[i % withdrawalTypes.length],
vendorId: `vendor-${i % vendors.length}`,
vendorName: i % 4 === 0 ? '' : vendors[i % vendors.length],
createdAt: '2025-12-18T00:00:00.000Z',
updatedAt: '2025-12-18T00:00:00.000Z',
};
});
};
export function WithdrawalManagement() {
const router = useRouter();
// ===== 상태 관리 =====
const [searchQuery, setSearchQuery] = useState('');
const [sortOption, setSortOption] = useState<SortOption>('latest');
const [withdrawalTypeFilter, setWithdrawalTypeFilter] = useState<string>('all');
const [vendorFilter, setVendorFilter] = useState<string>('all');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
// 상단 계정과목명 선택 (저장용)
const [selectedAccountSubject, setSelectedAccountSubject] = useState<string>('unset');
// 계정과목명 저장 다이얼로그
const [showSaveDialog, setShowSaveDialog] = useState(false);
// 삭제 다이얼로그
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
// 선택 필요 알림 다이얼로그
const [showSelectWarningDialog, setShowSelectWarningDialog] = useState(false);
// 날짜 범위 상태
const [startDate, setStartDate] = useState('2025-09-01');
const [endDate, setEndDate] = useState('2025-09-03');
// Mock 데이터
const [data, setData] = useState<WithdrawalRecord[]>(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 filteredData = useMemo(() => {
let result = data.filter(item =>
item.recipientName.includes(searchQuery) ||
item.accountName.includes(searchQuery) ||
item.note.includes(searchQuery) ||
item.vendorName.includes(searchQuery)
);
// 거래처 필터
if (vendorFilter !== 'all') {
result = result.filter(item => item.vendorName === vendorFilter);
}
// 출금 유형 필터
if (withdrawalTypeFilter !== 'all') {
result = result.filter(item => item.withdrawalType === withdrawalTypeFilter);
}
// 정렬
switch (sortOption) {
case 'latest':
result.sort((a, b) => new Date(b.withdrawalDate).getTime() - new Date(a.withdrawalDate).getTime());
break;
case 'oldest':
result.sort((a, b) => new Date(a.withdrawalDate).getTime() - new Date(b.withdrawalDate).getTime());
break;
case 'amountHigh':
result.sort((a, b) => b.withdrawalAmount - a.withdrawalAmount);
break;
case 'amountLow':
result.sort((a, b) => a.withdrawalAmount - b.withdrawalAmount);
break;
}
return result;
}, [data, searchQuery, vendorFilter, withdrawalTypeFilter, sortOption]);
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;
return filteredData.slice(startIndex, startIndex + itemsPerPage);
}, [filteredData, currentPage, itemsPerPage]);
const totalPages = Math.ceil(filteredData.length / itemsPerPage);
// ===== 전체 선택 핸들러 =====
const toggleSelectAll = useCallback(() => {
if (selectedItems.size === filteredData.length && filteredData.length > 0) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(filteredData.map(item => item.id)));
}
}, [selectedItems.size, filteredData]);
// ===== 액션 핸들러 =====
const handleRowClick = useCallback((item: WithdrawalRecord) => {
router.push(`/ko/accounting/withdrawals/${item.id}`);
}, [router]);
// 개별 항목 삭제 핸들러
const handleDeleteClick = useCallback((id: string) => {
setDeleteTargetId(id);
setShowDeleteDialog(true);
}, []);
// 삭제 확정 핸들러
const handleConfirmDelete = useCallback(() => {
if (deleteTargetId) {
console.log('삭제:', 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 handleRefresh = useCallback(() => {
console.log('새로고침: 은행 계좌 출금 내역 최신 데이터 조회');
// TODO: API 호출로 최신 데이터 조회
}, []);
// ===== 통계 카드 (총 출금, 당월 출금, 거래처 미설정, 출금유형 미설정) =====
const statCards: StatCard[] = useMemo(() => {
const totalWithdrawal = data.reduce((sum, d) => sum + d.withdrawalAmount, 0);
// 당월 출금
const currentMonth = new Date().getMonth();
const currentYear = new Date().getFullYear();
const monthlyWithdrawal = data
.filter(d => {
const date = new Date(d.withdrawalDate);
return date.getMonth() === currentMonth && date.getFullYear() === currentYear;
})
.reduce((sum, d) => sum + d.withdrawalAmount, 0);
// 거래처 미설정 건수
const vendorUnsetCount = data.filter(d => !d.vendorName).length;
// 출금유형 미설정 건수
const withdrawalTypeUnsetCount = data.filter(d => d.withdrawalType === 'unset').length;
return [
{ label: '총 출금', value: `${totalWithdrawal.toLocaleString()}`, icon: Banknote, iconColor: 'text-blue-500' },
{ label: '당월 출금', value: `${monthlyWithdrawal.toLocaleString()}`, icon: Banknote, iconColor: 'text-green-500' },
{ label: '거래처 미설정', value: `${vendorUnsetCount}`, icon: Banknote, iconColor: 'text-orange-500' },
{ label: '출금유형 미설정', value: `${withdrawalTypeUnsetCount}`, icon: Banknote, iconColor: 'text-red-500' },
];
}, [data]);
// ===== 테이블 컬럼 =====
const tableColumns: TableColumn[] = useMemo(() => [
{ key: 'withdrawalDate', label: '출금일' },
{ key: 'accountName', label: '출금계좌' },
{ key: 'recipientName', label: '수취인명' },
{ key: 'withdrawalAmount', label: '출금금액', className: 'text-right' },
{ key: 'vendorName', label: '거래처' },
{ key: 'note', label: '적요' },
{ key: 'withdrawalType', label: '출금유형', className: 'text-center' },
{ key: 'actions', label: '작업', className: 'text-center w-[80px]' },
], []);
// ===== 테이블 행 렌더링 =====
const renderTableRow = useCallback((item: WithdrawalRecord, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(item.id);
const isVendorUnset = !item.vendorName;
const isWithdrawalTypeUnset = item.withdrawalType === 'unset';
return (
<TableRow
key={item.id}
className="hover:bg-muted/50 cursor-pointer"
onClick={() => handleRowClick(item)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={isSelected} onCheckedChange={() => toggleSelection(item.id)} />
</TableCell>
{/* 출금일 */}
<TableCell>{item.withdrawalDate}</TableCell>
{/* 출금계좌 */}
<TableCell>{item.accountName}</TableCell>
{/* 수취인명 */}
<TableCell>{item.recipientName}</TableCell>
{/* 출금금액 */}
<TableCell className="text-right font-medium">{item.withdrawalAmount.toLocaleString()}</TableCell>
{/* 거래처 */}
<TableCell className={isVendorUnset ? 'text-red-500 font-medium' : ''}>
{item.vendorName || '미설정'}
</TableCell>
{/* 적요 */}
<TableCell className="text-gray-500">{item.note || '-'}</TableCell>
{/* 출금유형 */}
<TableCell className="text-center">
<Badge
variant="outline"
className={isWithdrawalTypeUnset ? 'border-red-300 text-red-500 bg-red-50' : ''}
>
{WITHDRAWAL_TYPE_LABELS[item.withdrawalType]}
</Badge>
</TableCell>
{/* 작업 */}
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
{isSelected && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-gray-600 hover:text-gray-700 hover:bg-gray-50"
onClick={() => router.push(`/ko/accounting/withdrawals/${item.id}?mode=edit`)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => handleDeleteClick(item.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
}, [selectedItems, toggleSelection, handleRowClick, handleDeleteClick, router]);
// ===== 모바일 카드 렌더링 =====
const renderMobileCard = useCallback((
item: WithdrawalRecord,
index: number,
globalIndex: number,
isSelected: boolean,
onToggle: () => void
) => {
return (
<ListMobileCard
id={item.id}
title={item.recipientName}
headerBadges={
<>
<Badge variant="outline">{WITHDRAWAL_TYPE_LABELS[item.withdrawalType]}</Badge>
</>
}
isSelected={isSelected}
onToggleSelection={onToggle}
infoGrid={
<div className="grid grid-cols-2 gap-3">
<InfoField label="출금일" value={item.withdrawalDate} />
<InfoField label="출금액" value={`${item.withdrawalAmount.toLocaleString()}`} />
<InfoField label="출금계좌" value={item.accountName} />
<InfoField label="거래처" value={item.vendorName || '-'} />
</div>
}
actions={
isSelected ? (
<div className="flex gap-2 w-full">
<Button variant="outline" className="flex-1" onClick={() => handleRowClick(item)}>
<Pencil className="w-4 h-4 mr-2" />
</Button>
<Button
variant="outline"
className="flex-1 text-red-500 border-red-200 hover:bg-red-50 hover:text-red-600"
onClick={() => handleDeleteClick(item.id)}
>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</div>
) : undefined
}
onCardClick={() => handleRowClick(item)}
/>
);
}, [handleRowClick, handleDeleteClick]);
// ===== 헤더 액션 =====
const headerActions = (
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
);
// ===== 계정과목명 저장 핸들러 =====
const handleSaveAccountSubject = useCallback(() => {
if (selectedItems.size === 0) {
setShowSelectWarningDialog(true);
return;
}
setShowSaveDialog(true);
}, [selectedItems.size]);
// 계정과목명 저장 확정
const handleConfirmSaveAccountSubject = useCallback(() => {
console.log('계정과목명 저장:', selectedAccountSubject, '선택된 항목:', Array.from(selectedItems));
// TODO: API 호출로 저장
setShowSaveDialog(false);
setSelectedItems(new Set());
}, [selectedAccountSubject, selectedItems]);
// ===== 거래처 목록 (필터용) =====
const vendorOptions = useMemo(() => {
const uniqueVendors = [...new Set(data.map(d => d.vendorName).filter(v => v))];
return [
{ value: 'all', label: '전체' },
...uniqueVendors.map(v => ({ value: v, label: v }))
];
}, [data]);
// ===== 테이블 헤더 액션 (필터들) =====
const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap">
{/* 거래처 필터 */}
<Select value={vendorFilter} onValueChange={setVendorFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="거래처" />
</SelectTrigger>
<SelectContent>
{vendorOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 출금유형 필터 */}
<Select value={withdrawalTypeFilter} onValueChange={setWithdrawalTypeFilter}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="출금유형" />
</SelectTrigger>
<SelectContent>
{WITHDRAWAL_TYPE_FILTER_OPTIONS.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-[120px]">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
// ===== 상단 계정과목명 + 저장 버튼 + 새로고침 =====
const accountSubjectSelector = (
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700"></span>
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="계정과목명 선택" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_SUBJECT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={handleSaveAccountSubject} className="bg-orange-500 hover:bg-orange-600">
<Save className="h-4 w-4 mr-2" />
</Button>
</div>
<Button
variant="outline"
onClick={handleRefresh}
>
<RefreshCw className="h-4 w-4 mr-2" />
</Button>
</div>
);
// ===== 테이블 합계 계산 =====
const tableTotals = useMemo(() => {
const totalAmount = filteredData.reduce((sum, item) => sum + item.withdrawalAmount, 0);
return { totalAmount };
}, [filteredData]);
// ===== 테이블 하단 합계 행 =====
const tableFooter = (
<TableRow className="bg-muted/50 font-medium">
<TableCell className="text-center"></TableCell>
<TableCell className="font-bold"></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell className="text-right font-bold">{tableTotals.totalAmount.toLocaleString()}</TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
);
return (
<>
<IntegratedListTemplateV2
title="출금관리"
description="출금 내역을 등록합니다"
icon={Banknote}
headerActions={headerActions}
stats={statCards}
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder="수취인명, 계좌명, 적요, 거래처 검색..."
beforeTableContent={accountSubjectSelector}
tableHeaderActions={tableHeaderActions}
tableColumns={tableColumns}
tableFooter={tableFooter}
data={paginatedData}
totalCount={filteredData.length}
allData={filteredData}
selectedItems={selectedItems}
onToggleSelection={toggleSelection}
onToggleSelectAll={toggleSelectAll}
getItemId={(item: WithdrawalRecord) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
pagination={{
currentPage,
totalPages,
totalItems: filteredData.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
/>
{/* 계정과목명 저장 확인 다이얼로그 */}
<Dialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
{selectedItems.size} {' '}
<span className="font-semibold text-orange-500">
{ACCOUNT_SUBJECT_OPTIONS.find(o => o.value === selectedAccountSubject)?.label}
</span>
() ?
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setShowSaveDialog(false)}>
</Button>
<Button
onClick={handleConfirmSaveAccountSubject}
className="bg-orange-500 hover:bg-orange-600"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 삭제 확인 다이얼로그 */}
<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>
{/* 선택 필요 알림 다이얼로그 */}
<AlertDialog open={showSelectWarningDialog} onOpenChange={setShowSelectWarningDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={() => setShowSelectWarningDialog(false)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,116 @@
// ===== 출금 유형 =====
export type WithdrawalType =
| 'unset' // 미설정
| 'purchasePayment' // 매입대금
| 'advance' // 선급금
| 'suspense' // 가지급금
| 'rent' // 임대료
| 'interestExpense' // 이자비용
| 'depositPayment' // 보증금 지급
| 'loanRepayment' // 차입금 상환
| 'dividendPayment' // 배당금 지급
| 'vatPayment' // 부가세 납부
| 'salary' // 급여
| 'insurance' // 4대보험
| 'tax' // 세금
| 'utilities' // 공과금
| 'expenses' // 경비
| 'other'; // 기타
export const WITHDRAWAL_TYPE_LABELS: Record<WithdrawalType, string> = {
unset: '미설정',
purchasePayment: '매입대금',
advance: '선급금',
suspense: '가지급금',
rent: '임대료',
interestExpense: '이자비용',
depositPayment: '보증금 지급',
loanRepayment: '차입금 상환',
dividendPayment: '배당금 지급',
vatPayment: '부가세 납부',
salary: '급여',
insurance: '4대보험',
tax: '세금',
utilities: '공과금',
expenses: '경비',
other: '기타',
};
export const WITHDRAWAL_TYPE_OPTIONS = [
{ value: 'unset', label: '미설정' },
{ value: 'purchasePayment', label: '매입대금' },
{ value: 'advance', label: '선급금' },
{ value: 'suspense', label: '가지급금' },
{ value: 'rent', label: '임대료' },
{ value: 'interestExpense', label: '이자비용' },
{ value: 'depositPayment', label: '보증금 지급' },
{ value: 'loanRepayment', label: '차입금 상환' },
{ value: 'dividendPayment', label: '배당금 지급' },
{ value: 'vatPayment', label: '부가세 납부' },
{ value: 'salary', label: '급여' },
{ value: 'insurance', label: '4대보험' },
{ value: 'tax', label: '세금' },
{ value: 'utilities', label: '공과금' },
{ value: 'expenses', label: '경비' },
{ value: 'other', label: '기타' },
];
// 출금유형 필터 옵션 (전체 포함)
export const WITHDRAWAL_TYPE_FILTER_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'purchasePayment', label: '매입대금' },
{ value: 'advance', label: '선급금' },
{ value: 'suspense', label: '가지급금' },
{ value: 'rent', label: '임대료' },
{ value: 'interestExpense', label: '이자비용' },
{ value: 'depositPayment', label: '보증금 지급' },
{ value: 'loanRepayment', label: '차입금 상환' },
{ value: 'dividendPayment', label: '배당금 지급' },
{ value: 'vatPayment', label: '부가세 납부' },
{ value: 'salary', label: '급여' },
{ value: 'insurance', label: '4대보험' },
{ value: 'tax', label: '세금' },
{ value: 'utilities', label: '공과금' },
{ value: 'expenses', label: '경비' },
{ value: 'other', label: '기타' },
{ value: 'unset', label: '미설정' },
];
// 상세 페이지 출금 유형 옵션 (전체 제외)
export const WITHDRAWAL_TYPE_SELECTOR_OPTIONS = WITHDRAWAL_TYPE_OPTIONS;
// ===== 계정과목명 옵션 (상단 셀렉트) =====
export const ACCOUNT_SUBJECT_OPTIONS = WITHDRAWAL_TYPE_OPTIONS.filter(o => o.value !== 'all');
// ===== 정렬 옵션 =====
export type SortOption = 'latest' | 'oldest' | 'amountHigh' | 'amountLow';
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'amountHigh', label: '금액 높은순' },
{ value: 'amountLow', label: '금액 낮은순' },
];
// ===== 출금 레코드 =====
export interface WithdrawalRecord {
id: string;
withdrawalDate: string; // 출금일
withdrawalAmount: number; // 출금액
accountName: string; // 출금계좌명
recipientName: string; // 수취인명
note: string; // 적요
withdrawalType: WithdrawalType; // 출금유형
vendorId: string; // 거래처 ID
vendorName: string; // 거래처명
createdAt: string;
updatedAt: string;
}
// ===== 거래처 타입 =====
export interface Vendor {
id: string;
name: string;
email?: string;
phone?: string;
}