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:
byeongcheolryu
2025-12-17 20:37:51 +09:00
parent 25f9d4e55f
commit d742c0ce26
25 changed files with 4032 additions and 0 deletions

View File

@@ -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`

View File

@@ -0,0 +1,5 @@
import { DocumentCreate } from '@/components/approval/DocumentCreate';
export default function DocumentCreatePage() {
return <DocumentCreate />;
}

View File

@@ -0,0 +1,5 @@
import { DraftBox } from '@/components/approval/DraftBox';
export default function DraftBoxPage() {
return <DraftBox />;
}

View File

@@ -0,0 +1,5 @@
import { ApprovalBox } from '@/components/approval/ApprovalBox';
export default function ApprovalInboxPage() {
return <ApprovalBox />;
}

View File

@@ -0,0 +1,5 @@
import { ReferenceBox } from '@/components/approval/ReferenceBox';
export default function ApprovalReferencePage() {
return <ReferenceBox />;
}

View 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}
/>
)}
</>
);
}

View 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',
};

View 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 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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: '최동현' },
];

View 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>
);
}

View File

@@ -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>
);
}

View 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">&nbsp;</td>
<td className="p-2 border-r border-gray-300">&nbsp;</td>
<td className="p-2">&nbsp;</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>
);
}

View 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>
);
}

View 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';

View 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;
}

View 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}
/>
)}
</>
);
}

View 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',
};

View 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>
</>
);
}

View 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',
};