diff options
Diffstat (limited to 'components/knox/approval/ApprovalDetail.tsx')
| -rw-r--r-- | components/knox/approval/ApprovalDetail.tsx | 412 |
1 files changed, 412 insertions, 0 deletions
diff --git a/components/knox/approval/ApprovalDetail.tsx b/components/knox/approval/ApprovalDetail.tsx new file mode 100644 index 00000000..6db43cbe --- /dev/null +++ b/components/knox/approval/ApprovalDetail.tsx @@ -0,0 +1,412 @@ +'use client' + +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; +import { toast } from 'sonner'; +import { Loader2, Search, FileText, Clock, User, AlertCircle } from 'lucide-react'; + +// API 함수 및 타입 +import { getApprovalDetail, getApprovalContent } from '@/lib/knox-api/approval/approval'; +import type { ApprovalDetailResponse, ApprovalContentResponse, ApprovalLine } from '@/lib/knox-api/approval/approval'; + +// Mock 데이터 +import { mockApprovalAPI, getStatusText, getRoleText, getApprovalStatusText } from './mocks/approval-mock'; + +interface ApprovalDetailProps { + useFakeData?: boolean; + systemId?: string; + initialApInfId?: string; +} + +interface ApprovalDetailData { + detail: ApprovalDetailResponse['data']; + content: ApprovalContentResponse['data']; +} + +// 첨부파일 타입 (가이드에 명확히 정의되어 있지 않아 필드 일부 추정) +interface ApprovalAttachment { + fileName?: string; + fileSize?: string; + downloadUrl?: string; + fileId?: string; + [key: string]: unknown; // 기타 필드 허용 +} + +export default function ApprovalDetail({ + useFakeData = false, + systemId = 'EVCP_SYSTEM', + initialApInfId = '' +}: ApprovalDetailProps) { + const [apInfId, setApInfId] = useState(initialApInfId); + const [approvalData, setApprovalData] = useState<ApprovalDetailData | null>(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + + const fetchApprovalDetail = async (id: string) => { + if (!id.trim()) { + toast.error('결재 ID를 입력해주세요.'); + return; + } + + setIsLoading(true); + setError(null); + setApprovalData(null); + + try { + const [detailResponse, contentResponse] = await Promise.all([ + useFakeData + ? mockApprovalAPI.getApprovalDetail(id) + : getApprovalDetail(id, systemId), + useFakeData + ? mockApprovalAPI.getApprovalContent(id) + : getApprovalContent(id, systemId) + ]); + + if (detailResponse.result === 'SUCCESS' && contentResponse.result === 'SUCCESS') { + setApprovalData({ + detail: detailResponse.data, + content: contentResponse.data + }); + } else { + setError('결재 정보를 가져오는데 실패했습니다.'); + toast.error('결재 정보를 가져오는데 실패했습니다.'); + } + } catch (err) { + console.error('결재 상세 조회 오류:', err); + setError('결재 정보를 가져오는 중 오류가 발생했습니다.'); + toast.error('결재 정보를 가져오는 중 오류가 발생했습니다.'); + } finally { + setIsLoading(false); + } + }; + + const formatDate = (dateString: string) => { + if (!dateString || dateString.length < 14) return dateString; + // YYYYMMDDHHMMSS 형식을 YYYY-MM-DD HH:MM:SS로 변환 + const year = dateString.substring(0, 4); + const month = dateString.substring(4, 6); + const day = dateString.substring(6, 8); + const hour = dateString.substring(8, 10); + const minute = dateString.substring(10, 12); + const second = dateString.substring(12, 14); + + return `${year}-${month}-${day} ${hour}:${minute}:${second}`; + }; + + const getSecurityTypeText = (type: string) => { + const typeMap: Record<string, string> = { + 'PERSONAL': '개인', + 'CONFIDENTIAL': '기밀', + 'CONFIDENTIAL_STRICT': '극기밀' + }; + return typeMap[type] || type; + }; + + const getSecurityTypeBadgeVariant = (type: string) => { + switch (type) { + case 'PERSONAL': + return 'default'; + case 'CONFIDENTIAL': + return 'secondary'; + case 'CONFIDENTIAL_STRICT': + return 'destructive'; + default: + return 'outline'; + } + }; + + const getStatusBadgeVariant = (status: string) => { + switch (status) { + case '2': // 완결 + return 'default'; + case '1': // 진행중 + return 'secondary'; + case '3': // 반려 + return 'destructive'; + case '4': // 상신취소 + return 'outline'; + default: + return 'outline'; + } + }; + + // 첨부파일 다운로드 헬퍼 + const handleDownload = async (attachment: ApprovalAttachment) => { + try { + // 1) downloadUrl 이 이미 포함된 경우 + if (attachment.downloadUrl) { + window.open(attachment.downloadUrl, '_blank'); + return; + } + + // 2) fileId + 별도 엔드포인트 조합 (가이드에 명시되지 않았으므로 best-effort 처리) + if (attachment.fileId) { + const url = `${process.env.KNOX_API_BASE_URL}/approval/api/v2.0/attachments/${attachment.fileId}`; + const resp = await fetch(url, { + method: 'GET', + headers: { + 'System-ID': systemId, + }, + }); + if (!resp.ok) throw new Error('다운로드 실패'); + + // blob 생성 후 브라우저 다운로드 + const blob = await resp.blob(); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = attachment.fileName || 'attachment'; + link.click(); + URL.revokeObjectURL(link.href); + return; + } + + toast.error('다운로드 URL 정보를 찾을 수 없습니다.'); + } catch (err) { + console.error('첨부파일 다운로드 오류:', err); + toast.error('첨부파일 다운로드 중 오류가 발생했습니다.'); + } + }; + + // 초기 로딩 (initialApInfId가 있는 경우) + useEffect(() => { + if (initialApInfId) { + fetchApprovalDetail(initialApInfId); + } + }, [initialApInfId, useFakeData, systemId]); + + return ( + <Card className="w-full max-w-6xl"> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <FileText className="w-5 h-5" /> + 결재 상세 조회 + </CardTitle> + <CardDescription> + 결재 ID를 입력하여 상세 정보를 조회합니다. {useFakeData && '(테스트 모드)'} + </CardDescription> + </CardHeader> + + <CardContent className="space-y-6"> + {/* 검색 영역 */} + <div className="flex items-center gap-3"> + <div className="flex-1"> + <Label htmlFor="apInfId">결재 ID</Label> + <Input + id="apInfId" + placeholder="결재 ID를 입력하세요" + value={apInfId} + onChange={(e) => setApInfId(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && fetchApprovalDetail(apInfId)} + /> + </div> + <Button + onClick={() => fetchApprovalDetail(apInfId)} + disabled={isLoading} + > + {isLoading ? ( + <> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + 조회 중... + </> + ) : ( + <> + <Search className="w-4 h-4 mr-2" /> + 조회 + </> + )} + </Button> + </div> + + {/* 에러 메시지 */} + {error && ( + <div className="p-4 bg-red-50 border border-red-200 rounded-lg"> + <div className="flex items-center gap-2 text-red-700"> + <AlertCircle className="w-4 h-4" /> + <span className="font-medium">오류</span> + </div> + <p className="text-sm text-red-600 mt-1">{error}</p> + </div> + )} + + {/* 결재 상세 정보 */} + {approvalData && ( + <div className="space-y-6"> + {/* 기본 정보 */} + <div className="space-y-4"> + <h3 className="text-lg font-semibold flex items-center gap-2"> + <FileText className="w-5 h-5" /> + 기본 정보 + </h3> + + <div className="grid grid-cols-2 gap-4 p-4 bg-gray-50 rounded-lg"> + <div> + <Label className="text-sm font-medium text-gray-600">결재 ID</Label> + <p className="text-sm font-mono mt-1">{approvalData.detail.apInfId}</p> + </div> + <div> + <Label className="text-sm font-medium text-gray-600">시스템 ID</Label> + <p className="text-sm mt-1">{approvalData.detail.systemId}</p> + </div> + <div> + <Label className="text-sm font-medium text-gray-600">제목</Label> + <p className="text-sm mt-1 font-medium">{approvalData.detail.subject}</p> + </div> + <div> + <Label className="text-sm font-medium text-gray-600">상신일시</Label> + <p className="text-sm mt-1 flex items-center gap-2"> + <Clock className="w-4 h-4" /> + {formatDate(approvalData.detail.sbmDt)} + </p> + </div> + <div> + <Label className="text-sm font-medium text-gray-600">상태</Label> + <div className="mt-1"> + <Badge variant={getStatusBadgeVariant(approvalData.detail.status)}> + {getStatusText(approvalData.detail.status)} + </Badge> + </div> + </div> + <div> + <Label className="text-sm font-medium text-gray-600">보안 등급</Label> + <div className="mt-1"> + <Badge variant={getSecurityTypeBadgeVariant(approvalData.detail.docSecuType)}> + {getSecurityTypeText(approvalData.detail.docSecuType)} + </Badge> + </div> + </div> + <div> + <Label className="text-sm font-medium text-gray-600">긴급 여부</Label> + <div className="mt-1"> + <Badge variant={approvalData.detail.urgYn === 'Y' ? 'destructive' : 'outline'}> + {approvalData.detail.urgYn === 'Y' ? '긴급' : '일반'} + </Badge> + </div> + </div> + <div> + <Label className="text-sm font-medium text-gray-600">언어</Label> + <p className="text-sm mt-1">{approvalData.detail.sbmLang}</p> + </div> + </div> + </div> + + <Separator /> + + {/* 결재 내용 */} + <div className="space-y-4"> + <h3 className="text-lg font-semibold">결재 내용</h3> + + <div className="p-4 bg-gray-50 rounded-lg"> + <div className="mb-2"> + <Label className="text-sm font-medium text-gray-600">내용 형식</Label> + <Badge variant="outline" className="ml-2"> + {approvalData.content.contentsType} + </Badge> + </div> + + <div className="mt-4 p-4 bg-white rounded border"> + <pre className="whitespace-pre-wrap text-sm"> + {approvalData.content.contents} + </pre> + </div> + </div> + </div> + + <Separator /> + + {/* 결재 경로 */} + <div className="space-y-4"> + <h3 className="text-lg font-semibold flex items-center gap-2"> + <User className="w-5 h-5" /> + 결재 경로 + </h3> + + <div className="space-y-3"> + {approvalData.detail.aplns.map((apln: ApprovalLine, index: number) => ( + <div key={index} className="flex items-center gap-4 p-4 border rounded-lg"> + <Badge variant="outline" className="min-w-[40px] text-center"> + {apln.seq} + </Badge> + + <div className="flex-1 grid grid-cols-4 gap-4"> + <div> + <Label className="text-xs font-medium text-gray-600">사용자 ID</Label> + <p className="text-sm mt-1">{apln.userId || apln.epId || '-'}</p> + </div> + <div> + <Label className="text-xs font-medium text-gray-600">이메일</Label> + <p className="text-sm mt-1">{apln.emailAddress || '-'}</p> + </div> + <div> + <Label className="text-xs font-medium text-gray-600">역할</Label> + <div className="mt-1"> + <Badge variant="secondary"> + {getRoleText(apln.role)} + </Badge> + </div> + </div> + <div> + <Label className="text-xs font-medium text-gray-600">상태</Label> + <div className="mt-1"> + <Badge variant={apln.aplnStatsCode === '1' ? 'default' : + apln.aplnStatsCode === '2' ? 'destructive' : 'outline'}> + {getApprovalStatusText(apln.aplnStatsCode)} + </Badge> + </div> + </div> + </div> + + <div className="flex flex-col gap-1 text-xs"> + {apln.arbPmtYn === 'Y' && ( + <Badge variant="outline" className="text-xs">전결권한</Badge> + )} + {apln.contentsMdfyPmtYn === 'Y' && ( + <Badge variant="outline" className="text-xs">본문수정</Badge> + )} + {apln.aplnMdfyPmtYn === 'Y' && ( + <Badge variant="outline" className="text-xs">경로변경</Badge> + )} + </div> + </div> + ))} + </div> + </div> + + {/* 첨부파일 */} + {approvalData.detail.attachments && approvalData.detail.attachments.length > 0 && ( + <> + <Separator /> + <div className="space-y-4"> + <h3 className="text-lg font-semibold">첨부파일</h3> + + <div className="space-y-2"> + {approvalData.detail.attachments.map((attachment: ApprovalAttachment, index: number) => ( + <div key={index} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg"> + <FileText className="w-4 h-4 text-gray-500" /> + <div className="flex-1"> + <p className="text-sm font-medium">{attachment.fileName || `첨부파일 ${index + 1}`}</p> + <p className="text-xs text-gray-500">{attachment.fileSize || '크기 정보 없음'}</p> + </div> + <Button + variant="outline" + size="sm" + onClick={() => handleDownload(attachment)} + > + 다운로드 + </Button> + </div> + ))} + </div> + </div> + </> + )} + </div> + )} + </CardContent> + </Card> + ); +}
\ No newline at end of file |
