609 lines
23 KiB
TypeScript
609 lines
23 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useState, useMemo, useCallback } from 'react';
|
||
|
|
import { format } from 'date-fns';
|
||
|
|
import {
|
||
|
|
FileCheck,
|
||
|
|
Check,
|
||
|
|
X,
|
||
|
|
Clock,
|
||
|
|
FileX,
|
||
|
|
Files,
|
||
|
|
Edit,
|
||
|
|
} from 'lucide-react';
|
||
|
|
import { Button } from '@/components/ui/button';
|
||
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
||
|
|
import { Badge } from '@/components/ui/badge';
|
||
|
|
import { TableRow, TableCell } from '@/components/ui/table';
|
||
|
|
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 {
|
||
|
|
IntegratedListTemplateV2,
|
||
|
|
type TableColumn,
|
||
|
|
type StatCard,
|
||
|
|
type TabOption,
|
||
|
|
} from '@/components/templates/IntegratedListTemplateV2';
|
||
|
|
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||
|
|
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
||
|
|
import { DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
||
|
|
import type { DocumentType, ProposalDocumentData, ExpenseReportDocumentData, ExpenseEstimateDocumentData } from '@/components/approval/DocumentDetail/types';
|
||
|
|
import type {
|
||
|
|
ApprovalTabType,
|
||
|
|
ApprovalRecord,
|
||
|
|
ApprovalStatus,
|
||
|
|
ApprovalType,
|
||
|
|
SortOption,
|
||
|
|
FilterOption,
|
||
|
|
} from './types';
|
||
|
|
import {
|
||
|
|
APPROVAL_TAB_LABELS,
|
||
|
|
SORT_OPTIONS,
|
||
|
|
FILTER_OPTIONS,
|
||
|
|
APPROVAL_TYPE_LABELS,
|
||
|
|
APPROVAL_STATUS_LABELS,
|
||
|
|
APPROVAL_STATUS_COLORS,
|
||
|
|
} from './types';
|
||
|
|
|
||
|
|
// ===== Mock 데이터 생성 =====
|
||
|
|
const generateApprovalData = (): ApprovalRecord[] => {
|
||
|
|
const departments = ['개발팀', '디자인팀', '기획팀', '영업팀', '인사팀'];
|
||
|
|
const positions = ['팀장', '파트장', '선임', '주임', '사원'];
|
||
|
|
const approvalTypes: ApprovalType[] = ['expense_report', 'proposal', 'expense_estimate'];
|
||
|
|
const statuses: ApprovalStatus[] = ['pending', 'approved', 'rejected'];
|
||
|
|
const titlesByType: Record<ApprovalType, string[]> = {
|
||
|
|
expense_report: ['12월 출장비 정산', '사무용품 구매비 청구', '고객 미팅 식대 정산', '세미나 참가비 정산'],
|
||
|
|
proposal: ['신규 프로젝트 품의', '장비 구매 품의', '외주 용역 품의', '마케팅 예산 품의'],
|
||
|
|
expense_estimate: ['2024년 하반기 예산', '신규 사업 예상 지출', '부서 운영비 예상', '행사 예산 내역'],
|
||
|
|
};
|
||
|
|
|
||
|
|
return Array.from({ length: 76 }, (_, i) => {
|
||
|
|
const status = statuses[i % statuses.length];
|
||
|
|
const approvalType = approvalTypes[i % approvalTypes.length];
|
||
|
|
const titles = titlesByType[approvalType];
|
||
|
|
const draftDate = new Date(2024, 8, Math.floor(Math.random() * 30) + 1);
|
||
|
|
const approvalDate = status !== 'pending' ? new Date(draftDate.getTime() + Math.random() * 7 * 24 * 60 * 60 * 1000) : undefined;
|
||
|
|
|
||
|
|
return {
|
||
|
|
id: `approval-${i + 1}`,
|
||
|
|
documentNo: `DOC-${String(i + 1).padStart(4, '0')}`,
|
||
|
|
approvalType,
|
||
|
|
documentStatus: status === 'pending' ? '진행중' : status === 'approved' ? '완료' : '반려',
|
||
|
|
title: titles[i % titles.length],
|
||
|
|
draftDate: format(draftDate, 'yyyy-MM-dd HH:mm'),
|
||
|
|
drafter: ['김철수', '이영희', '박민수', '정수진', '최동현', '강미영', '윤상호'][i % 7],
|
||
|
|
drafterDepartment: departments[i % departments.length],
|
||
|
|
drafterPosition: positions[i % positions.length],
|
||
|
|
approvalDate: approvalDate ? format(approvalDate, 'yyyy-MM-dd') : undefined,
|
||
|
|
approver: status !== 'pending' ? ['김부장', '이차장', '박과장'][i % 3] : undefined,
|
||
|
|
status,
|
||
|
|
priority: i % 5 === 0 ? 'high' : 'normal',
|
||
|
|
createdAt: new Date().toISOString(),
|
||
|
|
updatedAt: new Date().toISOString(),
|
||
|
|
};
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
export function ApprovalBox() {
|
||
|
|
// ===== 상태 관리 =====
|
||
|
|
const [activeTab, setActiveTab] = useState<ApprovalTabType>('all');
|
||
|
|
const [searchQuery, setSearchQuery] = useState('');
|
||
|
|
const [filterOption, setFilterOption] = useState<FilterOption>('all');
|
||
|
|
const [sortOption, setSortOption] = useState<SortOption>('latest');
|
||
|
|
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||
|
|
const [currentPage, setCurrentPage] = useState(1);
|
||
|
|
const itemsPerPage = 20;
|
||
|
|
|
||
|
|
// 날짜 범위 상태
|
||
|
|
const [startDate, setStartDate] = useState('2024-09-01');
|
||
|
|
const [endDate, setEndDate] = useState('2024-09-03');
|
||
|
|
|
||
|
|
// 다이얼로그 상태
|
||
|
|
const [approveDialogOpen, setApproveDialogOpen] = useState(false);
|
||
|
|
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
|
||
|
|
|
||
|
|
// ===== 문서 상세 모달 상태 =====
|
||
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||
|
|
const [selectedDocument, setSelectedDocument] = useState<ApprovalRecord | null>(null);
|
||
|
|
|
||
|
|
// Mock 데이터
|
||
|
|
const [approvalData] = useState<ApprovalRecord[]>(generateApprovalData);
|
||
|
|
|
||
|
|
// ===== 탭 변경 핸들러 =====
|
||
|
|
const handleTabChange = useCallback((value: string) => {
|
||
|
|
setActiveTab(value as ApprovalTabType);
|
||
|
|
setSelectedItems(new Set());
|
||
|
|
setSearchQuery('');
|
||
|
|
setCurrentPage(1);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// ===== 체크박스 핸들러 =====
|
||
|
|
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 toggleSelectAll = useCallback(() => {
|
||
|
|
if (selectedItems.size === filteredData.length && filteredData.length > 0) {
|
||
|
|
setSelectedItems(new Set());
|
||
|
|
} else {
|
||
|
|
setSelectedItems(new Set(filteredData.map(item => item.id)));
|
||
|
|
}
|
||
|
|
}, [selectedItems.size]);
|
||
|
|
|
||
|
|
// ===== 필터링된 데이터 =====
|
||
|
|
const filteredData = useMemo(() => {
|
||
|
|
let data = approvalData;
|
||
|
|
|
||
|
|
// 탭 필터
|
||
|
|
if (activeTab !== 'all') {
|
||
|
|
data = data.filter(item => item.status === activeTab);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 유형 필터
|
||
|
|
if (filterOption !== 'all') {
|
||
|
|
data = data.filter(item => item.approvalType === filterOption);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 검색 필터
|
||
|
|
if (searchQuery) {
|
||
|
|
data = data.filter(item =>
|
||
|
|
item.title.includes(searchQuery) ||
|
||
|
|
item.drafter.includes(searchQuery) ||
|
||
|
|
item.drafterDepartment.includes(searchQuery)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 정렬
|
||
|
|
switch (sortOption) {
|
||
|
|
case 'latest':
|
||
|
|
data = [...data].sort((a, b) => new Date(b.draftDate).getTime() - new Date(a.draftDate).getTime());
|
||
|
|
break;
|
||
|
|
case 'oldest':
|
||
|
|
data = [...data].sort((a, b) => new Date(a.draftDate).getTime() - new Date(b.draftDate).getTime());
|
||
|
|
break;
|
||
|
|
case 'draftDateAsc':
|
||
|
|
data = [...data].sort((a, b) => new Date(a.draftDate).getTime() - new Date(b.draftDate).getTime());
|
||
|
|
break;
|
||
|
|
case 'draftDateDesc':
|
||
|
|
data = [...data].sort((a, b) => new Date(b.draftDate).getTime() - new Date(a.draftDate).getTime());
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
return data;
|
||
|
|
}, [approvalData, activeTab, filterOption, searchQuery, 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 stats = useMemo(() => {
|
||
|
|
const all = approvalData.length;
|
||
|
|
const pending = approvalData.filter(item => item.status === 'pending').length;
|
||
|
|
const approved = approvalData.filter(item => item.status === 'approved').length;
|
||
|
|
const rejected = approvalData.filter(item => item.status === 'rejected').length;
|
||
|
|
return { all, pending, approved, rejected };
|
||
|
|
}, [approvalData]);
|
||
|
|
|
||
|
|
// ===== 승인/반려 핸들러 =====
|
||
|
|
const handleApproveClick = useCallback(() => {
|
||
|
|
if (selectedItems.size === 0) return;
|
||
|
|
setApproveDialogOpen(true);
|
||
|
|
}, [selectedItems.size]);
|
||
|
|
|
||
|
|
const handleApproveConfirm = useCallback(() => {
|
||
|
|
console.log('승인:', Array.from(selectedItems));
|
||
|
|
// TODO: API 호출
|
||
|
|
setSelectedItems(new Set());
|
||
|
|
setApproveDialogOpen(false);
|
||
|
|
}, [selectedItems]);
|
||
|
|
|
||
|
|
const handleRejectClick = useCallback(() => {
|
||
|
|
if (selectedItems.size === 0) return;
|
||
|
|
setRejectDialogOpen(true);
|
||
|
|
}, [selectedItems.size]);
|
||
|
|
|
||
|
|
const handleRejectConfirm = useCallback(() => {
|
||
|
|
console.log('반려:', Array.from(selectedItems));
|
||
|
|
// TODO: API 호출
|
||
|
|
setSelectedItems(new Set());
|
||
|
|
setRejectDialogOpen(false);
|
||
|
|
}, [selectedItems]);
|
||
|
|
|
||
|
|
// ===== 통계 카드 =====
|
||
|
|
const statCards: StatCard[] = useMemo(() => [
|
||
|
|
{ label: '전체결재', value: `${stats.all}건`, icon: Files, iconColor: 'text-blue-500' },
|
||
|
|
{ label: '미결재', value: `${stats.pending}건`, icon: Clock, iconColor: 'text-yellow-500' },
|
||
|
|
{ label: '결재완료', value: `${stats.approved}건`, icon: FileCheck, iconColor: 'text-green-500' },
|
||
|
|
{ label: '결재반려', value: `${stats.rejected}건`, icon: FileX, iconColor: 'text-red-500' },
|
||
|
|
], [stats]);
|
||
|
|
|
||
|
|
// ===== 탭 옵션 =====
|
||
|
|
const tabs: TabOption[] = useMemo(() => [
|
||
|
|
{ value: 'all', label: APPROVAL_TAB_LABELS.all, count: stats.all, color: 'blue' },
|
||
|
|
{ value: 'pending', label: APPROVAL_TAB_LABELS.pending, count: stats.pending, color: 'yellow' },
|
||
|
|
{ value: 'approved', label: APPROVAL_TAB_LABELS.approved, count: stats.approved, color: 'green' },
|
||
|
|
{ value: 'rejected', label: APPROVAL_TAB_LABELS.rejected, count: stats.rejected, color: 'red' },
|
||
|
|
], [stats]);
|
||
|
|
|
||
|
|
// ===== 테이블 컬럼 =====
|
||
|
|
// 문서번호, 문서유형, 제목, 기안자, 결재자, 기안일시, 상태, 작업
|
||
|
|
const tableColumns: TableColumn[] = useMemo(() => [
|
||
|
|
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||
|
|
{ key: 'documentNo', label: '문서번호' },
|
||
|
|
{ key: 'approvalType', label: '문서유형' },
|
||
|
|
{ key: 'title', label: '제목' },
|
||
|
|
{ key: 'drafter', label: '기안자' },
|
||
|
|
{ key: 'approver', label: '결재자' },
|
||
|
|
{ key: 'draftDate', label: '기안일시' },
|
||
|
|
{ key: 'status', label: '상태', className: 'text-center' },
|
||
|
|
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
|
||
|
|
], []);
|
||
|
|
|
||
|
|
// ===== 문서 클릭/상세 보기 핸들러 =====
|
||
|
|
const handleDocumentClick = useCallback((item: ApprovalRecord) => {
|
||
|
|
setSelectedDocument(item);
|
||
|
|
setIsModalOpen(true);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const handleModalEdit = useCallback(() => {
|
||
|
|
console.log('문서 수정:', selectedDocument?.id);
|
||
|
|
setIsModalOpen(false);
|
||
|
|
}, [selectedDocument]);
|
||
|
|
|
||
|
|
const handleModalCopy = useCallback(() => {
|
||
|
|
console.log('문서 복제:', selectedDocument?.id);
|
||
|
|
setIsModalOpen(false);
|
||
|
|
}, [selectedDocument]);
|
||
|
|
|
||
|
|
const handleModalApprove = useCallback(() => {
|
||
|
|
console.log('문서 승인:', selectedDocument?.id);
|
||
|
|
setIsModalOpen(false);
|
||
|
|
}, [selectedDocument]);
|
||
|
|
|
||
|
|
const handleModalReject = useCallback(() => {
|
||
|
|
console.log('문서 반려:', selectedDocument?.id);
|
||
|
|
setIsModalOpen(false);
|
||
|
|
}, [selectedDocument]);
|
||
|
|
|
||
|
|
// ===== ApprovalType → DocumentType 변환 =====
|
||
|
|
const getDocumentType = (approvalType: ApprovalType): DocumentType => {
|
||
|
|
switch (approvalType) {
|
||
|
|
case 'expense_estimate': return 'expenseEstimate';
|
||
|
|
case 'expense_report': return 'expenseReport';
|
||
|
|
default: return 'proposal';
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// ===== ApprovalRecord → 모달용 데이터 변환 =====
|
||
|
|
const convertToModalData = (item: ApprovalRecord): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData => {
|
||
|
|
const docType = getDocumentType(item.approvalType);
|
||
|
|
const drafter = {
|
||
|
|
id: 'drafter-1',
|
||
|
|
name: item.drafter,
|
||
|
|
position: item.drafterPosition,
|
||
|
|
department: item.drafterDepartment,
|
||
|
|
status: 'approved' as const,
|
||
|
|
};
|
||
|
|
const approvers = [{
|
||
|
|
id: 'approver-1',
|
||
|
|
name: item.approver || '미지정',
|
||
|
|
position: '부장',
|
||
|
|
department: '경영지원팀',
|
||
|
|
status: item.status === 'approved' ? 'approved' as const : item.status === 'rejected' ? 'rejected' as const : 'pending' as const,
|
||
|
|
}];
|
||
|
|
|
||
|
|
switch (docType) {
|
||
|
|
case 'expenseEstimate':
|
||
|
|
return {
|
||
|
|
documentNo: item.documentNo,
|
||
|
|
createdAt: item.draftDate,
|
||
|
|
items: [
|
||
|
|
{ id: '1', expectedPaymentDate: '2025-11-05', category: '통신 서비스', amount: 550000, vendor: 'KT', account: '국민 123-456-789012 홍길동' },
|
||
|
|
{ id: '2', expectedPaymentDate: '2025-11-12', category: '인건 대행', amount: 2500000, vendor: '에이치알코리아', account: '신한 110-123-456789 (주)에이치알' },
|
||
|
|
{ id: '3', expectedPaymentDate: '2025-11-15', category: '사무용품', amount: 350000, vendor: '오피스디포', account: '우리 1002-123-456789 오피스디포' },
|
||
|
|
{ id: '4', expectedPaymentDate: '2025-11-20', category: '임대료', amount: 3000000, vendor: '강남빌딩', account: '하나 123-12-12345 강남빌딩관리' },
|
||
|
|
{ id: '5', expectedPaymentDate: '2025-12-05', category: '통신 서비스', amount: 550000, vendor: 'KT', account: '국민 123-456-789012 홍길동' },
|
||
|
|
{ id: '6', expectedPaymentDate: '2025-12-10', category: '소프트웨어 구독', amount: 890000, vendor: 'Microsoft', account: '기업 123-456-78901234 MS코리아' },
|
||
|
|
{ id: '7', expectedPaymentDate: '2025-12-15', category: '인건 대행', amount: 2500000, vendor: '에이치알코리아', account: '신한 110-123-456789 (주)에이치알' },
|
||
|
|
{ id: '8', expectedPaymentDate: '2025-12-20', category: '임대료', amount: 3000000, vendor: '강남빌딩', account: '하나 123-12-12345 강남빌딩관리' },
|
||
|
|
],
|
||
|
|
totalExpense: 13340000,
|
||
|
|
accountBalance: 25000000,
|
||
|
|
finalDifference: 11660000,
|
||
|
|
approvers,
|
||
|
|
drafter,
|
||
|
|
};
|
||
|
|
case 'expenseReport':
|
||
|
|
return {
|
||
|
|
documentNo: item.documentNo,
|
||
|
|
createdAt: item.draftDate,
|
||
|
|
requestDate: item.draftDate,
|
||
|
|
paymentDate: item.draftDate,
|
||
|
|
items: [
|
||
|
|
{ id: '1', no: 1, description: '업무용 택시비', amount: 50000, note: '고객사 미팅' },
|
||
|
|
{ id: '2', no: 2, description: '식대', amount: 30000, note: '팀 회식' },
|
||
|
|
],
|
||
|
|
cardInfo: '삼성카드 **** 1234',
|
||
|
|
totalAmount: 80000,
|
||
|
|
attachments: [],
|
||
|
|
approvers,
|
||
|
|
drafter,
|
||
|
|
};
|
||
|
|
default:
|
||
|
|
return {
|
||
|
|
documentNo: item.documentNo,
|
||
|
|
createdAt: item.draftDate,
|
||
|
|
vendor: '거래처',
|
||
|
|
vendorPaymentDate: item.draftDate,
|
||
|
|
title: item.title,
|
||
|
|
description: item.title,
|
||
|
|
reason: '업무상 필요',
|
||
|
|
estimatedCost: 1000000,
|
||
|
|
attachments: [],
|
||
|
|
approvers,
|
||
|
|
drafter,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// ===== 테이블 행 렌더링 =====
|
||
|
|
// 컬럼 순서: 번호, 문서번호, 문서유형, 제목, 기안자, 결재자, 기안일시, 상태, 작업
|
||
|
|
const renderTableRow = useCallback((item: ApprovalRecord, index: number, globalIndex: number) => {
|
||
|
|
const isSelected = selectedItems.has(item.id);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<TableRow
|
||
|
|
key={item.id}
|
||
|
|
className="hover:bg-muted/50 cursor-pointer"
|
||
|
|
onClick={() => handleDocumentClick(item)}
|
||
|
|
>
|
||
|
|
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||
|
|
<Checkbox checked={isSelected} onCheckedChange={() => toggleSelection(item.id)} />
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="text-center">{globalIndex}</TableCell>
|
||
|
|
<TableCell className="font-mono text-sm">{item.documentNo}</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
<Badge variant="outline">{APPROVAL_TYPE_LABELS[item.approvalType]}</Badge>
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="font-medium max-w-[200px] truncate">{item.title}</TableCell>
|
||
|
|
<TableCell>{item.drafter}</TableCell>
|
||
|
|
<TableCell>{item.approver || '-'}</TableCell>
|
||
|
|
<TableCell>{item.draftDate}</TableCell>
|
||
|
|
<TableCell className="text-center">
|
||
|
|
<Badge className={APPROVAL_STATUS_COLORS[item.status]}>
|
||
|
|
{APPROVAL_STATUS_LABELS[item.status]}
|
||
|
|
</Badge>
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||
|
|
{isSelected && (
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="sm"
|
||
|
|
onClick={() => handleDocumentClick(item)}
|
||
|
|
>
|
||
|
|
<Edit className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
);
|
||
|
|
}, [selectedItems, toggleSelection, handleDocumentClick]);
|
||
|
|
|
||
|
|
// ===== 모바일 카드 렌더링 =====
|
||
|
|
const renderMobileCard = useCallback((
|
||
|
|
item: ApprovalRecord,
|
||
|
|
index: number,
|
||
|
|
globalIndex: number,
|
||
|
|
isSelected: boolean,
|
||
|
|
onToggle: () => void
|
||
|
|
) => {
|
||
|
|
return (
|
||
|
|
<ListMobileCard
|
||
|
|
id={item.id}
|
||
|
|
title={item.title}
|
||
|
|
headerBadges={
|
||
|
|
<div className="flex gap-1">
|
||
|
|
<Badge variant="outline">{APPROVAL_TYPE_LABELS[item.approvalType]}</Badge>
|
||
|
|
<Badge className={APPROVAL_STATUS_COLORS[item.status]}>
|
||
|
|
{APPROVAL_STATUS_LABELS[item.status]}
|
||
|
|
</Badge>
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
isSelected={isSelected}
|
||
|
|
onToggleSelection={onToggle}
|
||
|
|
infoGrid={
|
||
|
|
<div className="grid grid-cols-2 gap-3">
|
||
|
|
<InfoField label="문서번호" value={item.documentNo} />
|
||
|
|
<InfoField label="기안자" value={item.drafter} />
|
||
|
|
<InfoField label="부서" value={item.drafterDepartment} />
|
||
|
|
<InfoField label="직급" value={item.drafterPosition} />
|
||
|
|
<InfoField label="기안일" value={item.draftDate} />
|
||
|
|
<InfoField label="결재일" value={item.approvalDate || '-'} />
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
actions={
|
||
|
|
item.status === 'pending' && (
|
||
|
|
<div className="flex gap-2">
|
||
|
|
<Button variant="default" className="flex-1" onClick={handleApproveClick}>
|
||
|
|
<Check className="w-4 h-4 mr-2" /> 승인
|
||
|
|
</Button>
|
||
|
|
<Button variant="outline" className="flex-1" onClick={handleRejectClick}>
|
||
|
|
<X className="w-4 h-4 mr-2" /> 반려
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
}, [handleApproveClick, handleRejectClick]);
|
||
|
|
|
||
|
|
// ===== 헤더 액션 (DateRangeSelector + 승인/반려 버튼) =====
|
||
|
|
const headerActions = (
|
||
|
|
<DateRangeSelector
|
||
|
|
startDate={startDate}
|
||
|
|
endDate={endDate}
|
||
|
|
onStartDateChange={setStartDate}
|
||
|
|
onEndDateChange={setEndDate}
|
||
|
|
extraActions={
|
||
|
|
selectedItems.size > 0 && (
|
||
|
|
<>
|
||
|
|
<Button variant="default" onClick={handleApproveClick}>
|
||
|
|
<Check className="h-4 w-4 mr-2" />
|
||
|
|
승인
|
||
|
|
</Button>
|
||
|
|
<Button variant="destructive" onClick={handleRejectClick}>
|
||
|
|
<X className="h-4 w-4 mr-2" />
|
||
|
|
반려
|
||
|
|
</Button>
|
||
|
|
</>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
|
||
|
|
// ===== 테이블 헤더 액션 (필터 + 정렬 셀렉트) =====
|
||
|
|
const tableHeaderActions = (
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
{/* 필터 셀렉트박스 */}
|
||
|
|
<Select value={filterOption} onValueChange={(value) => setFilterOption(value as FilterOption)}>
|
||
|
|
<SelectTrigger className="w-[140px]">
|
||
|
|
<SelectValue placeholder="필터 선택" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{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-[140px]">
|
||
|
|
<SelectValue placeholder="정렬 선택" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{SORT_OPTIONS.map((option) => (
|
||
|
|
<SelectItem key={option.value} value={option.value}>
|
||
|
|
{option.label}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<IntegratedListTemplateV2
|
||
|
|
title="결재함"
|
||
|
|
description="결재 문서를 관리합니다"
|
||
|
|
icon={FileCheck}
|
||
|
|
headerActions={headerActions}
|
||
|
|
stats={statCards}
|
||
|
|
searchValue={searchQuery}
|
||
|
|
onSearchChange={setSearchQuery}
|
||
|
|
searchPlaceholder="제목, 기안자, 부서 검색..."
|
||
|
|
tableHeaderActions={tableHeaderActions}
|
||
|
|
tabs={tabs}
|
||
|
|
activeTab={activeTab}
|
||
|
|
onTabChange={handleTabChange}
|
||
|
|
tableColumns={tableColumns}
|
||
|
|
data={paginatedData}
|
||
|
|
totalCount={filteredData.length}
|
||
|
|
allData={filteredData}
|
||
|
|
selectedItems={selectedItems}
|
||
|
|
onToggleSelection={toggleSelection}
|
||
|
|
onToggleSelectAll={toggleSelectAll}
|
||
|
|
getItemId={(item: ApprovalRecord) => item.id}
|
||
|
|
renderTableRow={renderTableRow}
|
||
|
|
renderMobileCard={renderMobileCard}
|
||
|
|
pagination={{
|
||
|
|
currentPage,
|
||
|
|
totalPages,
|
||
|
|
totalItems: filteredData.length,
|
||
|
|
itemsPerPage,
|
||
|
|
onPageChange: setCurrentPage,
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* 승인 확인 다이얼로그 */}
|
||
|
|
<AlertDialog open={approveDialogOpen} onOpenChange={setApproveDialogOpen}>
|
||
|
|
<AlertDialogContent>
|
||
|
|
<AlertDialogHeader>
|
||
|
|
<AlertDialogTitle>결재 승인</AlertDialogTitle>
|
||
|
|
<AlertDialogDescription>
|
||
|
|
정말 {selectedItems.size}건을 승인하시겠습니까?
|
||
|
|
</AlertDialogDescription>
|
||
|
|
</AlertDialogHeader>
|
||
|
|
<AlertDialogFooter>
|
||
|
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||
|
|
<AlertDialogAction onClick={handleApproveConfirm}>
|
||
|
|
승인
|
||
|
|
</AlertDialogAction>
|
||
|
|
</AlertDialogFooter>
|
||
|
|
</AlertDialogContent>
|
||
|
|
</AlertDialog>
|
||
|
|
|
||
|
|
{/* 반려 확인 다이얼로그 */}
|
||
|
|
<AlertDialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
|
||
|
|
<AlertDialogContent>
|
||
|
|
<AlertDialogHeader>
|
||
|
|
<AlertDialogTitle>결재 반려</AlertDialogTitle>
|
||
|
|
<AlertDialogDescription>
|
||
|
|
정말 {selectedItems.size}건을 반려하시겠습니까?
|
||
|
|
</AlertDialogDescription>
|
||
|
|
</AlertDialogHeader>
|
||
|
|
<AlertDialogFooter>
|
||
|
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||
|
|
<AlertDialogAction
|
||
|
|
onClick={handleRejectConfirm}
|
||
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||
|
|
>
|
||
|
|
반려
|
||
|
|
</AlertDialogAction>
|
||
|
|
</AlertDialogFooter>
|
||
|
|
</AlertDialogContent>
|
||
|
|
</AlertDialog>
|
||
|
|
|
||
|
|
{/* 문서 상세 모달 */}
|
||
|
|
{selectedDocument && (
|
||
|
|
<DocumentDetailModal
|
||
|
|
open={isModalOpen}
|
||
|
|
onOpenChange={setIsModalOpen}
|
||
|
|
documentType={getDocumentType(selectedDocument.approvalType)}
|
||
|
|
data={convertToModalData(selectedDocument)}
|
||
|
|
onEdit={handleModalEdit}
|
||
|
|
onCopy={handleModalCopy}
|
||
|
|
onApprove={handleModalApprove}
|
||
|
|
onReject={handleModalReject}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|