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