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