diff --git a/claudedocs/approval/[IMPL-2025-12-17] approval-document-checklist.md b/claudedocs/approval/[IMPL-2025-12-17] approval-document-checklist.md new file mode 100644 index 00000000..4fa1bcf2 --- /dev/null +++ b/claudedocs/approval/[IMPL-2025-12-17] approval-document-checklist.md @@ -0,0 +1,134 @@ +# 전자결재 문서 작성/상세 기능 구현 체크리스트 + +## 개요 +- **작업일**: 2025-12-17 +- **목표**: 전자결재 기안함 문서 작성 및 상세 모달 구현 + +--- + +## 1. 기안함 목록 페이지 (완료) + +- [x] 기안함 컴포넌트 구현 (`src/components/approval/DraftBox/`) +- [x] 타입 정의 (`types.ts`) +- [x] 메인 컴포넌트 (`index.tsx`) +- [x] 라우트 페이지 (`src/app/[locale]/(protected)/approval/draft/page.tsx`) +- [x] 통계 카드 수정 (진행, 완료, 반려, 임시 저장) +- [x] 체크박스 선택 시에만 작업 버튼 표시 +- [x] 헤더 버튼 순서 조정 (상신/삭제 → 문서 작성) + +**접속 URL**: `http://localhost:3000/ko/approval/draft` + +--- + +## 2. 문서 작성 페이지 (완료) + +### 2.1 공통 컴포넌트 +- [x] 타입 정의 (`src/components/approval/DocumentCreate/types.ts`) +- [x] 기본 정보 섹션 (`BasicInfoSection.tsx`) +- [x] 결재선 섹션 (`ApprovalLineSection.tsx`) +- [x] 참조 섹션 (`ReferenceSection.tsx`) + +### 2.2 문서 유형별 폼 +- [x] 품의서 폼 (`ProposalForm.tsx`) +- [x] 지출결의서 폼 (`ExpenseReportForm.tsx`) +- [x] 지출 예상 내역서 폼 (`ExpenseEstimateForm.tsx`) + - [x] Fragment key 에러 수정 + +### 2.3 메인 컴포넌트 및 라우트 +- [x] 메인 컴포넌트 (`index.tsx`) +- [x] 라우트 페이지 (`src/app/[locale]/(protected)/approval/draft/new/page.tsx`) +- [x] 기안함에서 문서 작성 버튼 클릭 시 페이지 이동 연결 + +**접속 URL**: `http://localhost:3000/ko/approval/draft/new` + +--- + +## 3. 문서 상세 모달 (완료) + +### 3.1 디자인 참고 +- [x] sam-design 프로젝트 `QuoteDetailView.tsx` 산출내역서 모달 구조 분석 + +### 3.2 공통 컴포넌트 (`src/components/approval/DocumentDetail/`) +- [x] 타입 정의 (`types.ts`) +- [x] 결재선 박스 (`ApprovalLineBox.tsx`) + +### 3.3 문서 유형별 컴포넌트 +- [x] 품의서 문서 (`ProposalDocument.tsx`) +- [x] 지출결의서 문서 (`ExpenseReportDocument.tsx`) +- [x] 지출 예상 내역서 문서 (`ExpenseEstimateDocument.tsx`) + +### 3.4 메인 모달 컴포넌트 +- [x] 메인 모달 (`index.tsx` - DocumentDetailModal) + - [x] 상단 버튼: 복제, 수정, 반려, 승인, 인쇄, 공유, 닫기 + - [x] 공유 드롭다운: PDF, 이메일, 팩스, 카카오톡 + - [x] 스크롤 가능한 문서 영역 (A4 형식) + +### 3.5 기안함 연결 +- [x] 기안함 목록에서 문서 클릭 시 조건부 처리 + - 임시저장 상태 → 문서 작성 페이지 (수정 모드) + - 그 외 상태 → 문서 상세 모달 +- [x] 문서 작성 화면에서 상세 버튼 클릭 시 미리보기 모달 + +--- + +## 4. 추가 작업 (완료) + +- [x] 빌드 테스트 (2025-12-17 완료) + - ✓ Compiled successfully in 7.0s + - ✓ Generating static pages (108/108) +- [ ] 근태관리 작업 버튼 수정 확인 (별도 작업) +- [ ] 문서 URL 목록 업데이트 (`claudedocs/[REF] all-pages-test-urls.md`) (별도 작업) + +--- + +## 파일 구조 + +``` +src/components/approval/ +├── DraftBox/ +│ ├── types.ts +│ └── index.tsx +├── DocumentCreate/ +│ ├── types.ts +│ ├── BasicInfoSection.tsx +│ ├── ApprovalLineSection.tsx +│ ├── ReferenceSection.tsx +│ ├── ProposalForm.tsx +│ ├── ExpenseReportForm.tsx +│ ├── ExpenseEstimateForm.tsx +│ └── index.tsx +└── DocumentDetail/ + ├── types.ts + ├── ApprovalLineBox.tsx + ├── ProposalDocument.tsx + ├── ExpenseReportDocument.tsx + ├── ExpenseEstimateDocument.tsx ✅ + └── index.tsx ✅ + +src/app/[locale]/(protected)/approval/ +├── draft/ +│ ├── page.tsx +│ └── new/ +│ └── page.tsx +``` + +--- + +## 참고 사항 + +### 문서 유형 +1. **품의서** (`proposal`) + - 구매처 정보, 제목, 품의 내역, 품의 사유, 예상 비용, 첨부파일 + +2. **지출결의서** (`expenseReport`) + - 지출 요청일/결제일, 내역 테이블, 법인카드, 총 비용, 첨부파일 + +3. **지출 예상 내역서** (`expenseEstimate`) + - 월별 테이블, 소계, 지출 합계, 계좌 잔액, 최종 차액 + +### 모달 디자인 구조 (sam-design 참고) +- Dialog: `max-w-[95vw] md:max-w-[800px] lg:max-w-[900px] h-[95vh]` +- 헤더: 고정 (`flex-shrink-0`) +- 버튼 영역: 고정 (`flex-shrink-0 bg-muted/30`) +- 문서 영역: 스크롤 (`flex-1 overflow-y-auto bg-gray-100`) +- A4 크기: `max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg p-8` \ No newline at end of file diff --git a/src/app/[locale]/(protected)/approval/draft/new/page.tsx b/src/app/[locale]/(protected)/approval/draft/new/page.tsx new file mode 100644 index 00000000..21147cce --- /dev/null +++ b/src/app/[locale]/(protected)/approval/draft/new/page.tsx @@ -0,0 +1,5 @@ +import { DocumentCreate } from '@/components/approval/DocumentCreate'; + +export default function DocumentCreatePage() { + return ; +} diff --git a/src/app/[locale]/(protected)/approval/draft/page.tsx b/src/app/[locale]/(protected)/approval/draft/page.tsx new file mode 100644 index 00000000..d9eb1bd7 --- /dev/null +++ b/src/app/[locale]/(protected)/approval/draft/page.tsx @@ -0,0 +1,5 @@ +import { DraftBox } from '@/components/approval/DraftBox'; + +export default function DraftBoxPage() { + return ; +} diff --git a/src/app/[locale]/(protected)/approval/inbox/page.tsx b/src/app/[locale]/(protected)/approval/inbox/page.tsx new file mode 100644 index 00000000..b203ba9b --- /dev/null +++ b/src/app/[locale]/(protected)/approval/inbox/page.tsx @@ -0,0 +1,5 @@ +import { ApprovalBox } from '@/components/approval/ApprovalBox'; + +export default function ApprovalInboxPage() { + return ; +} diff --git a/src/app/[locale]/(protected)/approval/reference/page.tsx b/src/app/[locale]/(protected)/approval/reference/page.tsx new file mode 100644 index 00000000..c5af6fc0 --- /dev/null +++ b/src/app/[locale]/(protected)/approval/reference/page.tsx @@ -0,0 +1,5 @@ +import { ReferenceBox } from '@/components/approval/ReferenceBox'; + +export default function ApprovalReferencePage() { + return ; +} \ No newline at end of file diff --git a/src/components/approval/ApprovalBox/index.tsx b/src/components/approval/ApprovalBox/index.tsx new file mode 100644 index 00000000..67b69b2e --- /dev/null +++ b/src/components/approval/ApprovalBox/index.tsx @@ -0,0 +1,609 @@ +'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 = { + 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('all'); + const [searchQuery, setSearchQuery] = useState(''); + const [filterOption, setFilterOption] = useState('all'); + const [sortOption, setSortOption] = useState('latest'); + const [selectedItems, setSelectedItems] = useState>(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(null); + + // Mock 데이터 + const [approvalData] = useState(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 ( + handleDocumentClick(item)} + > + e.stopPropagation()}> + toggleSelection(item.id)} /> + + {globalIndex} + {item.documentNo} + + {APPROVAL_TYPE_LABELS[item.approvalType]} + + {item.title} + {item.drafter} + {item.approver || '-'} + {item.draftDate} + + + {APPROVAL_STATUS_LABELS[item.status]} + + + e.stopPropagation()}> + {isSelected && ( + + )} + + + ); + }, [selectedItems, toggleSelection, handleDocumentClick]); + + // ===== 모바일 카드 렌더링 ===== + const renderMobileCard = useCallback(( + item: ApprovalRecord, + index: number, + globalIndex: number, + isSelected: boolean, + onToggle: () => void + ) => { + return ( + + {APPROVAL_TYPE_LABELS[item.approvalType]} + + {APPROVAL_STATUS_LABELS[item.status]} + + + } + isSelected={isSelected} + onToggleSelection={onToggle} + infoGrid={ +
+ + + + + + +
+ } + actions={ + item.status === 'pending' && ( +
+ + +
+ ) + } + /> + ); + }, [handleApproveClick, handleRejectClick]); + + // ===== 헤더 액션 (DateRangeSelector + 승인/반려 버튼) ===== + const headerActions = ( + 0 && ( + <> + + + + ) + } + /> + ); + + // ===== 테이블 헤더 액션 (필터 + 정렬 셀렉트) ===== + const tableHeaderActions = ( +
+ {/* 필터 셀렉트박스 */} + + + {/* 정렬 셀렉트박스 */} + +
+ ); + + return ( + <> + item.id} + renderTableRow={renderTableRow} + renderMobileCard={renderMobileCard} + pagination={{ + currentPage, + totalPages, + totalItems: filteredData.length, + itemsPerPage, + onPageChange: setCurrentPage, + }} + /> + + {/* 승인 확인 다이얼로그 */} + + + + 결재 승인 + + 정말 {selectedItems.size}건을 승인하시겠습니까? + + + + 취소 + + 승인 + + + + + + {/* 반려 확인 다이얼로그 */} + + + + 결재 반려 + + 정말 {selectedItems.size}건을 반려하시겠습니까? + + + + 취소 + + 반려 + + + + + + {/* 문서 상세 모달 */} + {selectedDocument && ( + + )} + + ); +} \ No newline at end of file diff --git a/src/components/approval/ApprovalBox/types.ts b/src/components/approval/ApprovalBox/types.ts new file mode 100644 index 00000000..c0c545c4 --- /dev/null +++ b/src/components/approval/ApprovalBox/types.ts @@ -0,0 +1,92 @@ +/** + * 결재함 타입 정의 + * 4개 메인 탭: 전체결재, 미결재, 결재완료, 결재반려 + */ + +// ===== 메인 탭 타입 ===== +export type ApprovalTabType = 'all' | 'pending' | 'approved' | 'rejected'; + +// 결재 상태 +export type ApprovalStatus = 'pending' | 'approved' | 'rejected'; + +// 결재 유형 (문서 종류): 지출결의서, 품의서, 지출예상내역서 +export type ApprovalType = 'expense_report' | 'proposal' | 'expense_estimate'; + +// 필터 옵션 +export type FilterOption = 'all' | 'expense_report' | 'proposal' | 'expense_estimate'; + +export const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [ + { value: 'all', label: '전체' }, + { value: 'expense_report', label: '지출결의서' }, + { value: 'proposal', label: '품의서' }, + { value: 'expense_estimate', label: '지출예상내역서' }, +]; + +// 정렬 옵션 +export type SortOption = 'latest' | 'oldest' | 'draftDateAsc' | 'draftDateDesc'; + +export const SORT_OPTIONS: { value: SortOption; label: string }[] = [ + { value: 'latest', label: '최신순' }, + { value: 'oldest', label: '오래된순' }, + { value: 'draftDateAsc', label: '기안일 오름차순' }, + { value: 'draftDateDesc', label: '기안일 내림차순' }, +]; + +// ===== 결재 문서 레코드 ===== +export interface ApprovalRecord { + id: string; + documentNo: string; // 문서번호 + approvalType: ApprovalType; // 결재유형 (휴가, 경비 등) + documentStatus: string; // 문서상태 + title: string; // 제목 + draftDate: string; // 기안일 + drafter: string; // 기안자 + drafterDepartment: string; // 기안자 부서 + drafterPosition: string; // 기안자 직급 + approvalDate?: string; // 결재일 + approver?: string; // 결재자 + status: ApprovalStatus; // 결재 상태 + priority?: 'high' | 'normal' | 'low'; // 우선순위 + createdAt: string; + updatedAt: string; +} + +// ===== 폼 데이터 ===== +export interface ApprovalFormData { + documentId: string; + action: 'approve' | 'reject'; + comment?: string; +} + +// ===== 상수 정의 ===== + +export const APPROVAL_TAB_LABELS: Record = { + all: '전체결재', + pending: '미결재', + approved: '결재완료', + rejected: '결재반려', +}; + +export const APPROVAL_TYPE_LABELS: Record = { + expense_report: '지출결의서', + proposal: '품의서', + expense_estimate: '지출예상내역서', +}; + +export const APPROVAL_TYPE_COLORS: Record = { + expense_report: 'blue', + proposal: 'green', + expense_estimate: 'purple', +}; + +export const APPROVAL_STATUS_LABELS: Record = { + pending: '대기', + approved: '승인', + rejected: '반려', +}; + +export const APPROVAL_STATUS_COLORS: Record = { + pending: 'bg-yellow-100 text-yellow-800', + approved: 'bg-green-100 text-green-800', + rejected: 'bg-red-100 text-red-800', +}; \ No newline at end of file diff --git a/src/components/approval/DocumentCreate/ApprovalLineSection.tsx b/src/components/approval/DocumentCreate/ApprovalLineSection.tsx new file mode 100644 index 00000000..940b204d --- /dev/null +++ b/src/components/approval/DocumentCreate/ApprovalLineSection.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { Plus, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import type { ApprovalPerson } from './types'; +import { MOCK_EMPLOYEES } from './types'; + +interface ApprovalLineSectionProps { + data: ApprovalPerson[]; + onChange: (data: ApprovalPerson[]) => void; +} + +export function ApprovalLineSection({ data, onChange }: ApprovalLineSectionProps) { + const handleAdd = () => { + const newPerson: ApprovalPerson = { + id: `temp-${Date.now()}`, + department: '', + position: '', + name: '', + }; + onChange([...data, newPerson]); + }; + + const handleRemove = (index: number) => { + onChange(data.filter((_, i) => i !== index)); + }; + + const handleChange = (index: number, employeeId: string) => { + const employee = MOCK_EMPLOYEES.find((e) => e.id === employeeId); + if (employee) { + const newData = [...data]; + newData[index] = { ...employee }; + onChange(newData); + } + }; + + return ( +
+
+

