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:
@@ -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`
|
||||||
5
src/app/[locale]/(protected)/approval/draft/new/page.tsx
Normal file
5
src/app/[locale]/(protected)/approval/draft/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { DocumentCreate } from '@/components/approval/DocumentCreate';
|
||||||
|
|
||||||
|
export default function DocumentCreatePage() {
|
||||||
|
return <DocumentCreate />;
|
||||||
|
}
|
||||||
5
src/app/[locale]/(protected)/approval/draft/page.tsx
Normal file
5
src/app/[locale]/(protected)/approval/draft/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { DraftBox } from '@/components/approval/DraftBox';
|
||||||
|
|
||||||
|
export default function DraftBoxPage() {
|
||||||
|
return <DraftBox />;
|
||||||
|
}
|
||||||
5
src/app/[locale]/(protected)/approval/inbox/page.tsx
Normal file
5
src/app/[locale]/(protected)/approval/inbox/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ApprovalBox } from '@/components/approval/ApprovalBox';
|
||||||
|
|
||||||
|
export default function ApprovalInboxPage() {
|
||||||
|
return <ApprovalBox />;
|
||||||
|
}
|
||||||
5
src/app/[locale]/(protected)/approval/reference/page.tsx
Normal file
5
src/app/[locale]/(protected)/approval/reference/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ReferenceBox } from '@/components/approval/ReferenceBox';
|
||||||
|
|
||||||
|
export default function ApprovalReferencePage() {
|
||||||
|
return <ReferenceBox />;
|
||||||
|
}
|
||||||
609
src/components/approval/ApprovalBox/index.tsx
Normal file
609
src/components/approval/ApprovalBox/index.tsx
Normal file
@@ -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<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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/components/approval/ApprovalBox/types.ts
Normal file
92
src/components/approval/ApprovalBox/types.ts
Normal file
@@ -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<ApprovalTabType, string> = {
|
||||||
|
all: '전체결재',
|
||||||
|
pending: '미결재',
|
||||||
|
approved: '결재완료',
|
||||||
|
rejected: '결재반려',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const APPROVAL_TYPE_LABELS: Record<ApprovalType, string> = {
|
||||||
|
expense_report: '지출결의서',
|
||||||
|
proposal: '품의서',
|
||||||
|
expense_estimate: '지출예상내역서',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const APPROVAL_TYPE_COLORS: Record<ApprovalType, string> = {
|
||||||
|
expense_report: 'blue',
|
||||||
|
proposal: 'green',
|
||||||
|
expense_estimate: 'purple',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const APPROVAL_STATUS_LABELS: Record<ApprovalStatus, string> = {
|
||||||
|
pending: '대기',
|
||||||
|
approved: '승인',
|
||||||
|
rejected: '반려',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const APPROVAL_STATUS_COLORS: Record<ApprovalStatus, string> = {
|
||||||
|
pending: 'bg-yellow-100 text-yellow-800',
|
||||||
|
approved: 'bg-green-100 text-green-800',
|
||||||
|
rejected: 'bg-red-100 text-red-800',
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<div className="bg-white rounded-lg border p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold">결재선</h3>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleAdd}>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-sm text-gray-500 mb-2">부서 / 직책 / 이름</div>
|
||||||
|
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<div className="text-center py-4 text-gray-400">
|
||||||
|
결재선을 추가해주세요
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
data.map((person, index) => (
|
||||||
|
<div key={person.id} className="flex items-center gap-2">
|
||||||
|
<span className="w-8 text-center text-sm text-gray-500">{index + 1}</span>
|
||||||
|
<Select
|
||||||
|
value={person.id.startsWith('temp-') ? '' : person.id}
|
||||||
|
onValueChange={(value) => handleChange(index, value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="flex-1">
|
||||||
|
<SelectValue placeholder="부서명 / 직책명 / 이름 ▼" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{MOCK_EMPLOYEES.map((employee) => (
|
||||||
|
<SelectItem key={employee.id} value={employee.id}>
|
||||||
|
{employee.department} / {employee.position} / {employee.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9 text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||||
|
onClick={() => handleRemove(index)}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/components/approval/DocumentCreate/BasicInfoSection.tsx
Normal file
81
src/components/approval/DocumentCreate/BasicInfoSection.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="bg-white rounded-lg border p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">기본 정보</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* 기안자 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="drafter">기안자</Label>
|
||||||
|
<Input
|
||||||
|
id="drafter"
|
||||||
|
value={data.drafter}
|
||||||
|
disabled
|
||||||
|
className="bg-gray-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 작성일 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="draftDate">작성일</Label>
|
||||||
|
<Input
|
||||||
|
id="draftDate"
|
||||||
|
value={data.draftDate}
|
||||||
|
disabled
|
||||||
|
className="bg-gray-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 문서번호 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="documentNo">문서번호</Label>
|
||||||
|
<Input
|
||||||
|
id="documentNo"
|
||||||
|
placeholder="문서번호를 입력해주세요"
|
||||||
|
value={data.documentNo}
|
||||||
|
onChange={(e) => onChange({ ...data, documentNo: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 문서유형 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="documentType">문서유형</Label>
|
||||||
|
<Select
|
||||||
|
value={data.documentType}
|
||||||
|
onValueChange={(value) => onChange({ ...data, documentType: value as DocumentType })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="문서유형 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DOCUMENT_TYPE_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
src/components/approval/DocumentCreate/ExpenseEstimateForm.tsx
Normal file
152
src/components/approval/DocumentCreate/ExpenseEstimateForm.tsx
Normal file
@@ -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<string, ExpenseEstimateItem[]>);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 지출 예상 내역서 정보 */}
|
||||||
|
<div className="bg-white rounded-lg border p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">지출 예상 내역서 목록</h3>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[50px] text-center"></TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">예상 지급일</TableHead>
|
||||||
|
<TableHead className="min-w-[150px]">항목</TableHead>
|
||||||
|
<TableHead className="min-w-[120px] text-right">지출금액</TableHead>
|
||||||
|
<TableHead className="min-w-[100px]">거래처</TableHead>
|
||||||
|
<TableHead className="min-w-[150px]">적록</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{Object.entries(groupedByMonth).map(([month, monthItems]) => (
|
||||||
|
<Fragment key={month}>
|
||||||
|
{monthItems.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Checkbox
|
||||||
|
checked={item.checked}
|
||||||
|
onCheckedChange={(checked) => handleCheckChange(item.id, !!checked)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{item.expectedPaymentDate}</TableCell>
|
||||||
|
<TableCell>{item.category}</TableCell>
|
||||||
|
<TableCell className="text-right text-blue-600 font-medium">
|
||||||
|
{formatCurrency(item.amount)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{item.vendor}</TableCell>
|
||||||
|
<TableCell>{item.memo}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{/* 월별 소계 */}
|
||||||
|
<TableRow className="bg-pink-50">
|
||||||
|
<TableCell colSpan={2} className="font-medium">
|
||||||
|
{month.replace('-', '년 ')}월 계
|
||||||
|
</TableCell>
|
||||||
|
<TableCell></TableCell>
|
||||||
|
<TableCell className="text-right text-red-600 font-bold">
|
||||||
|
{formatCurrency(getMonthSubtotal(monthItems))}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell colSpan={2}></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 합계 행들 */}
|
||||||
|
<TableRow className="bg-gray-50 border-t-2">
|
||||||
|
<TableCell colSpan={3} className="font-semibold">지출 합계</TableCell>
|
||||||
|
<TableCell className="text-right text-red-600 font-bold">
|
||||||
|
{formatCurrency(totalExpense)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell colSpan={2}></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow className="bg-gray-50">
|
||||||
|
<TableCell colSpan={3} className="font-semibold">계좌 잔액</TableCell>
|
||||||
|
<TableCell className="text-right font-bold">
|
||||||
|
{formatCurrency(accountBalance)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell colSpan={2}></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow className="bg-gray-50">
|
||||||
|
<TableCell colSpan={3} className="font-semibold">최종 차액</TableCell>
|
||||||
|
<TableCell className={`text-right font-bold ${finalDifference >= 0 ? 'text-blue-600' : 'text-red-600'}`}>
|
||||||
|
{formatCurrency(finalDifference)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell colSpan={2}></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
242
src/components/approval/DocumentCreate/ExpenseReportForm.tsx
Normal file
242
src/components/approval/DocumentCreate/ExpenseReportForm.tsx
Normal file
@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 지출 정보 */}
|
||||||
|
<div className="bg-white rounded-lg border p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">지출 정보</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="requestDate">지출 요청일</Label>
|
||||||
|
<Input
|
||||||
|
id="requestDate"
|
||||||
|
type="date"
|
||||||
|
value={data.requestDate}
|
||||||
|
onChange={(e) => onChange({ ...data, requestDate: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="paymentDate">결제일</Label>
|
||||||
|
<Input
|
||||||
|
id="paymentDate"
|
||||||
|
type="date"
|
||||||
|
value={data.paymentDate}
|
||||||
|
onChange={(e) => onChange({ ...data, paymentDate: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 지출결의서 정보 */}
|
||||||
|
<div className="bg-white rounded-lg border p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold">지출결의서 정보</h3>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleAddItem}>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[60px] text-center">번호</TableHead>
|
||||||
|
<TableHead className="min-w-[200px]">적요</TableHead>
|
||||||
|
<TableHead className="min-w-[150px]">금액</TableHead>
|
||||||
|
<TableHead className="min-w-[150px]">비고</TableHead>
|
||||||
|
<TableHead className="w-[60px] text-center">삭제</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.items.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center py-8 text-gray-400">
|
||||||
|
항목을 추가해주세요
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
data.items.map((item, index) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell className="text-center">{index + 1}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Input
|
||||||
|
placeholder="적요를 입력해주세요"
|
||||||
|
value={item.description}
|
||||||
|
onChange={(e) => handleItemChange(index, 'description', e.target.value)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="금액을 입력해주세요"
|
||||||
|
value={item.amount || ''}
|
||||||
|
onChange={(e) => handleItemChange(index, 'amount', Number(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Input
|
||||||
|
placeholder="비고를 입력해주세요"
|
||||||
|
value={item.note}
|
||||||
|
onChange={(e) => handleItemChange(index, 'note', e.target.value)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||||
|
onClick={() => handleRemoveItem(index)}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 결제 정보 */}
|
||||||
|
<div className="bg-white rounded-lg border p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">결제 정보</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="card">카드</Label>
|
||||||
|
<Select
|
||||||
|
value={data.cardId}
|
||||||
|
onValueChange={(value) => onChange({ ...data, cardId: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="카드를 선택해주세요" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CARD_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>총 비용</Label>
|
||||||
|
<div className="h-10 px-3 py-2 bg-gray-50 border rounded-md text-right font-semibold">
|
||||||
|
{formatCurrency(data.totalAmount)}원
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 참고 이미지 정보 */}
|
||||||
|
<div className="bg-white rounded-lg border p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">참고 이미지 정보</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>첨부파일</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
placeholder="파일을 선택해주세요"
|
||||||
|
value={data.attachments.map((f) => f.name).join(', ')}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
accept="image/*"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
찾기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{data.attachments.length > 0 && (
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{data.attachments.length}개 파일 선택됨
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
src/components/approval/DocumentCreate/ProposalForm.tsx
Normal file
173
src/components/approval/DocumentCreate/ProposalForm.tsx
Normal file
@@ -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<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (files) {
|
||||||
|
onChange({ ...data, attachments: [...data.attachments, ...Array.from(files)] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 구매처 정보 */}
|
||||||
|
<div className="bg-white rounded-lg border p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">구매처 정보</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="vendor">구매처</Label>
|
||||||
|
<Input
|
||||||
|
id="vendor"
|
||||||
|
placeholder="구매처를 입력해주세요"
|
||||||
|
value={data.vendor}
|
||||||
|
onChange={(e) => onChange({ ...data, vendor: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="vendorPaymentDate">구매처 결제일</Label>
|
||||||
|
<Input
|
||||||
|
id="vendorPaymentDate"
|
||||||
|
type="date"
|
||||||
|
value={data.vendorPaymentDate}
|
||||||
|
onChange={(e) => onChange({ ...data, vendorPaymentDate: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 품의서 정보 */}
|
||||||
|
<div className="bg-white rounded-lg border p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">품의서 정보</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 제목 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">제목</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
placeholder="제목을 입력해주세요"
|
||||||
|
value={data.title}
|
||||||
|
onChange={(e) => onChange({ ...data, title: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 품의 내역 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">품의 내역</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
placeholder="품의 내역을 입력해주세요"
|
||||||
|
value={data.description}
|
||||||
|
onChange={(e) => onChange({ ...data, description: e.target.value })}
|
||||||
|
className="min-h-[100px] pr-12"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="absolute right-2 bottom-2"
|
||||||
|
title="녹음"
|
||||||
|
>
|
||||||
|
<Mic className="w-4 h-4" />
|
||||||
|
녹음
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 품의 사유 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="reason">품의 사유</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Textarea
|
||||||
|
id="reason"
|
||||||
|
placeholder="품의 사유를 입력해주세요"
|
||||||
|
value={data.reason}
|
||||||
|
onChange={(e) => onChange({ ...data, reason: e.target.value })}
|
||||||
|
className="min-h-[100px] pr-12"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="absolute right-2 bottom-2"
|
||||||
|
title="녹음"
|
||||||
|
>
|
||||||
|
<Mic className="w-4 h-4" />
|
||||||
|
녹음
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 예상 비용 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="estimatedCost">예상 비용</Label>
|
||||||
|
<Input
|
||||||
|
id="estimatedCost"
|
||||||
|
type="number"
|
||||||
|
placeholder="금액을 입력해주세요"
|
||||||
|
value={data.estimatedCost || ''}
|
||||||
|
onChange={(e) => onChange({ ...data, estimatedCost: Number(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 참고 이미지 정보 */}
|
||||||
|
<div className="bg-white rounded-lg border p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">참고 이미지 정보</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>첨부파일</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
placeholder="파일을 선택해주세요"
|
||||||
|
value={data.attachments.map((f) => f.name).join(', ')}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
accept="image/*"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
찾기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{data.attachments.length > 0 && (
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{data.attachments.length}개 파일 선택됨
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
src/components/approval/DocumentCreate/ReferenceSection.tsx
Normal file
94
src/components/approval/DocumentCreate/ReferenceSection.tsx
Normal file
@@ -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 ReferenceSectionProps {
|
||||||
|
data: ApprovalPerson[];
|
||||||
|
onChange: (data: ApprovalPerson[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReferenceSection({ data, onChange }: ReferenceSectionProps) {
|
||||||
|
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 (
|
||||||
|
<div className="bg-white rounded-lg border p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold">참조</h3>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleAdd}>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-sm text-gray-500 mb-2">부서 / 직책 / 이름</div>
|
||||||
|
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<div className="text-center py-4 text-gray-400">
|
||||||
|
참조자를 추가해주세요
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
data.map((person, index) => (
|
||||||
|
<div key={person.id} className="flex items-center gap-2">
|
||||||
|
<span className="w-8 text-center text-sm text-gray-500">{index + 1}</span>
|
||||||
|
<Select
|
||||||
|
value={person.id.startsWith('temp-') ? '' : person.id}
|
||||||
|
onValueChange={(value) => handleChange(index, value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="flex-1">
|
||||||
|
<SelectValue placeholder="부서명 / 직책명 / 이름 ▼" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{MOCK_EMPLOYEES.map((employee) => (
|
||||||
|
<SelectItem key={employee.id} value={employee.id}>
|
||||||
|
{employee.department} / {employee.position} / {employee.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9 text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||||
|
onClick={() => handleRemove(index)}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
293
src/components/approval/DocumentCreate/index.tsx
Normal file
293
src/components/approval/DocumentCreate/index.tsx
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { FileText, Trash2, Send, Save, ArrowLeft, Eye } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import { BasicInfoSection } from './BasicInfoSection';
|
||||||
|
import { ApprovalLineSection } from './ApprovalLineSection';
|
||||||
|
import { ReferenceSection } from './ReferenceSection';
|
||||||
|
import { ProposalForm } from './ProposalForm';
|
||||||
|
import { ExpenseReportForm } from './ExpenseReportForm';
|
||||||
|
import { ExpenseEstimateForm } from './ExpenseEstimateForm';
|
||||||
|
import { DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
||||||
|
import type {
|
||||||
|
DocumentType as ModalDocumentType,
|
||||||
|
ProposalDocumentData,
|
||||||
|
ExpenseReportDocumentData,
|
||||||
|
ExpenseEstimateDocumentData,
|
||||||
|
} from '@/components/approval/DocumentDetail/types';
|
||||||
|
import type {
|
||||||
|
DocumentType,
|
||||||
|
BasicInfo,
|
||||||
|
ApprovalPerson,
|
||||||
|
ProposalData,
|
||||||
|
ExpenseReportData,
|
||||||
|
ExpenseEstimateData,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// 초기 데이터
|
||||||
|
const getInitialBasicInfo = (): BasicInfo => ({
|
||||||
|
drafter: '홍길동',
|
||||||
|
draftDate: format(new Date(), 'yyyy-MM-dd HH:mm'),
|
||||||
|
documentNo: '',
|
||||||
|
documentType: 'proposal',
|
||||||
|
});
|
||||||
|
|
||||||
|
const getInitialProposalData = (): ProposalData => ({
|
||||||
|
vendor: '',
|
||||||
|
vendorPaymentDate: format(new Date(), 'yyyy-MM-dd'),
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
reason: '',
|
||||||
|
estimatedCost: 0,
|
||||||
|
attachments: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const getInitialExpenseReportData = (): ExpenseReportData => ({
|
||||||
|
requestDate: format(new Date(), 'yyyy-MM-dd'),
|
||||||
|
paymentDate: format(new Date(), 'yyyy-MM-dd'),
|
||||||
|
items: [],
|
||||||
|
cardId: '',
|
||||||
|
totalAmount: 0,
|
||||||
|
attachments: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const getInitialExpenseEstimateData = (): ExpenseEstimateData => ({
|
||||||
|
items: [],
|
||||||
|
totalExpense: 0,
|
||||||
|
accountBalance: 10000000,
|
||||||
|
finalDifference: 10000000,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function DocumentCreate() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// 상태 관리
|
||||||
|
const [basicInfo, setBasicInfo] = useState<BasicInfo>(getInitialBasicInfo);
|
||||||
|
const [approvalLine, setApprovalLine] = useState<ApprovalPerson[]>([]);
|
||||||
|
const [references, setReferences] = useState<ApprovalPerson[]>([]);
|
||||||
|
const [proposalData, setProposalData] = useState<ProposalData>(getInitialProposalData);
|
||||||
|
const [expenseReportData, setExpenseReportData] = useState<ExpenseReportData>(getInitialExpenseReportData);
|
||||||
|
const [expenseEstimateData, setExpenseEstimateData] = useState<ExpenseEstimateData>(getInitialExpenseEstimateData);
|
||||||
|
|
||||||
|
// 미리보기 모달 상태
|
||||||
|
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||||
|
|
||||||
|
// 핸들러
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
router.back();
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
if (confirm('작성 중인 문서를 삭제하시겠습니까?')) {
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
console.log('상신:', {
|
||||||
|
basicInfo,
|
||||||
|
approvalLine,
|
||||||
|
references,
|
||||||
|
proposalData: basicInfo.documentType === 'proposal' ? proposalData : undefined,
|
||||||
|
expenseReportData: basicInfo.documentType === 'expenseReport' ? expenseReportData : undefined,
|
||||||
|
expenseEstimateData: basicInfo.documentType === 'expenseEstimate' ? expenseEstimateData : undefined,
|
||||||
|
});
|
||||||
|
alert('문서가 상신되었습니다.');
|
||||||
|
router.back();
|
||||||
|
}, [basicInfo, approvalLine, references, proposalData, expenseReportData, expenseEstimateData, router]);
|
||||||
|
|
||||||
|
const handleSaveDraft = useCallback(() => {
|
||||||
|
console.log('임시저장:', {
|
||||||
|
basicInfo,
|
||||||
|
approvalLine,
|
||||||
|
references,
|
||||||
|
proposalData: basicInfo.documentType === 'proposal' ? proposalData : undefined,
|
||||||
|
expenseReportData: basicInfo.documentType === 'expenseReport' ? expenseReportData : undefined,
|
||||||
|
expenseEstimateData: basicInfo.documentType === 'expenseEstimate' ? expenseEstimateData : undefined,
|
||||||
|
});
|
||||||
|
alert('임시저장되었습니다.');
|
||||||
|
}, [basicInfo, approvalLine, references, proposalData, expenseReportData, expenseEstimateData]);
|
||||||
|
|
||||||
|
// 미리보기 핸들러
|
||||||
|
const handlePreview = useCallback(() => {
|
||||||
|
setIsPreviewOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 미리보기용 데이터 변환
|
||||||
|
const getPreviewData = useCallback((): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData => {
|
||||||
|
const drafter = {
|
||||||
|
id: 'drafter-1',
|
||||||
|
name: basicInfo.drafter,
|
||||||
|
position: '사원',
|
||||||
|
department: '개발팀',
|
||||||
|
status: 'approved' as const,
|
||||||
|
};
|
||||||
|
const approvers = approvalLine.map((a, index) => ({
|
||||||
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
position: a.position,
|
||||||
|
department: a.department,
|
||||||
|
status: (index === 0 ? 'pending' : 'none') as 'pending' | 'approved' | 'rejected' | 'none',
|
||||||
|
}));
|
||||||
|
|
||||||
|
switch (basicInfo.documentType) {
|
||||||
|
case 'expenseEstimate':
|
||||||
|
return {
|
||||||
|
documentNo: basicInfo.documentNo || '미발급',
|
||||||
|
createdAt: basicInfo.draftDate,
|
||||||
|
items: expenseEstimateData.items.map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
expectedPaymentDate: item.expectedPaymentDate,
|
||||||
|
category: item.category,
|
||||||
|
amount: item.amount,
|
||||||
|
vendor: item.vendor,
|
||||||
|
account: item.memo || '',
|
||||||
|
})),
|
||||||
|
totalExpense: expenseEstimateData.totalExpense,
|
||||||
|
accountBalance: expenseEstimateData.accountBalance,
|
||||||
|
finalDifference: expenseEstimateData.finalDifference,
|
||||||
|
approvers,
|
||||||
|
drafter,
|
||||||
|
};
|
||||||
|
case 'expenseReport':
|
||||||
|
return {
|
||||||
|
documentNo: basicInfo.documentNo || '미발급',
|
||||||
|
createdAt: basicInfo.draftDate,
|
||||||
|
requestDate: expenseReportData.requestDate,
|
||||||
|
paymentDate: expenseReportData.paymentDate,
|
||||||
|
items: expenseReportData.items.map((item, index) => ({
|
||||||
|
id: item.id,
|
||||||
|
no: index + 1,
|
||||||
|
description: item.description,
|
||||||
|
amount: item.amount,
|
||||||
|
note: item.note,
|
||||||
|
})),
|
||||||
|
cardInfo: expenseReportData.cardId || '-',
|
||||||
|
totalAmount: expenseReportData.totalAmount,
|
||||||
|
attachments: expenseReportData.attachments,
|
||||||
|
approvers,
|
||||||
|
drafter,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
documentNo: basicInfo.documentNo || '미발급',
|
||||||
|
createdAt: basicInfo.draftDate,
|
||||||
|
vendor: proposalData.vendor || '-',
|
||||||
|
vendorPaymentDate: proposalData.vendorPaymentDate,
|
||||||
|
title: proposalData.title || '(제목 없음)',
|
||||||
|
description: proposalData.description || '-',
|
||||||
|
reason: proposalData.reason || '-',
|
||||||
|
estimatedCost: proposalData.estimatedCost,
|
||||||
|
attachments: proposalData.attachments,
|
||||||
|
approvers,
|
||||||
|
drafter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [basicInfo, approvalLine, proposalData, expenseReportData, expenseEstimateData]);
|
||||||
|
|
||||||
|
// 문서 유형별 폼 렌더링
|
||||||
|
const renderDocumentTypeForm = () => {
|
||||||
|
switch (basicInfo.documentType) {
|
||||||
|
case 'proposal':
|
||||||
|
return <ProposalForm data={proposalData} onChange={setProposalData} />;
|
||||||
|
case 'expenseReport':
|
||||||
|
return <ExpenseReportForm data={expenseReportData} onChange={setExpenseReportData} />;
|
||||||
|
case 'expenseEstimate':
|
||||||
|
return <ExpenseEstimateForm data={expenseEstimateData} onChange={setExpenseEstimateData} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-6 px-4 max-w-4xl">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="ghost" size="icon" onClick={handleBack}>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="h-6 w-6 text-primary" />
|
||||||
|
<div>
|
||||||
|
<CardTitle>문서 작성</CardTitle>
|
||||||
|
<CardDescription>새로운 문서를 작성합니다</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 액션 버튼 (스텝) */}
|
||||||
|
<div className="flex items-center justify-center gap-2 mb-6">
|
||||||
|
<Button variant="outline" className="min-w-[80px]" onClick={handlePreview}>
|
||||||
|
<Eye className="w-4 h-4 mr-1" />
|
||||||
|
상세
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="min-w-[80px]" onClick={handleDelete}>
|
||||||
|
<Trash2 className="w-4 h-4 mr-1" />
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
<Button variant="default" className="min-w-[80px]" onClick={handleSubmit}>
|
||||||
|
<Send className="w-4 h-4 mr-1" />
|
||||||
|
상신
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" className="min-w-[80px]" onClick={handleSaveDraft}>
|
||||||
|
<Save className="w-4 h-4 mr-1" />
|
||||||
|
임시저장
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 폼 영역 */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 기본 정보 */}
|
||||||
|
<BasicInfoSection data={basicInfo} onChange={setBasicInfo} />
|
||||||
|
|
||||||
|
{/* 결재선 */}
|
||||||
|
<ApprovalLineSection data={approvalLine} onChange={setApprovalLine} />
|
||||||
|
|
||||||
|
{/* 참조 */}
|
||||||
|
<ReferenceSection data={references} onChange={setReferences} />
|
||||||
|
|
||||||
|
{/* 문서 유형별 폼 */}
|
||||||
|
{renderDocumentTypeForm()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 고정 버튼 (모바일) */}
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 p-4 bg-white border-t md:hidden">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" className="flex-1" onClick={handleDelete}>
|
||||||
|
<Trash2 className="w-4 h-4 mr-1" />
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" className="flex-1" onClick={handleSaveDraft}>
|
||||||
|
<Save className="w-4 h-4 mr-1" />
|
||||||
|
임시저장
|
||||||
|
</Button>
|
||||||
|
<Button variant="default" className="flex-1" onClick={handleSubmit}>
|
||||||
|
<Send className="w-4 h-4 mr-1" />
|
||||||
|
상신
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모바일 하단 여백 */}
|
||||||
|
<div className="h-20 md:hidden" />
|
||||||
|
|
||||||
|
{/* 미리보기 모달 */}
|
||||||
|
<DocumentDetailModal
|
||||||
|
open={isPreviewOpen}
|
||||||
|
onOpenChange={setIsPreviewOpen}
|
||||||
|
documentType={basicInfo.documentType as ModalDocumentType}
|
||||||
|
data={getPreviewData()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
src/components/approval/DocumentCreate/types.ts
Normal file
100
src/components/approval/DocumentCreate/types.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
// ===== 문서 작성 타입 정의 =====
|
||||||
|
|
||||||
|
// 문서 유형
|
||||||
|
export type DocumentType = 'proposal' | 'expenseReport' | 'expenseEstimate';
|
||||||
|
|
||||||
|
export const DOCUMENT_TYPE_OPTIONS: { value: DocumentType; label: string }[] = [
|
||||||
|
{ value: 'proposal', label: '품의서' },
|
||||||
|
{ value: 'expenseReport', label: '지출결의서' },
|
||||||
|
{ value: 'expenseEstimate', label: '지출 예상 내역서' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 결재자/참조자 정보
|
||||||
|
export interface ApprovalPerson {
|
||||||
|
id: string;
|
||||||
|
department: string;
|
||||||
|
position: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본 정보
|
||||||
|
export interface BasicInfo {
|
||||||
|
drafter: string;
|
||||||
|
draftDate: string;
|
||||||
|
documentNo: string;
|
||||||
|
documentType: DocumentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 품의서 데이터
|
||||||
|
export interface ProposalData {
|
||||||
|
vendor: string;
|
||||||
|
vendorPaymentDate: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
reason: string;
|
||||||
|
estimatedCost: number;
|
||||||
|
attachments: File[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 지출결의서 항목
|
||||||
|
export interface ExpenseReportItem {
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 지출결의서 데이터
|
||||||
|
export interface ExpenseReportData {
|
||||||
|
requestDate: string;
|
||||||
|
paymentDate: string;
|
||||||
|
items: ExpenseReportItem[];
|
||||||
|
cardId: string;
|
||||||
|
totalAmount: number;
|
||||||
|
attachments: File[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 지출 예상 내역서 항목
|
||||||
|
export interface ExpenseEstimateItem {
|
||||||
|
id: string;
|
||||||
|
checked: boolean;
|
||||||
|
expectedPaymentDate: string;
|
||||||
|
category: string;
|
||||||
|
amount: number;
|
||||||
|
vendor: string;
|
||||||
|
memo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 지출 예상 내역서 데이터
|
||||||
|
export interface ExpenseEstimateData {
|
||||||
|
items: ExpenseEstimateItem[];
|
||||||
|
totalExpense: number;
|
||||||
|
accountBalance: number;
|
||||||
|
finalDifference: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전체 문서 데이터
|
||||||
|
export interface DocumentFormData {
|
||||||
|
basicInfo: BasicInfo;
|
||||||
|
approvalLine: ApprovalPerson[];
|
||||||
|
references: ApprovalPerson[];
|
||||||
|
proposalData?: ProposalData;
|
||||||
|
expenseReportData?: ExpenseReportData;
|
||||||
|
expenseEstimateData?: ExpenseEstimateData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카드 옵션
|
||||||
|
export const CARD_OPTIONS = [
|
||||||
|
{ value: 'ibk-1234', label: 'IBK기업카드_1234 (카드명)' },
|
||||||
|
{ value: 'shinhan-5678', label: '신한카드_5678 (카드명)' },
|
||||||
|
{ value: 'kb-9012', label: 'KB국민카드_9012 (카드명)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock 사원 데이터
|
||||||
|
export const MOCK_EMPLOYEES: ApprovalPerson[] = [
|
||||||
|
{ id: '1', department: '개발팀', position: '팀장', name: '김철수' },
|
||||||
|
{ id: '2', department: '개발팀', position: '부장', name: '이영희' },
|
||||||
|
{ id: '3', department: '인사팀', position: '팀장', name: '박민수' },
|
||||||
|
{ id: '4', department: '경영지원팀', position: '이사', name: '정수진' },
|
||||||
|
{ id: '5', department: '영업팀', position: '대표', name: '최동현' },
|
||||||
|
];
|
||||||
85
src/components/approval/DocumentDetail/ApprovalLineBox.tsx
Normal file
85
src/components/approval/DocumentDetail/ApprovalLineBox.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { CheckCircle, XCircle, Clock } from 'lucide-react';
|
||||||
|
import type { Approver } from './types';
|
||||||
|
|
||||||
|
interface ApprovalLineBoxProps {
|
||||||
|
drafter: Approver;
|
||||||
|
approvers: Approver[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApprovalLineBox({ drafter, approvers }: ApprovalLineBoxProps) {
|
||||||
|
const getStatusIcon = (status: Approver['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'approved':
|
||||||
|
return <CheckCircle className="w-3 h-3 text-green-600" />;
|
||||||
|
case 'rejected':
|
||||||
|
return <XCircle className="w-3 h-3 text-red-600" />;
|
||||||
|
case 'pending':
|
||||||
|
return <Clock className="w-3 h-3 text-yellow-600" />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-300 text-xs">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="grid grid-cols-[60px_1fr] border-b border-gray-300">
|
||||||
|
<div className="bg-gray-100 p-1.5 text-center font-medium border-r border-gray-300">
|
||||||
|
구분
|
||||||
|
</div>
|
||||||
|
<div className="grid" style={{ gridTemplateColumns: `repeat(${1 + approvers.length}, 1fr)` }}>
|
||||||
|
<div className="bg-gray-100 p-1.5 text-center font-medium border-r border-gray-300">
|
||||||
|
작성
|
||||||
|
</div>
|
||||||
|
{approvers.map((approver, index) => (
|
||||||
|
<div
|
||||||
|
key={approver.id}
|
||||||
|
className={`bg-gray-100 p-1.5 text-center font-medium ${index < approvers.length - 1 ? 'border-r border-gray-300' : ''}`}
|
||||||
|
>
|
||||||
|
{approver.status === 'approved' ? '승인' : approver.status === 'rejected' ? '반려' : '결재'}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 이름 행 */}
|
||||||
|
<div className="grid grid-cols-[60px_1fr] border-b border-gray-300">
|
||||||
|
<div className="bg-gray-50 p-1.5 text-center border-r border-gray-300">이름</div>
|
||||||
|
<div className="grid" style={{ gridTemplateColumns: `repeat(${1 + approvers.length}, 1fr)` }}>
|
||||||
|
<div className="p-1.5 text-center border-r border-gray-300 flex items-center justify-center gap-1">
|
||||||
|
{drafter.name}
|
||||||
|
</div>
|
||||||
|
{approvers.map((approver, index) => (
|
||||||
|
<div
|
||||||
|
key={approver.id}
|
||||||
|
className={`p-1.5 text-center flex items-center justify-center gap-1 ${index < approvers.length - 1 ? 'border-r border-gray-300' : ''}`}
|
||||||
|
>
|
||||||
|
{getStatusIcon(approver.status)}
|
||||||
|
{approver.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 부서 행 */}
|
||||||
|
<div className="grid grid-cols-[60px_1fr]">
|
||||||
|
<div className="bg-gray-50 p-1.5 text-center border-r border-gray-300">부서명</div>
|
||||||
|
<div className="grid" style={{ gridTemplateColumns: `repeat(${1 + approvers.length}, 1fr)` }}>
|
||||||
|
<div className="p-1.5 text-center border-r border-gray-300">
|
||||||
|
{drafter.department}
|
||||||
|
</div>
|
||||||
|
{approvers.map((approver, index) => (
|
||||||
|
<div
|
||||||
|
key={approver.id}
|
||||||
|
className={`p-1.5 text-center ${index < approvers.length - 1 ? 'border-r border-gray-300' : ''}`}
|
||||||
|
>
|
||||||
|
{approver.department}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Fragment } from 'react';
|
||||||
|
import { ApprovalLineBox } from './ApprovalLineBox';
|
||||||
|
import type { ExpenseEstimateDocumentData } from './types';
|
||||||
|
|
||||||
|
interface ExpenseEstimateDocumentProps {
|
||||||
|
data: ExpenseEstimateDocumentData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpenseEstimateDocument({ data }: ExpenseEstimateDocumentProps) {
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 월별 그룹핑
|
||||||
|
const groupedByMonth = data.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<string, typeof data.items>);
|
||||||
|
|
||||||
|
const getMonthSubtotal = (monthItems: typeof data.items) => {
|
||||||
|
return monthItems.reduce((sum, item) => sum + item.amount, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMonthLabel = (month: string) => {
|
||||||
|
const [year, mon] = month.split('-');
|
||||||
|
return `${year}년 ${parseInt(mon)}월 계`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white p-8 min-h-full">
|
||||||
|
{/* 문서 헤더 */}
|
||||||
|
<div className="flex justify-between items-start mb-6">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-2xl font-bold text-center mb-2">지출 예상 내역서</h1>
|
||||||
|
<p className="text-sm text-gray-600 text-center">
|
||||||
|
문서번호: {data.documentNo} | 작성일자: {data.createdAt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<ApprovalLineBox drafter={data.drafter} approvers={data.approvers} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 문서 내용 */}
|
||||||
|
<div className="border border-gray-300">
|
||||||
|
{/* 지출 예상 내역서 헤더 */}
|
||||||
|
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
|
||||||
|
지출 예상 내역서 목록
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-100 border-b border-gray-300">
|
||||||
|
<th className="p-2 text-center font-medium border-r border-gray-300 w-32">예상 지급일</th>
|
||||||
|
<th className="p-2 text-left font-medium border-r border-gray-300">항목</th>
|
||||||
|
<th className="p-2 text-right font-medium border-r border-gray-300 w-32">지출금액</th>
|
||||||
|
<th className="p-2 text-left font-medium border-r border-gray-300 w-24">거래처</th>
|
||||||
|
<th className="p-2 text-left font-medium w-40">계좌</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(groupedByMonth).map(([month, monthItems]) => (
|
||||||
|
<Fragment key={month}>
|
||||||
|
{monthItems.map((item) => (
|
||||||
|
<tr key={item.id} className="border-b border-gray-300">
|
||||||
|
<td className="p-2 text-center border-r border-gray-300">{item.expectedPaymentDate}</td>
|
||||||
|
<td className="p-2 border-r border-gray-300">{item.category}</td>
|
||||||
|
<td className="p-2 text-right border-r border-gray-300 text-blue-600 font-medium">
|
||||||
|
{formatCurrency(item.amount)}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 border-r border-gray-300">{item.vendor}</td>
|
||||||
|
<td className="p-2">{item.account}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{/* 월별 소계 */}
|
||||||
|
<tr className="bg-pink-50 border-b border-gray-300">
|
||||||
|
<td colSpan={2} className="p-2 font-medium border-r border-gray-300">
|
||||||
|
{getMonthLabel(month)}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-right border-r border-gray-300 text-red-600 font-bold">
|
||||||
|
{formatCurrency(getMonthSubtotal(monthItems))}
|
||||||
|
</td>
|
||||||
|
<td colSpan={2} className="p-2"></td>
|
||||||
|
</tr>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 지출 합계 */}
|
||||||
|
<tr className="bg-gray-100 border-b border-gray-300">
|
||||||
|
<td colSpan={2} className="p-3 font-semibold border-r border-gray-300">지출 합계</td>
|
||||||
|
<td className="p-3 text-right border-r border-gray-300 text-red-600 font-bold">
|
||||||
|
{formatCurrency(data.totalExpense)}
|
||||||
|
</td>
|
||||||
|
<td colSpan={2} className="p-3"></td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* 계좌 잔액 */}
|
||||||
|
<tr className="bg-gray-100 border-b border-gray-300">
|
||||||
|
<td colSpan={2} className="p-3 font-semibold border-r border-gray-300">계좌 잔액</td>
|
||||||
|
<td className="p-3 text-right border-r border-gray-300 font-bold">
|
||||||
|
{formatCurrency(data.accountBalance)}
|
||||||
|
</td>
|
||||||
|
<td colSpan={2} className="p-3"></td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* 최종 차액 */}
|
||||||
|
<tr className="bg-gray-100">
|
||||||
|
<td colSpan={2} className="p-3 font-semibold border-r border-gray-300">최종 차액</td>
|
||||||
|
<td className={`p-3 text-right border-r border-gray-300 font-bold ${data.finalDifference >= 0 ? 'text-blue-600' : 'text-red-600'}`}>
|
||||||
|
{formatCurrency(data.finalDifference)}
|
||||||
|
</td>
|
||||||
|
<td colSpan={2} className="p-3"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
src/components/approval/DocumentDetail/ExpenseReportDocument.tsx
Normal file
135
src/components/approval/DocumentDetail/ExpenseReportDocument.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ApprovalLineBox } from './ApprovalLineBox';
|
||||||
|
import type { ExpenseReportDocumentData } from './types';
|
||||||
|
|
||||||
|
interface ExpenseReportDocumentProps {
|
||||||
|
data: ExpenseReportDocumentData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpenseReportDocument({ data }: ExpenseReportDocumentProps) {
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white p-8 min-h-full">
|
||||||
|
{/* 문서 헤더 */}
|
||||||
|
<div className="flex justify-between items-start mb-6">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-2xl font-bold text-center mb-2">지출결의서</h1>
|
||||||
|
<p className="text-sm text-gray-600 text-center">
|
||||||
|
문서번호: {data.documentNo} | 작성일자: {data.createdAt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<ApprovalLineBox drafter={data.drafter} approvers={data.approvers} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 문서 내용 */}
|
||||||
|
<div className="border border-gray-300">
|
||||||
|
{/* 지출 요청일 / 결제일 */}
|
||||||
|
<div className="grid grid-cols-2 border-b border-gray-300">
|
||||||
|
<div className="flex border-r border-gray-300">
|
||||||
|
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||||
|
지출 요청일
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-3 text-sm">{data.requestDate || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||||
|
결제일
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-3 text-sm">{data.paymentDate || '-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 지출결의서 내역 */}
|
||||||
|
<div className="border-b border-gray-300">
|
||||||
|
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
|
||||||
|
지출결의서 내역
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-100 border-b border-gray-300">
|
||||||
|
<th className="p-2 text-center font-medium border-r border-gray-300 w-16">No.</th>
|
||||||
|
<th className="p-2 text-left font-medium border-r border-gray-300">적요</th>
|
||||||
|
<th className="p-2 text-right font-medium border-r border-gray-300 w-32">금액</th>
|
||||||
|
<th className="p-2 text-left font-medium w-40">비고</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.items.length > 0 ? (
|
||||||
|
data.items.map((item, index) => (
|
||||||
|
<tr key={item.id} className="border-b border-gray-300">
|
||||||
|
<td className="p-2 text-center border-r border-gray-300">{index + 1}</td>
|
||||||
|
<td className="p-2 border-r border-gray-300">{item.description}</td>
|
||||||
|
<td className="p-2 text-right border-r border-gray-300">
|
||||||
|
{formatCurrency(item.amount)}
|
||||||
|
</td>
|
||||||
|
<td className="p-2">{item.note}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{[1, 2, 3].map((num) => (
|
||||||
|
<tr key={num} className="border-b border-gray-300">
|
||||||
|
<td className="p-2 text-center border-r border-gray-300">{num}</td>
|
||||||
|
<td className="p-2 border-r border-gray-300"> </td>
|
||||||
|
<td className="p-2 border-r border-gray-300"> </td>
|
||||||
|
<td className="p-2"> </td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 법인카드 / 총 비용 */}
|
||||||
|
<div className="grid grid-cols-2 border-b border-gray-300">
|
||||||
|
<div className="flex border-r border-gray-300">
|
||||||
|
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||||
|
법인카드
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-3 text-sm">{data.cardInfo || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||||
|
총 비용
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-3 text-sm font-semibold">
|
||||||
|
{formatCurrency(data.totalAmount)}원
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 참고 이미지 */}
|
||||||
|
<div>
|
||||||
|
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
|
||||||
|
참고 이미지
|
||||||
|
</div>
|
||||||
|
<div className="min-h-[150px] p-4 bg-gray-50">
|
||||||
|
{data.attachments && data.attachments.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{data.attachments.map((url, index) => (
|
||||||
|
<img
|
||||||
|
key={index}
|
||||||
|
src={url}
|
||||||
|
alt={`첨부 이미지 ${index + 1}`}
|
||||||
|
className="w-full h-32 object-cover rounded border"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-gray-400 py-8">
|
||||||
|
첨부된 이미지가 없습니다
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
src/components/approval/DocumentDetail/ProposalDocument.tsx
Normal file
113
src/components/approval/DocumentDetail/ProposalDocument.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ApprovalLineBox } from './ApprovalLineBox';
|
||||||
|
import type { ProposalDocumentData } from './types';
|
||||||
|
|
||||||
|
interface ProposalDocumentProps {
|
||||||
|
data: ProposalDocumentData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProposalDocument({ data }: ProposalDocumentProps) {
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white p-8 min-h-full">
|
||||||
|
{/* 문서 헤더 */}
|
||||||
|
<div className="flex justify-between items-start mb-6">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-2xl font-bold text-center mb-2">품의서</h1>
|
||||||
|
<p className="text-sm text-gray-600 text-center">
|
||||||
|
문서번호: {data.documentNo} | 작성일자: {data.createdAt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<ApprovalLineBox drafter={data.drafter} approvers={data.approvers} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 문서 내용 */}
|
||||||
|
<div className="border border-gray-300">
|
||||||
|
{/* 구매처 정보 */}
|
||||||
|
<div className="grid grid-cols-2 border-b border-gray-300">
|
||||||
|
<div className="flex border-r border-gray-300">
|
||||||
|
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||||
|
구매처
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-3 text-sm">{data.vendor || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||||
|
구매처 결제일
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-3 text-sm">{data.vendorPaymentDate || '-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 제목 */}
|
||||||
|
<div className="flex border-b border-gray-300">
|
||||||
|
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||||
|
제목
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-3 text-sm">{data.title || '-'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 품의 내역 */}
|
||||||
|
<div className="flex border-b border-gray-300">
|
||||||
|
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||||
|
품의 내역
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-3 text-sm min-h-[100px] whitespace-pre-wrap">
|
||||||
|
{data.description || '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 품의 사유 */}
|
||||||
|
<div className="flex border-b border-gray-300">
|
||||||
|
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||||
|
품의 사유
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-3 text-sm min-h-[100px] whitespace-pre-wrap">
|
||||||
|
{data.reason || '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 예상 비용 */}
|
||||||
|
<div className="flex border-b border-gray-300">
|
||||||
|
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||||
|
예상 비용
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-3 text-sm font-semibold">
|
||||||
|
{formatCurrency(data.estimatedCost)}원
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 참고 이미지 */}
|
||||||
|
<div>
|
||||||
|
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
|
||||||
|
참고 이미지
|
||||||
|
</div>
|
||||||
|
<div className="min-h-[150px] p-4 bg-gray-50">
|
||||||
|
{data.attachments && data.attachments.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{data.attachments.map((url, index) => (
|
||||||
|
<img
|
||||||
|
key={index}
|
||||||
|
src={url}
|
||||||
|
alt={`첨부 이미지 ${index + 1}`}
|
||||||
|
className="w-full h-32 object-cover rounded border"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-gray-400 py-8">
|
||||||
|
첨부된 이미지가 없습니다
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
src/components/approval/DocumentDetail/index.tsx
Normal file
184
src/components/approval/DocumentDetail/index.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
VisuallyHidden,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Copy,
|
||||||
|
Edit,
|
||||||
|
X as XIcon,
|
||||||
|
CheckCircle,
|
||||||
|
Printer,
|
||||||
|
Share2,
|
||||||
|
ChevronDown,
|
||||||
|
FileText,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
MessageCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { ProposalDocument } from './ProposalDocument';
|
||||||
|
import { ExpenseReportDocument } from './ExpenseReportDocument';
|
||||||
|
import { ExpenseEstimateDocument } from './ExpenseEstimateDocument';
|
||||||
|
import type {
|
||||||
|
DocumentType,
|
||||||
|
DocumentDetailModalProps,
|
||||||
|
ProposalDocumentData,
|
||||||
|
ExpenseReportDocumentData,
|
||||||
|
ExpenseEstimateDocumentData,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export function DocumentDetailModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
documentType,
|
||||||
|
data,
|
||||||
|
onEdit,
|
||||||
|
onCopy,
|
||||||
|
onApprove,
|
||||||
|
onReject,
|
||||||
|
}: DocumentDetailModalProps) {
|
||||||
|
const getDocumentTitle = () => {
|
||||||
|
switch (documentType) {
|
||||||
|
case 'proposal':
|
||||||
|
return '품의서';
|
||||||
|
case 'expenseReport':
|
||||||
|
return '지출결의서';
|
||||||
|
case 'expenseEstimate':
|
||||||
|
return '지출 예상 내역서';
|
||||||
|
default:
|
||||||
|
return '문서';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
window.print();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSharePdf = () => {
|
||||||
|
console.log('PDF 다운로드');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShareEmail = () => {
|
||||||
|
console.log('이메일 공유');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShareFax = () => {
|
||||||
|
console.log('팩스 전송');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShareKakao = () => {
|
||||||
|
console.log('카카오톡 공유');
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderDocument = () => {
|
||||||
|
switch (documentType) {
|
||||||
|
case 'proposal':
|
||||||
|
return <ProposalDocument data={data as ProposalDocumentData} />;
|
||||||
|
case 'expenseReport':
|
||||||
|
return <ExpenseReportDocument data={data as ExpenseReportDocumentData} />;
|
||||||
|
case 'expenseEstimate':
|
||||||
|
return <ExpenseEstimateDocument data={data as ExpenseEstimateDocumentData} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-[95vw] md:max-w-[800px] lg:max-w-[900px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
|
||||||
|
<VisuallyHidden>
|
||||||
|
<DialogTitle>{getDocumentTitle()} 상세</DialogTitle>
|
||||||
|
</VisuallyHidden>
|
||||||
|
|
||||||
|
{/* 헤더 영역 - 고정 */}
|
||||||
|
<div className="flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
|
||||||
|
<h2 className="text-lg font-semibold">{getDocumentTitle()} 상세</h2>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="h-8 w-8"
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 영역 - 고정 */}
|
||||||
|
<div className="flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
|
||||||
|
<Button variant="outline" size="sm" onClick={onCopy}>
|
||||||
|
<Copy className="h-4 w-4 mr-1" />
|
||||||
|
복제
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={onEdit}>
|
||||||
|
<Edit className="h-4 w-4 mr-1" />
|
||||||
|
수정
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={onReject} className="text-red-600 hover:text-red-700">
|
||||||
|
<XIcon className="h-4 w-4 mr-1" />
|
||||||
|
반려
|
||||||
|
</Button>
|
||||||
|
<Button variant="default" size="sm" onClick={onApprove} className="bg-blue-600 hover:bg-blue-700">
|
||||||
|
<CheckCircle className="h-4 w-4 mr-1" />
|
||||||
|
승인
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||||
|
<Printer className="h-4 w-4 mr-1" />
|
||||||
|
인쇄
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 공유 드롭다운 */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Share2 className="h-4 w-4 mr-1" />
|
||||||
|
공유
|
||||||
|
<ChevronDown className="h-3 w-3 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={handleSharePdf}>
|
||||||
|
<FileText className="h-4 w-4 mr-2" />
|
||||||
|
PDF 다운로드
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handleShareEmail}>
|
||||||
|
<Mail className="h-4 w-4 mr-2" />
|
||||||
|
이메일
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handleShareFax}>
|
||||||
|
<Phone className="h-4 w-4 mr-2" />
|
||||||
|
팩스
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handleShareKakao}>
|
||||||
|
<MessageCircle className="h-4 w-4 mr-2" />
|
||||||
|
카카오톡
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 문서 영역 - 스크롤 */}
|
||||||
|
<div className="flex-1 overflow-y-auto bg-gray-100 p-6">
|
||||||
|
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg">
|
||||||
|
{renderDocument()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export types and components
|
||||||
|
export type { DocumentType, DocumentDetailModalProps } from './types';
|
||||||
|
export { ProposalDocument } from './ProposalDocument';
|
||||||
|
export { ExpenseReportDocument } from './ExpenseReportDocument';
|
||||||
|
export { ExpenseEstimateDocument } from './ExpenseEstimateDocument';
|
||||||
85
src/components/approval/DocumentDetail/types.ts
Normal file
85
src/components/approval/DocumentDetail/types.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// ===== 문서 상세 모달 타입 정의 =====
|
||||||
|
|
||||||
|
export type DocumentType = 'proposal' | 'expenseReport' | 'expenseEstimate';
|
||||||
|
|
||||||
|
// 결재자 정보
|
||||||
|
export interface Approver {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
position: string;
|
||||||
|
department: string;
|
||||||
|
status: 'pending' | 'approved' | 'rejected' | 'none';
|
||||||
|
approvedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 품의서 데이터
|
||||||
|
export interface ProposalDocumentData {
|
||||||
|
documentNo: string;
|
||||||
|
createdAt: string;
|
||||||
|
vendor: string;
|
||||||
|
vendorPaymentDate: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
reason: string;
|
||||||
|
estimatedCost: number;
|
||||||
|
attachments?: string[];
|
||||||
|
approvers: Approver[];
|
||||||
|
drafter: Approver;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 지출결의서 항목
|
||||||
|
export interface ExpenseReportItem {
|
||||||
|
id: string;
|
||||||
|
no: number;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 지출결의서 데이터
|
||||||
|
export interface ExpenseReportDocumentData {
|
||||||
|
documentNo: string;
|
||||||
|
createdAt: string;
|
||||||
|
requestDate: string;
|
||||||
|
paymentDate: string;
|
||||||
|
items: ExpenseReportItem[];
|
||||||
|
cardInfo: string;
|
||||||
|
totalAmount: number;
|
||||||
|
attachments?: string[];
|
||||||
|
approvers: Approver[];
|
||||||
|
drafter: Approver;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 지출 예상 내역서 항목
|
||||||
|
export interface ExpenseEstimateItem {
|
||||||
|
id: string;
|
||||||
|
expectedPaymentDate: string;
|
||||||
|
category: string;
|
||||||
|
amount: number;
|
||||||
|
vendor: string;
|
||||||
|
account: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 지출 예상 내역서 데이터
|
||||||
|
export interface ExpenseEstimateDocumentData {
|
||||||
|
documentNo: string;
|
||||||
|
createdAt: string;
|
||||||
|
items: ExpenseEstimateItem[];
|
||||||
|
totalExpense: number;
|
||||||
|
accountBalance: number;
|
||||||
|
finalDifference: number;
|
||||||
|
approvers: Approver[];
|
||||||
|
drafter: Approver;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 문서 상세 모달 Props
|
||||||
|
export interface DocumentDetailModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
documentType: DocumentType;
|
||||||
|
data: ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData;
|
||||||
|
onEdit?: () => void;
|
||||||
|
onCopy?: () => void;
|
||||||
|
onApprove?: () => void;
|
||||||
|
onReject?: () => void;
|
||||||
|
}
|
||||||
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',
|
||||||
|
};
|
||||||
501
src/components/approval/ReferenceBox/index.tsx
Normal file
501
src/components/approval/ReferenceBox/index.tsx
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import {
|
||||||
|
Files,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Check,
|
||||||
|
BookOpen,
|
||||||
|
} 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 { toast } from 'sonner';
|
||||||
|
import type {
|
||||||
|
ReferenceTabType,
|
||||||
|
ReferenceRecord,
|
||||||
|
ReadStatus,
|
||||||
|
ApprovalType,
|
||||||
|
DocumentStatus,
|
||||||
|
SortOption,
|
||||||
|
FilterOption,
|
||||||
|
} from './types';
|
||||||
|
import {
|
||||||
|
REFERENCE_TAB_LABELS,
|
||||||
|
SORT_OPTIONS,
|
||||||
|
FILTER_OPTIONS,
|
||||||
|
APPROVAL_TYPE_LABELS,
|
||||||
|
DOCUMENT_STATUS_LABELS,
|
||||||
|
DOCUMENT_STATUS_COLORS,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// ===== Mock 데이터 생성 =====
|
||||||
|
const generateReferenceData = (): ReferenceRecord[] => {
|
||||||
|
const departments = ['개발팀', '디자인팀', '기획팀', '영업팀', '인사팀'];
|
||||||
|
const positions = ['팀장', '파트장', '선임', '주임', '사원'];
|
||||||
|
const approvalTypes: ApprovalType[] = ['expense_report', 'proposal', 'expense_estimate'];
|
||||||
|
const documentStatuses: DocumentStatus[] = ['pending', 'approved', 'rejected'];
|
||||||
|
const readStatuses: ReadStatus[] = ['read', 'unread'];
|
||||||
|
const titlesByType: Record<ApprovalType, string[]> = {
|
||||||
|
expense_report: ['12월 출장비 정산', '사무용품 구매비 청구', '고객 미팅 식대 정산', '세미나 참가비 정산'],
|
||||||
|
proposal: ['신규 프로젝트 품의', '장비 구매 품의', '외주 용역 품의', '마케팅 예산 품의'],
|
||||||
|
expense_estimate: ['2024년 하반기 예산', '신규 사업 예상 지출', '부서 운영비 예상', '행사 예산 내역'],
|
||||||
|
};
|
||||||
|
|
||||||
|
return Array.from({ length: 55 }, (_, i) => {
|
||||||
|
const approvalType = approvalTypes[i % approvalTypes.length];
|
||||||
|
const titles = titlesByType[approvalType];
|
||||||
|
const draftDate = new Date(2025, 9, Math.floor(Math.random() * 30) + 1, Math.floor(Math.random() * 24), Math.floor(Math.random() * 60));
|
||||||
|
const readStatus = i === 0 ? 'unread' : 'read'; // 1건만 미열람
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `ref-${i + 1}`,
|
||||||
|
documentNo: `abc${String(123 + i).padStart(3, '0')}`,
|
||||||
|
approvalType,
|
||||||
|
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],
|
||||||
|
documentStatus: documentStatuses[i % documentStatuses.length],
|
||||||
|
readStatus,
|
||||||
|
readAt: readStatus === 'read' ? format(new Date(draftDate.getTime() + Math.random() * 7 * 24 * 60 * 60 * 1000), 'yyyy-MM-dd HH:mm') : undefined,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReferenceBox() {
|
||||||
|
// ===== 상태 관리 =====
|
||||||
|
const [activeTab, setActiveTab] = useState<ReferenceTabType>('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('2025-09-01');
|
||||||
|
const [endDate, setEndDate] = useState('2025-09-03');
|
||||||
|
|
||||||
|
// 다이얼로그 상태
|
||||||
|
const [markReadDialogOpen, setMarkReadDialogOpen] = useState(false);
|
||||||
|
const [markUnreadDialogOpen, setMarkUnreadDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
// Mock 데이터
|
||||||
|
const [referenceData, setReferenceData] = useState<ReferenceRecord[]>(generateReferenceData);
|
||||||
|
|
||||||
|
// ===== 탭 변경 핸들러 =====
|
||||||
|
const handleTabChange = useCallback((value: string) => {
|
||||||
|
setActiveTab(value as ReferenceTabType);
|
||||||
|
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 = referenceData;
|
||||||
|
|
||||||
|
// 탭 필터 (열람 상태)
|
||||||
|
if (activeTab !== 'all') {
|
||||||
|
data = data.filter(item => item.readStatus === 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;
|
||||||
|
}, [referenceData, 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 = referenceData.length;
|
||||||
|
const read = referenceData.filter(item => item.readStatus === 'read').length;
|
||||||
|
const unread = referenceData.filter(item => item.readStatus === 'unread').length;
|
||||||
|
return { all, read, unread };
|
||||||
|
}, [referenceData]);
|
||||||
|
|
||||||
|
// ===== 열람/미열람 처리 핸들러 =====
|
||||||
|
const handleMarkReadClick = useCallback(() => {
|
||||||
|
if (selectedItems.size === 0) return;
|
||||||
|
setMarkReadDialogOpen(true);
|
||||||
|
}, [selectedItems.size]);
|
||||||
|
|
||||||
|
const handleMarkReadConfirm = useCallback(() => {
|
||||||
|
// 선택된 항목들을 열람으로 변경
|
||||||
|
setReferenceData(prev =>
|
||||||
|
prev.map(item =>
|
||||||
|
selectedItems.has(item.id)
|
||||||
|
? { ...item, readStatus: 'read' as ReadStatus, readAt: format(new Date(), 'yyyy-MM-dd HH:mm') }
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setSelectedItems(new Set());
|
||||||
|
setMarkReadDialogOpen(false);
|
||||||
|
|
||||||
|
toast.success('열람 처리 완료', {
|
||||||
|
description: '열람 처리가 완료되었습니다.',
|
||||||
|
});
|
||||||
|
}, [selectedItems]);
|
||||||
|
|
||||||
|
const handleMarkUnreadClick = useCallback(() => {
|
||||||
|
if (selectedItems.size === 0) return;
|
||||||
|
setMarkUnreadDialogOpen(true);
|
||||||
|
}, [selectedItems.size]);
|
||||||
|
|
||||||
|
const handleMarkUnreadConfirm = useCallback(() => {
|
||||||
|
// 선택된 항목들을 미열람으로 변경
|
||||||
|
setReferenceData(prev =>
|
||||||
|
prev.map(item =>
|
||||||
|
selectedItems.has(item.id)
|
||||||
|
? { ...item, readStatus: 'unread' as ReadStatus, readAt: undefined }
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setSelectedItems(new Set());
|
||||||
|
setMarkUnreadDialogOpen(false);
|
||||||
|
|
||||||
|
toast.success('미열람 처리 완료', {
|
||||||
|
description: '미열람 처리가 완료되었습니다.',
|
||||||
|
});
|
||||||
|
}, [selectedItems]);
|
||||||
|
|
||||||
|
// ===== 통계 카드 =====
|
||||||
|
const statCards: StatCard[] = useMemo(() => [
|
||||||
|
{ label: '전체', value: `${stats.all}건`, icon: Files, iconColor: 'text-blue-500' },
|
||||||
|
{ label: '열람', value: `${stats.read}건`, icon: Eye, iconColor: 'text-green-500' },
|
||||||
|
{ label: '미열람', value: `${stats.unread}건`, icon: EyeOff, iconColor: 'text-red-500' },
|
||||||
|
], [stats]);
|
||||||
|
|
||||||
|
// ===== 탭 옵션 (열람/미열람 토글 버튼 형태) =====
|
||||||
|
const tabs: TabOption[] = useMemo(() => [
|
||||||
|
{ value: 'all', label: REFERENCE_TAB_LABELS.all, count: stats.all, color: 'blue' },
|
||||||
|
{ value: 'read', label: REFERENCE_TAB_LABELS.read, count: stats.read, color: 'green' },
|
||||||
|
{ value: 'unread', label: REFERENCE_TAB_LABELS.unread, count: stats.unread, 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: 'draftDate', label: '기안일시' },
|
||||||
|
{ key: 'status', label: '상태', className: 'text-center' },
|
||||||
|
{ key: 'confirm', label: '확인', className: 'w-[80px] text-center' },
|
||||||
|
], []);
|
||||||
|
|
||||||
|
// ===== 테이블 행 렌더링 =====
|
||||||
|
const renderTableRow = useCallback((item: ReferenceRecord, index: number, globalIndex: number) => {
|
||||||
|
const isSelected = selectedItems.has(item.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<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.draftDate}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge className={DOCUMENT_STATUS_COLORS[item.documentStatus]}>
|
||||||
|
{DOCUMENT_STATUS_LABELS[item.documentStatus]}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{item.readStatus === 'read' && (
|
||||||
|
<Check className="h-4 w-4 mx-auto text-green-600" />
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}, [selectedItems, toggleSelection]);
|
||||||
|
|
||||||
|
// ===== 모바일 카드 렌더링 =====
|
||||||
|
const renderMobileCard = useCallback((
|
||||||
|
item: ReferenceRecord,
|
||||||
|
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={DOCUMENT_STATUS_COLORS[item.documentStatus]}>
|
||||||
|
{DOCUMENT_STATUS_LABELS[item.documentStatus]}
|
||||||
|
</Badge>
|
||||||
|
{item.readStatus === 'read' ? (
|
||||||
|
<Badge className="bg-gray-100 text-gray-800">열람</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="bg-blue-100 text-blue-800">미열람</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.readAt || '-'} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{item.readStatus === 'unread' ? (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedItems(new Set([item.id]));
|
||||||
|
setMarkReadDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4 mr-2" /> 열람 처리
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedItems(new Set([item.id]));
|
||||||
|
setMarkUnreadDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EyeOff className="w-4 h-4 mr-2" /> 미열람 처리
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ===== 헤더 액션 (DateRangeSelector + 열람/미열람 버튼) =====
|
||||||
|
const headerActions = (
|
||||||
|
<DateRangeSelector
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
onStartDateChange={setStartDate}
|
||||||
|
onEndDateChange={setEndDate}
|
||||||
|
extraActions={
|
||||||
|
selectedItems.size > 0 && (
|
||||||
|
<>
|
||||||
|
<Button variant="default" onClick={handleMarkReadClick}>
|
||||||
|
<Eye className="h-4 w-4 mr-2" />
|
||||||
|
열람
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={handleMarkUnreadClick}>
|
||||||
|
<EyeOff 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={BookOpen}
|
||||||
|
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: ReferenceRecord) => item.id}
|
||||||
|
renderTableRow={renderTableRow}
|
||||||
|
renderMobileCard={renderMobileCard}
|
||||||
|
pagination={{
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
totalItems: filteredData.length,
|
||||||
|
itemsPerPage,
|
||||||
|
onPageChange: setCurrentPage,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 열람 처리 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={markReadDialogOpen} onOpenChange={setMarkReadDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>열람 처리</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
정말 {selectedItems.size}건을 열람 처리하시겠습니까?
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleMarkReadConfirm}>
|
||||||
|
확인
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* 미열람 처리 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={markUnreadDialogOpen} onOpenChange={setMarkUnreadDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>미열람 처리</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
정말 {selectedItems.size}건을 미열람 처리하시겠습니까?
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleMarkUnreadConfirm}>
|
||||||
|
확인
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
src/components/approval/ReferenceBox/types.ts
Normal file
89
src/components/approval/ReferenceBox/types.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* 참조함 타입 정의
|
||||||
|
* 열람 상태 기반 탭: 전체, 열람, 미열람
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ===== 메인 탭 타입 =====
|
||||||
|
export type ReferenceTabType = 'all' | 'read' | 'unread';
|
||||||
|
|
||||||
|
// 열람 상태
|
||||||
|
export type ReadStatus = 'read' | 'unread';
|
||||||
|
|
||||||
|
// 결재 유형 (문서 종류): 지출결의서, 품의서, 지출예상내역서
|
||||||
|
export type ApprovalType = 'expense_report' | 'proposal' | 'expense_estimate';
|
||||||
|
|
||||||
|
// 문서 상태
|
||||||
|
export type DocumentStatus = 'pending' | 'approved' | 'rejected';
|
||||||
|
|
||||||
|
// 필터 옵션
|
||||||
|
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 ReferenceRecord {
|
||||||
|
id: string;
|
||||||
|
documentNo: string; // 문서번호
|
||||||
|
approvalType: ApprovalType; // 문서유형
|
||||||
|
title: string; // 제목
|
||||||
|
draftDate: string; // 기안일시
|
||||||
|
drafter: string; // 기안자
|
||||||
|
drafterDepartment: string; // 기안자 부서
|
||||||
|
drafterPosition: string; // 기안자 직급
|
||||||
|
documentStatus: DocumentStatus; // 문서 상태 (진행중, 완료, 반려)
|
||||||
|
readStatus: ReadStatus; // 열람 상태
|
||||||
|
readAt?: string; // 열람일시
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 상수 정의 =====
|
||||||
|
|
||||||
|
export const REFERENCE_TAB_LABELS: Record<ReferenceTabType, string> = {
|
||||||
|
all: '전체',
|
||||||
|
read: '열람',
|
||||||
|
unread: '미열람',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const APPROVAL_TYPE_LABELS: Record<ApprovalType, string> = {
|
||||||
|
expense_report: '지출결의서',
|
||||||
|
proposal: '품의서',
|
||||||
|
expense_estimate: '지출예상내역서',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DOCUMENT_STATUS_LABELS: Record<DocumentStatus, string> = {
|
||||||
|
pending: '진행중',
|
||||||
|
approved: '완료',
|
||||||
|
rejected: '반려',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DOCUMENT_STATUS_COLORS: Record<DocumentStatus, string> = {
|
||||||
|
pending: 'bg-yellow-100 text-yellow-800',
|
||||||
|
approved: 'bg-green-100 text-green-800',
|
||||||
|
rejected: 'bg-red-100 text-red-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const READ_STATUS_LABELS: Record<ReadStatus, string> = {
|
||||||
|
read: '열람',
|
||||||
|
unread: '미열람',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const READ_STATUS_COLORS: Record<ReadStatus, string> = {
|
||||||
|
read: 'bg-gray-100 text-gray-800',
|
||||||
|
unread: 'bg-blue-100 text-blue-800',
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user