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