feat: 전자결재 시스템 구현 (기안함, 결재함, 참조함, 문서상세)
- 기안함(DraftBox): 문서 목록, 상신/삭제, 문서작성 연결 - 결재함(ApprovalBox): 결재 대기 문서 목록, 문서상세 모달 연결 - 참조함(ReferenceBox): 참조 문서 목록, 열람/미열람 처리 - 문서작성(DocumentCreate): 품의서, 지출결의서, 지출예상내역서 폼 - 문서상세(DocumentDetail): 공유 모달, 결재선 박스, 3종 문서 뷰어 - 테이블 번호 컬럼 추가 (1번부터 시작) - sonner toast 적용 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
553
src/components/approval/DraftBox/index.tsx
Normal file
553
src/components/approval/DraftBox/index.tsx
Normal file
@@ -0,0 +1,553 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { format } from 'date-fns';
|
||||
import {
|
||||
FileText,
|
||||
Send,
|
||||
Trash2,
|
||||
Plus,
|
||||
Pencil,
|
||||
} 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 {
|
||||
IntegratedListTemplateV2,
|
||||
type TableColumn,
|
||||
type StatCard,
|
||||
} 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 {
|
||||
DraftRecord,
|
||||
DocumentStatus,
|
||||
Approver,
|
||||
SortOption,
|
||||
FilterOption,
|
||||
} from './types';
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
FILTER_OPTIONS,
|
||||
DOCUMENT_STATUS_LABELS,
|
||||
DOCUMENT_STATUS_COLORS,
|
||||
APPROVER_STATUS_COLORS,
|
||||
} from './types';
|
||||
|
||||
// ===== Mock 데이터 생성 (Hydration 오류 방지를 위해 고정값 사용) =====
|
||||
const generateMockData = (): DraftRecord[] => {
|
||||
const documentTypes = ['품의서', '지출결의서', '지출 예상 내역서'];
|
||||
const titles = [
|
||||
'사무용품 구매 품의',
|
||||
'12월 프로젝트 비용 지출',
|
||||
'2025년 1분기 지출 예상',
|
||||
'노트북 구매 품의',
|
||||
'출장비 지출 결의',
|
||||
'소프트웨어 라이선스 구매 품의',
|
||||
'11월 법인카드 사용 내역',
|
||||
'2025년 상반기 예산 계획',
|
||||
'사무실 인테리어 품의',
|
||||
'팀 회식비 지출 결의',
|
||||
];
|
||||
const drafters = ['김철수', '이영희', '박민수', '정수진', '최동현'];
|
||||
const approverNames = ['강미영', '윤상호', '임지현', '한승우', '송예진', '조현우', '배수빈'];
|
||||
const positions = ['팀장', '부장', '이사', '대표'];
|
||||
const departments = ['개발팀', '인사팀', '영업팀', '기획팀', '경영지원팀'];
|
||||
const statuses: DocumentStatus[] = ['draft', 'pending', 'inProgress', 'approved', 'rejected'];
|
||||
const approverStatuses: Approver['status'][] = ['pending', 'approved', 'rejected', 'none'];
|
||||
|
||||
return Array.from({ length: 76 }, (_, i) => {
|
||||
const approverCount = (i % 3) + 1; // 1~3명 고정 패턴
|
||||
const approvers: Approver[] = Array.from({ length: approverCount }, (_, j) => ({
|
||||
id: `approver-${i}-${j}`,
|
||||
name: approverNames[(i + j) % approverNames.length],
|
||||
position: positions[j % positions.length],
|
||||
department: departments[(i + j) % departments.length],
|
||||
status: j === 0 ? approverStatuses[i % 4] : 'none',
|
||||
approvedAt: j === 0 && i % 2 === 0 ? format(new Date(2025, 11, (i % 17) + 1), 'yyyy-MM-dd') : undefined,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: `draft-${i + 1}`,
|
||||
documentNo: `DOC-2025-${String(i + 1).padStart(4, '0')}`,
|
||||
documentType: documentTypes[i % documentTypes.length],
|
||||
title: titles[i % titles.length],
|
||||
draftDate: format(new Date(2025, 11, (i % 17) + 1), 'yyyy-MM-dd'),
|
||||
drafter: drafters[i % drafters.length],
|
||||
approvers,
|
||||
status: statuses[i % statuses.length],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export function DraftBox() {
|
||||
const router = useRouter();
|
||||
|
||||
// ===== 상태 관리 =====
|
||||
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('2025-01-01');
|
||||
const [endDate, setEndDate] = useState('2025-12-31');
|
||||
|
||||
// Mock 데이터
|
||||
const [data] = useState<DraftRecord[]>(generateMockData);
|
||||
|
||||
// ===== 문서 상세 모달 상태 =====
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedDocument, setSelectedDocument] = useState<DraftRecord | null>(null);
|
||||
|
||||
// ===== 체크박스 핸들러 =====
|
||||
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.title.includes(searchQuery) ||
|
||||
item.documentNo.includes(searchQuery) ||
|
||||
item.drafter.includes(searchQuery)
|
||||
);
|
||||
|
||||
// 상태 필터
|
||||
if (filterOption !== 'all') {
|
||||
result = result.filter(item => item.status === filterOption);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
switch (sortOption) {
|
||||
case 'latest':
|
||||
result.sort((a, b) => new Date(b.draftDate).getTime() - new Date(a.draftDate).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
result.sort((a, b) => new Date(a.draftDate).getTime() - new Date(b.draftDate).getTime());
|
||||
break;
|
||||
case 'titleAsc':
|
||||
result.sort((a, b) => a.title.localeCompare(b.title));
|
||||
break;
|
||||
case 'titleDesc':
|
||||
result.sort((a, b) => b.title.localeCompare(a.title));
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [data, searchQuery, filterOption, 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 handleSubmit = useCallback(() => {
|
||||
console.log('상신:', Array.from(selectedItems));
|
||||
setSelectedItems(new Set());
|
||||
}, [selectedItems]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
console.log('삭제:', Array.from(selectedItems));
|
||||
setSelectedItems(new Set());
|
||||
}, [selectedItems]);
|
||||
|
||||
const handleNewDocument = useCallback(() => {
|
||||
router.push('/ko/approval/draft/new');
|
||||
}, [router]);
|
||||
|
||||
// ===== 문서 클릭/수정 핸들러 (조건부 로직) =====
|
||||
// 임시저장 → 문서 작성 페이지 (수정 모드)
|
||||
// 그 외 → 문서 상세 모달
|
||||
const handleDocumentClick = useCallback((item: DraftRecord) => {
|
||||
if (item.status === 'draft') {
|
||||
// 임시저장 상태 → 문서 작성 페이지로 이동 (수정 모드)
|
||||
router.push(`/ko/approval/draft/new?id=${item.id}&mode=edit`);
|
||||
} else {
|
||||
// 그 외 상태 → 문서 상세 모달 열기
|
||||
setSelectedDocument(item);
|
||||
setIsModalOpen(true);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const handleModalEdit = useCallback(() => {
|
||||
if (selectedDocument) {
|
||||
router.push(`/ko/approval/draft/new?id=${selectedDocument.id}&mode=edit`);
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
}, [selectedDocument, router]);
|
||||
|
||||
const handleModalCopy = useCallback(() => {
|
||||
if (selectedDocument) {
|
||||
router.push(`/ko/approval/draft/new?copyFrom=${selectedDocument.id}`);
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
}, [selectedDocument, router]);
|
||||
|
||||
const handleModalApprove = useCallback(() => {
|
||||
console.log('승인:', selectedDocument?.id);
|
||||
setIsModalOpen(false);
|
||||
}, [selectedDocument]);
|
||||
|
||||
const handleModalReject = useCallback(() => {
|
||||
console.log('반려:', selectedDocument?.id);
|
||||
setIsModalOpen(false);
|
||||
}, [selectedDocument]);
|
||||
|
||||
// ===== DraftRecord → 모달용 데이터 변환 =====
|
||||
const getDocumentType = (docType: string): DocumentType => {
|
||||
if (docType.includes('지출') && docType.includes('예상')) return 'expenseEstimate';
|
||||
if (docType.includes('지출')) return 'expenseReport';
|
||||
return 'proposal';
|
||||
};
|
||||
|
||||
const convertToModalData = (item: DraftRecord): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData => {
|
||||
const docType = getDocumentType(item.documentType);
|
||||
const drafter = {
|
||||
id: 'drafter-1',
|
||||
name: item.drafter,
|
||||
position: '사원',
|
||||
department: '개발팀',
|
||||
status: 'approved' as const,
|
||||
};
|
||||
const approvers = item.approvers.map(a => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
position: a.position,
|
||||
department: a.department,
|
||||
status: a.status,
|
||||
approvedAt: a.approvedAt,
|
||||
}));
|
||||
|
||||
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 statCards: StatCard[] = useMemo(() => {
|
||||
const inProgressCount = data.filter(d => d.status === 'inProgress' || d.status === 'pending').length;
|
||||
const approvedCount = data.filter(d => d.status === 'approved').length;
|
||||
const rejectedCount = data.filter(d => d.status === 'rejected').length;
|
||||
const draftCount = data.filter(d => d.status === 'draft').length;
|
||||
|
||||
return [
|
||||
{ label: '진행', value: `${inProgressCount}건`, icon: FileText, iconColor: 'text-blue-500' },
|
||||
{ label: '완료', value: `${approvedCount}건`, icon: FileText, iconColor: 'text-green-500' },
|
||||
{ label: '반려', value: `${rejectedCount}건`, icon: FileText, iconColor: 'text-red-500' },
|
||||
{ label: '임시 저장', value: `${draftCount}건`, icon: FileText, iconColor: 'text-gray-500' },
|
||||
];
|
||||
}, [data]);
|
||||
|
||||
// ===== 테이블 컬럼 (스크린샷 기준) =====
|
||||
const tableColumns: TableColumn[] = useMemo(() => [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'documentNo', label: '문서번호' },
|
||||
{ key: 'documentType', label: '문서유형' },
|
||||
{ key: 'title', label: '제목' },
|
||||
{ key: 'approvers', label: '결재자' },
|
||||
{ key: 'draftDate', label: '기안일시' },
|
||||
{ key: 'status', label: '상태', className: 'text-center' },
|
||||
{ key: 'actions', label: '작업', className: 'text-center' },
|
||||
], []);
|
||||
|
||||
// ===== 결재자 텍스트 포맷 (예: "강미영 외 2명") =====
|
||||
const formatApprovers = (approvers: Approver[]): string => {
|
||||
if (approvers.length === 0) return '-';
|
||||
if (approvers.length === 1) return approvers[0].name;
|
||||
return `${approvers[0].name} 외 ${approvers.length - 1}명`;
|
||||
};
|
||||
|
||||
// ===== 테이블 행 렌더링 (스크린샷 기준: 수정/삭제) =====
|
||||
const renderTableRow = useCallback((item: DraftRecord, 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="text-sm">{item.documentNo}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{item.documentType}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium max-w-[250px] truncate">{item.title}</TableCell>
|
||||
<TableCell>{formatApprovers(item.approvers)}</TableCell>
|
||||
<TableCell>{item.draftDate}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={DOCUMENT_STATUS_COLORS[item.status]}>
|
||||
{DOCUMENT_STATUS_LABELS[item.status]}
|
||||
</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={() => handleDocumentClick(item)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => console.log('삭제:', item.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}, [selectedItems, toggleSelection, formatApprovers, handleDocumentClick]);
|
||||
|
||||
// ===== 모바일 카드 렌더링 =====
|
||||
const renderMobileCard = useCallback((
|
||||
item: DraftRecord,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
isSelected: boolean,
|
||||
onToggle: () => void
|
||||
) => {
|
||||
return (
|
||||
<ListMobileCard
|
||||
id={item.id}
|
||||
title={item.title}
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge variant="outline">{item.documentType}</Badge>
|
||||
<Badge className={DOCUMENT_STATUS_COLORS[item.status]}>
|
||||
{DOCUMENT_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
</>
|
||||
}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<InfoField label="문서번호" value={item.documentNo} />
|
||||
<InfoField label="기안일자" value={item.draftDate} />
|
||||
<InfoField label="기안자" value={item.drafter} />
|
||||
<InfoField
|
||||
label="결재자"
|
||||
value={item.approvers.map(a => a.name).join(' → ') || '-'}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
isSelected ? (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="flex-1" onClick={() => handleDocumentClick(item)}>
|
||||
<Pencil className="w-4 h-4 mr-2" /> 수정
|
||||
</Button>
|
||||
<Button variant="outline" className="flex-1 text-red-600" onClick={() => console.log('삭제:', item.id)}>
|
||||
<Trash2 className="w-4 h-4 mr-2" /> 삭제
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
onClick={() => handleDocumentClick(item)}
|
||||
/>
|
||||
);
|
||||
}, [handleDocumentClick]);
|
||||
|
||||
// ===== 헤더 액션 (DateRangeSelector + 버튼들) =====
|
||||
const headerActions = (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
extraActions={
|
||||
<>
|
||||
{selectedItems.size > 0 && (
|
||||
<>
|
||||
<Button variant="default" onClick={handleSubmit}>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
상신
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button onClick={handleNewDocument}>
|
||||
<Plus 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-[120px]">
|
||||
<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-[120px]">
|
||||
<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={FileText}
|
||||
headerActions={headerActions}
|
||||
stats={statCards}
|
||||
searchValue={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
searchPlaceholder="문서번호, 제목, 기안자 검색..."
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
totalCount={filteredData.length}
|
||||
allData={filteredData}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={toggleSelection}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
getItemId={(item: DraftRecord) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: filteredData.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 문서 상세 모달 */}
|
||||
{selectedDocument && (
|
||||
<DocumentDetailModal
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
documentType={getDocumentType(selectedDocument.documentType)}
|
||||
data={convertToModalData(selectedDocument)}
|
||||
onEdit={handleModalEdit}
|
||||
onCopy={handleModalCopy}
|
||||
onApprove={handleModalApprove}
|
||||
onReject={handleModalReject}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
77
src/components/approval/DraftBox/types.ts
Normal file
77
src/components/approval/DraftBox/types.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 기안함 타입 정의
|
||||
*/
|
||||
|
||||
// ===== 문서 상태 =====
|
||||
export type DocumentStatus = 'draft' | 'pending' | 'inProgress' | 'approved' | 'rejected';
|
||||
|
||||
// ===== 필터 옵션 =====
|
||||
export type FilterOption = 'all' | 'draft' | 'pending' | 'inProgress' | 'approved' | 'rejected';
|
||||
|
||||
export const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'draft', label: '임시저장' },
|
||||
{ value: 'pending', label: '결재대기' },
|
||||
{ value: 'inProgress', label: '진행중' },
|
||||
{ value: 'approved', label: '완료' },
|
||||
{ value: 'rejected', label: '반려' },
|
||||
];
|
||||
|
||||
// ===== 정렬 옵션 =====
|
||||
export type SortOption = 'latest' | 'oldest' | 'titleAsc' | 'titleDesc';
|
||||
|
||||
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '오래된순' },
|
||||
{ value: 'titleAsc', label: '제목 오름차순' },
|
||||
{ value: 'titleDesc', label: '제목 내림차순' },
|
||||
];
|
||||
|
||||
// ===== 결재자 정보 =====
|
||||
export interface Approver {
|
||||
id: string;
|
||||
name: string;
|
||||
position: string;
|
||||
department: string;
|
||||
status: 'pending' | 'approved' | 'rejected' | 'none';
|
||||
approvedAt?: string;
|
||||
}
|
||||
|
||||
// ===== 기안 문서 레코드 =====
|
||||
export interface DraftRecord {
|
||||
id: string;
|
||||
documentNo: string; // 문서번호
|
||||
documentType: string; // 문서제목 (양식명)
|
||||
title: string; // 제목
|
||||
draftDate: string; // 기안일자
|
||||
drafter: string; // 기안자
|
||||
approvers: Approver[]; // 결재자 목록 (최대 3명)
|
||||
status: DocumentStatus; // 문서상태
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ===== 상수 정의 =====
|
||||
|
||||
export const DOCUMENT_STATUS_LABELS: Record<DocumentStatus, string> = {
|
||||
draft: '임시저장',
|
||||
pending: '결재대기',
|
||||
inProgress: '진행중',
|
||||
approved: '완료',
|
||||
rejected: '반려',
|
||||
};
|
||||
|
||||
export const DOCUMENT_STATUS_COLORS: Record<DocumentStatus, string> = {
|
||||
draft: 'bg-gray-100 text-gray-800',
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
inProgress: 'bg-blue-100 text-blue-800',
|
||||
approved: 'bg-green-100 text-green-800',
|
||||
rejected: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
export const APPROVER_STATUS_COLORS: Record<Approver['status'], string> = {
|
||||
none: 'bg-gray-100 text-gray-600',
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
approved: 'bg-green-100 text-green-800',
|
||||
rejected: 'bg-red-100 text-red-800',
|
||||
};
|
||||
Reference in New Issue
Block a user