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:
@@ -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>
|
||||
);
|
||||
}
|
||||
605
src/components/accounting/WithdrawalManagement/index.tsx
Normal file
605
src/components/accounting/WithdrawalManagement/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
116
src/components/accounting/WithdrawalManagement/types.ts
Normal file
116
src/components/accounting/WithdrawalManagement/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user