결재선

+ +
+ +
+
부서 / 직책 / 이름
+ + {data.length === 0 ? ( +
+ 결재선을 추가해주세요 +
+ ) : ( + data.map((person, index) => ( +
+ {index + 1} + + +
+ )) + )} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/approval/DocumentCreate/BasicInfoSection.tsx b/src/components/approval/DocumentCreate/BasicInfoSection.tsx new file mode 100644 index 00000000..e9acaf44 --- /dev/null +++ b/src/components/approval/DocumentCreate/BasicInfoSection.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import type { BasicInfo, DocumentType } from './types'; +import { DOCUMENT_TYPE_OPTIONS } from './types'; + +interface BasicInfoSectionProps { + data: BasicInfo; + onChange: (data: BasicInfo) => void; +} + +export function BasicInfoSection({ data, onChange }: BasicInfoSectionProps) { + return ( +
+

기본 정보

+ +
+ {/* 기안자 */} +
+ + +
+ + {/* 작성일 */} +
+ + +
+ + {/* 문서번호 */} +
+ + onChange({ ...data, documentNo: e.target.value })} + /> +
+ + {/* 문서유형 */} +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/approval/DocumentCreate/ExpenseEstimateForm.tsx b/src/components/approval/DocumentCreate/ExpenseEstimateForm.tsx new file mode 100644 index 00000000..aa71e559 --- /dev/null +++ b/src/components/approval/DocumentCreate/ExpenseEstimateForm.tsx @@ -0,0 +1,152 @@ +'use client'; + +import { Fragment } from 'react'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import type { ExpenseEstimateData, ExpenseEstimateItem } from './types'; + +interface ExpenseEstimateFormProps { + data: ExpenseEstimateData; + onChange: (data: ExpenseEstimateData) => void; +} + +// Mock 데이터 생성 +const generateMockEstimateItems = (): ExpenseEstimateItem[] => { + return [ + { id: '1', checked: false, expectedPaymentDate: '2025-11-12', category: '통신 서비스', amount: 1000000, vendor: '회사명', memo: '국민 1234 홍길동' }, + { id: '2', checked: false, expectedPaymentDate: '2025-11-12', category: '인건 대행', amount: 1000000, vendor: '회사명', memo: '국민 1234 홍길동' }, + { id: '3', checked: false, expectedPaymentDate: '2025-11-12', category: '통신 서비스', amount: 1000000, vendor: '회사명', memo: '국민 1234 홍길동' }, + { id: '4', checked: false, expectedPaymentDate: '2025-11-12', category: '인건 대행', amount: 1000000, vendor: '회사명', memo: '국민 1234 홍길동' }, + // 11월 소계 후 + { id: '5', checked: false, expectedPaymentDate: '2025-12-12', category: '기타서비스 12월분', amount: 1000000, vendor: '회사명', memo: '국민 1234 홍길동' }, + { id: '6', checked: false, expectedPaymentDate: '2025-12-12', category: '통신 서비스', amount: 1000000, vendor: '회사명', memo: '국민 1234 홍길동' }, + ]; +}; + +export function ExpenseEstimateForm({ data, onChange }: ExpenseEstimateFormProps) { + // Mock 데이터 초기화 + const items = data.items.length > 0 ? data.items : generateMockEstimateItems(); + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('ko-KR').format(amount); + }; + + const handleCheckChange = (id: string, checked: boolean) => { + const newItems = items.map((item) => + item.id === id ? { ...item, checked } : item + ); + const totalExpense = newItems.reduce((sum, item) => sum + item.amount, 0); + onChange({ + ...data, + items: newItems, + totalExpense, + finalDifference: data.accountBalance - totalExpense, + }); + }; + + // 월별 그룹핑 + const groupedByMonth = items.reduce((acc, item) => { + const month = item.expectedPaymentDate.substring(0, 7); // YYYY-MM + if (!acc[month]) { + acc[month] = []; + } + acc[month].push(item); + return acc; + }, {} as Record); + + const getMonthSubtotal = (monthItems: ExpenseEstimateItem[]) => { + return monthItems.reduce((sum, item) => sum + item.amount, 0); + }; + + const totalExpense = items.reduce((sum, item) => sum + item.amount, 0); + const accountBalance = data.accountBalance || 10000000; // Mock 계좌 잔액 + const finalDifference = accountBalance - totalExpense; + + return ( +
+ {/* 지출 예상 내역서 정보 */} +
+

지출 예상 내역서 목록

+ +
+ + + + + 예상 지급일 + 항목 + 지출금액 + 거래처 + 적록 + + + + {Object.entries(groupedByMonth).map(([month, monthItems]) => ( + + {monthItems.map((item) => ( + + + handleCheckChange(item.id, !!checked)} + /> + + {item.expectedPaymentDate} + {item.category} + + {formatCurrency(item.amount)} + + {item.vendor} + {item.memo} + + ))} + {/* 월별 소계 */} + + + {month.replace('-', '년 ')}월 계 + + + + {formatCurrency(getMonthSubtotal(monthItems))} + + + + + ))} + + {/* 합계 행들 */} + + 지출 합계 + + {formatCurrency(totalExpense)} + + + + + 계좌 잔액 + + {formatCurrency(accountBalance)} + + + + + 최종 차액 + = 0 ? 'text-blue-600' : 'text-red-600'}`}> + {formatCurrency(finalDifference)} + + + + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/approval/DocumentCreate/ExpenseReportForm.tsx b/src/components/approval/DocumentCreate/ExpenseReportForm.tsx new file mode 100644 index 00000000..e458cfa6 --- /dev/null +++ b/src/components/approval/DocumentCreate/ExpenseReportForm.tsx @@ -0,0 +1,242 @@ +'use client'; + +import { useRef } from 'react'; +import { Plus, X, Upload } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import type { ExpenseReportData, ExpenseReportItem } from './types'; +import { CARD_OPTIONS } from './types'; + +interface ExpenseReportFormProps { + data: ExpenseReportData; + onChange: (data: ExpenseReportData) => void; +} + +export function ExpenseReportForm({ data, onChange }: ExpenseReportFormProps) { + const fileInputRef = useRef(null); + + const handleAddItem = () => { + const newItem: ExpenseReportItem = { + id: `item-${Date.now()}`, + description: '', + amount: 0, + note: '', + }; + onChange({ ...data, items: [...data.items, newItem] }); + }; + + const handleRemoveItem = (index: number) => { + const newItems = data.items.filter((_, i) => i !== index); + const totalAmount = newItems.reduce((sum, item) => sum + item.amount, 0); + onChange({ ...data, items: newItems, totalAmount }); + }; + + const handleItemChange = (index: number, field: keyof ExpenseReportItem, value: string | number) => { + const newItems = [...data.items]; + newItems[index] = { ...newItems[index], [field]: value }; + const totalAmount = newItems.reduce((sum, item) => sum + item.amount, 0); + onChange({ ...data, items: newItems, totalAmount }); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const files = e.target.files; + if (files) { + onChange({ ...data, attachments: [...data.attachments, ...Array.from(files)] }); + } + }; + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('ko-KR').format(amount); + }; + + return ( +
+ {/* 지출 정보 */} +
+

지출 정보

+ +
+
+ + onChange({ ...data, requestDate: e.target.value })} + /> +
+ +
+ + onChange({ ...data, paymentDate: e.target.value })} + /> +
+
+
+ + {/* 지출결의서 정보 */} +
+
+

지출결의서 정보

+ +
+ +
+ + + + 번호 + 적요 + 금액 + 비고 + 삭제 + + + + {data.items.length === 0 ? ( + + + 항목을 추가해주세요 + + + ) : ( + data.items.map((item, index) => ( + + {index + 1} + + handleItemChange(index, 'description', e.target.value)} + /> + + + handleItemChange(index, 'amount', Number(e.target.value) || 0)} + /> + + + handleItemChange(index, 'note', e.target.value)} + /> + + + + + + )) + )} + +
+
+
+ + {/* 결제 정보 */} +
+

결제 정보

+ +
+
+ + +
+ +
+ +
+ {formatCurrency(data.totalAmount)}원 +
+
+
+
+ + {/* 참고 이미지 정보 */} +
+

참고 이미지 정보

+ +
+ +
+ f.name).join(', ')} + className="flex-1" + /> + + +
+ {data.attachments.length > 0 && ( +
+ {data.attachments.length}개 파일 선택됨 +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/approval/DocumentCreate/ProposalForm.tsx b/src/components/approval/DocumentCreate/ProposalForm.tsx new file mode 100644 index 00000000..2ba7b5ce --- /dev/null +++ b/src/components/approval/DocumentCreate/ProposalForm.tsx @@ -0,0 +1,173 @@ +'use client'; + +import { useRef } from 'react'; +import { Mic, Upload } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import type { ProposalData } from './types'; + +interface ProposalFormProps { + data: ProposalData; + onChange: (data: ProposalData) => void; +} + +export function ProposalForm({ data, onChange }: ProposalFormProps) { + const fileInputRef = useRef(null); + + const handleFileChange = (e: React.ChangeEvent) => { + const files = e.target.files; + if (files) { + onChange({ ...data, attachments: [...data.attachments, ...Array.from(files)] }); + } + }; + + return ( +
+ {/* 구매처 정보 */} +
+

구매처 정보

+ +
+
+ + onChange({ ...data, vendor: e.target.value })} + /> +
+ +
+ + onChange({ ...data, vendorPaymentDate: e.target.value })} + /> +
+
+
+ + {/* 품의서 정보 */} +
+

품의서 정보

+ +
+ {/* 제목 */} +
+ + onChange({ ...data, title: e.target.value })} + /> +
+ + {/* 품의 내역 */} +
+ +
+