diff options
| author | joonhoekim <26rote@gmail.com> | 2025-07-18 03:58:34 +0000 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-07-18 03:58:34 +0000 |
| commit | 3e59693e017742d971f490eb7c58870cb745a98d (patch) | |
| tree | f7f846613e40e4f058de70afca5809b8e6bd0e2d /components/knox | |
| parent | 2ef02e27dbe639876fa3b90c30307dda183545ec (diff) | |
(김준회) 결재 모듈 개발
Diffstat (limited to 'components/knox')
| -rw-r--r-- | components/knox/approval/ApprovalCancel.tsx | 341 | ||||
| -rw-r--r-- | components/knox/approval/ApprovalDetail.tsx | 362 | ||||
| -rw-r--r-- | components/knox/approval/ApprovalList.tsx | 322 | ||||
| -rw-r--r-- | components/knox/approval/ApprovalManager.tsx | 190 | ||||
| -rw-r--r-- | components/knox/approval/ApprovalSubmit.tsx | 476 | ||||
| -rw-r--r-- | components/knox/approval/index.ts | 23 | ||||
| -rw-r--r-- | components/knox/approval/mocks/approval-mock.ts | 230 |
7 files changed, 1944 insertions, 0 deletions
diff --git a/components/knox/approval/ApprovalCancel.tsx b/components/knox/approval/ApprovalCancel.tsx new file mode 100644 index 00000000..d077bfc6 --- /dev/null +++ b/components/knox/approval/ApprovalCancel.tsx @@ -0,0 +1,341 @@ +'use client' + +import { useState } 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 { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'; +import { toast } from 'sonner'; +import { Loader2, XCircle, AlertTriangle, CheckCircle } from 'lucide-react'; + +// API 함수 및 타입 +import { cancelApproval, getApprovalDetail } from '@/lib/knox-api/approval/approval'; +import type { ApprovalDetailResponse } from '@/lib/knox-api/approval/approval'; + +// Mock 데이터 +import { mockApprovalAPI, getStatusText } from './mocks/approval-mock'; + +interface ApprovalCancelProps { + useFakeData?: boolean; + systemId?: string; + initialApInfId?: string; + onCancelSuccess?: (apInfId: string) => void; +} + +export default function ApprovalCancel({ + useFakeData = false, + systemId = 'EVCP_SYSTEM', + initialApInfId = '', + onCancelSuccess +}: ApprovalCancelProps) { + const [apInfId, setApInfId] = useState(initialApInfId); + const [approvalDetail, setApprovalDetail] = useState<ApprovalDetailResponse['data'] | null>(null); + const [isLoading, setIsLoading] = useState(false); + const [isCancelling, setIsCancelling] = useState(false); + const [error, setError] = useState<string | null>(null); + const [cancelResult, setCancelResult] = useState<{ apInfId: string } | null>(null); + + const fetchApprovalDetail = async () => { + if (!apInfId.trim()) { + toast.error('결재 ID를 입력해주세요.'); + return; + } + + setIsLoading(true); + setError(null); + setApprovalDetail(null); + setCancelResult(null); + + try { + const response = useFakeData + ? await mockApprovalAPI.getApprovalDetail(apInfId) + : await getApprovalDetail(apInfId, systemId); + + if (response.result === 'SUCCESS') { + setApprovalDetail(response.data); + } else { + setError('결재 정보를 가져오는데 실패했습니다.'); + toast.error('결재 정보를 가져오는데 실패했습니다.'); + } + } catch (err) { + console.error('결재 상세 조회 오류:', err); + setError('결재 정보를 가져오는 중 오류가 발생했습니다.'); + toast.error('결재 정보를 가져오는 중 오류가 발생했습니다.'); + } finally { + setIsLoading(false); + } + }; + + const handleCancelApproval = async () => { + if (!approvalDetail) return; + + setIsCancelling(true); + + try { + const response = useFakeData + ? await mockApprovalAPI.cancelApproval(approvalDetail.apInfId) + : await cancelApproval(approvalDetail.apInfId, systemId); + + if (response.result === 'SUCCESS') { + setCancelResult({ apInfId: response.data.apInfId }); + toast.success('결재가 성공적으로 취소되었습니다.'); + onCancelSuccess?.(response.data.apInfId); + + // 상태 업데이트 + setApprovalDetail({ + ...approvalDetail, + status: '4' // 상신취소 + }); + } else { + toast.error('결재 취소에 실패했습니다.'); + } + } catch (err) { + console.error('결재 취소 오류:', err); + toast.error('결재 취소 중 오류가 발생했습니다.'); + } finally { + setIsCancelling(false); + } + }; + + const formatDate = (dateString: string) => { + if (!dateString || dateString.length < 14) return dateString; + 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 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 canCancelApproval = (status: string) => { + // 진행중(1), 보류(0) 상태에서만 취소 가능 + return ['0', '1'].includes(status); + }; + + const getCancelabilityMessage = (status: string) => { + if (canCancelApproval(status)) { + return '이 결재는 취소할 수 있습니다.'; + } + + switch (status) { + case '2': + return '완결된 결재는 취소할 수 없습니다.'; + case '3': + return '반려된 결재는 취소할 수 없습니다.'; + case '4': + return '이미 취소된 결재입니다.'; + case '5': + return '전결 처리된 결재는 취소할 수 없습니다.'; + case '6': + return '후완결된 결재는 취소할 수 없습니다.'; + default: + return '현재 상태에서는 취소할 수 없습니다.'; + } + }; + + return ( + <Card className="w-full max-w-4xl"> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <XCircle 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()} + /> + </div> + <Button + onClick={fetchApprovalDetail} + disabled={isLoading} + > + {isLoading ? ( + <> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + 조회 중... + </> + ) : ( + '조회' + )} + </Button> + </div> + + {/* 취소 완료 메시지 */} + {cancelResult && ( + <div className="p-4 bg-green-50 border border-green-200 rounded-lg"> + <div className="flex items-center gap-2 text-green-700"> + <CheckCircle className="w-4 h-4" /> + <span className="font-medium">취소 완료</span> + </div> + <p className="text-sm text-green-600 mt-1"> + 결재 ID: {cancelResult.apInfId}가 성공적으로 취소되었습니다. + </p> + </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"> + <AlertTriangle className="w-4 h-4" /> + <span className="font-medium">오류</span> + </div> + <p className="text-sm text-red-600 mt-1">{error}</p> + </div> + )} + + {/* 결재 정보 */} + {approvalDetail && ( + <div className="space-y-6"> + <div className="space-y-4"> + <h3 className="text-lg font-semibold">결재 정보</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">{approvalDetail.apInfId}</p> + </div> + <div> + <Label className="text-sm font-medium text-gray-600">제목</Label> + <p className="text-sm mt-1 font-medium">{approvalDetail.subject}</p> + </div> + <div> + <Label className="text-sm font-medium text-gray-600">상신일시</Label> + <p className="text-sm mt-1">{formatDate(approvalDetail.sbmDt)}</p> + </div> + <div> + <Label className="text-sm font-medium text-gray-600">현재 상태</Label> + <div className="mt-1"> + <Badge variant={getStatusBadgeVariant(approvalDetail.status)}> + {getStatusText(approvalDetail.status)} + </Badge> + </div> + </div> + </div> + </div> + + <Separator /> + + {/* 취소 가능 여부 */} + <div className="space-y-4"> + <h3 className="text-lg font-semibold">취소 가능 여부</h3> + + <div className={`p-4 rounded-lg border ${ + canCancelApproval(approvalDetail.status) + ? 'bg-blue-50 border-blue-200' + : 'bg-yellow-50 border-yellow-200' + }`}> + <div className="flex items-center gap-2 mb-2"> + {canCancelApproval(approvalDetail.status) ? ( + <CheckCircle className="w-4 h-4 text-blue-600" /> + ) : ( + <AlertTriangle className="w-4 h-4 text-yellow-600" /> + )} + <span className={`font-medium ${ + canCancelApproval(approvalDetail.status) + ? 'text-blue-700' + : 'text-yellow-700' + }`}> + {canCancelApproval(approvalDetail.status) ? '취소 가능' : '취소 불가'} + </span> + </div> + <p className={`text-sm ${ + canCancelApproval(approvalDetail.status) + ? 'text-blue-600' + : 'text-yellow-600' + }`}> + {getCancelabilityMessage(approvalDetail.status)} + </p> + </div> + </div> + + {/* 취소 버튼 */} + {canCancelApproval(approvalDetail.status) && ( + <> + <Separator /> + + <div className="flex justify-end"> + <AlertDialog> + <AlertDialogTrigger asChild> + <Button variant="destructive" disabled={isCancelling}> + {isCancelling ? ( + <> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + 취소 중... + </> + ) : ( + <> + <XCircle className="w-4 h-4 mr-2" /> + 결재 취소 + </> + )} + </Button> + </AlertDialogTrigger> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>결재 취소 확인</AlertDialogTitle> + <AlertDialogDescription> + 정말로 이 결재를 취소하시겠습니까? + <br /> + <br /> + <strong>결재 ID:</strong> {approvalDetail.apInfId} + <br /> + <strong>제목:</strong> {approvalDetail.subject} + <br /> + <br /> + 이 작업은 되돌릴 수 없습니다. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>취소</AlertDialogCancel> + <AlertDialogAction + onClick={handleCancelApproval} + className="bg-red-600 hover:bg-red-700" + > + 결재 취소 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </div> + </> + )} + </div> + )} + </CardContent> + </Card> + ); +}
\ No newline at end of file diff --git a/components/knox/approval/ApprovalDetail.tsx b/components/knox/approval/ApprovalDetail.tsx new file mode 100644 index 00000000..034bde7d --- /dev/null +++ b/components/knox/approval/ApprovalDetail.tsx @@ -0,0 +1,362 @@ +'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']; +} + +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'; + } + }; + + // 초기 로딩 (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: any, 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"> + 다운로드 + </Button> + </div> + ))} + </div> + </div> + </> + )} + </div> + )} + </CardContent> + </Card> + ); +}
\ No newline at end of file diff --git a/components/knox/approval/ApprovalList.tsx b/components/knox/approval/ApprovalList.tsx new file mode 100644 index 00000000..7f80e74a --- /dev/null +++ b/components/knox/approval/ApprovalList.tsx @@ -0,0 +1,322 @@ +'use client' + +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { toast } from 'sonner'; +import { Loader2, List, Eye, RefreshCw, AlertCircle } from 'lucide-react'; + +// API 함수 및 타입 +import { getSubmissionList, getApprovalHistory } from '@/lib/knox-api/approval/approval'; +import type { SubmissionListResponse, ApprovalHistoryResponse } from '@/lib/knox-api/approval/approval'; + +// Mock 데이터 +import { mockApprovalAPI, getStatusText } from './mocks/approval-mock'; + +interface ApprovalListProps { + useFakeData?: boolean; + systemId?: string; + type?: 'submission' | 'history'; + onItemClick?: (apInfId: string) => void; +} + +type ListItem = { + apInfId: string; + subject: string; + sbmDt: string; + status: string; + urgYn?: string; + docSecuType?: string; + actionType?: string; + actionDt?: string; + userId?: string; +}; + +export default function ApprovalList({ + useFakeData = false, + systemId = 'EVCP_SYSTEM', + type = 'submission', + onItemClick +}: ApprovalListProps) { + const [listData, setListData] = useState<ListItem[]>([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + + const fetchData = async () => { + setIsLoading(true); + setError(null); + + try { + let response: SubmissionListResponse | ApprovalHistoryResponse; + + if (type === 'submission') { + response = useFakeData + ? await mockApprovalAPI.getSubmissionList() + : await getSubmissionList(systemId); + } else { + response = useFakeData + ? await mockApprovalAPI.getApprovalHistory() + : await getApprovalHistory(systemId); + } + + if (response.result === 'SUCCESS') { + setListData(response.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; + 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); + + return `${year}-${month}-${day} ${hour}:${minute}`; + }; + + 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 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 getActionTypeText = (actionType: string) => { + const actionMap: Record<string, string> = { + 'SUBMIT': '상신', + 'APPROVE': '승인', + 'REJECT': '반려', + 'CANCEL': '취소', + 'DELEGATE': '위임' + }; + return actionMap[actionType] || actionType; + }; + + const handleItemClick = (apInfId: string) => { + onItemClick?.(apInfId); + }; + + // 컴포넌트 마운트 시 데이터 로드 + useEffect(() => { + fetchData(); + }, [type, useFakeData, systemId]); + + return ( + <Card className="w-full max-w-6xl"> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <List className="w-5 h-5" /> + {type === 'submission' ? '상신함' : '결재 이력'} + </CardTitle> + <CardDescription> + {type === 'submission' + ? '상신한 결재 목록을 확인합니다.' + : '결재 처리 이력을 확인합니다.' + } {useFakeData && '(테스트 모드)'} + </CardDescription> + </CardHeader> + + <CardContent className="space-y-4"> + {/* 새로고침 버튼 */} + <div className="flex justify-between items-center"> + <div className="text-sm text-gray-500"> + 총 {listData.length}건 + </div> + <Button + onClick={fetchData} + disabled={isLoading} + variant="outline" + size="sm" + > + {isLoading ? ( + <> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + 조회 중... + </> + ) : ( + <> + <RefreshCw 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> + )} + + {/* 목록 테이블 */} + <div className="border rounded-lg"> + <Table> + <TableHeader> + <TableRow> + <TableHead>결재 ID</TableHead> + <TableHead>제목</TableHead> + <TableHead>상신일시</TableHead> + <TableHead>상태</TableHead> + {type === 'submission' && ( + <> + <TableHead>긴급</TableHead> + <TableHead>보안등급</TableHead> + </> + )} + {type === 'history' && ( + <> + <TableHead>처리일시</TableHead> + <TableHead>처리자</TableHead> + <TableHead>처리유형</TableHead> + </> + )} + <TableHead>작업</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {listData.length === 0 ? ( + <TableRow> + <TableCell + colSpan={type === 'submission' ? 7 : 8} + className="text-center py-8 text-gray-500" + > + {isLoading ? '데이터를 불러오는 중...' : '데이터가 없습니다.'} + </TableCell> + </TableRow> + ) : ( + listData.map((item) => ( + <TableRow key={item.apInfId} className="hover:bg-gray-50"> + <TableCell className="font-mono text-sm"> + {item.apInfId} + </TableCell> + <TableCell className="font-medium"> + {item.subject} + </TableCell> + <TableCell> + {formatDate(item.sbmDt)} + </TableCell> + <TableCell> + <Badge variant={getStatusBadgeVariant(item.status)}> + {getStatusText(item.status)} + </Badge> + </TableCell> + + {type === 'submission' && ( + <> + <TableCell> + {item.urgYn === 'Y' ? ( + <Badge variant="destructive" className="text-xs"> + 긴급 + </Badge> + ) : ( + <Badge variant="outline" className="text-xs"> + 일반 + </Badge> + )} + </TableCell> + <TableCell> + <Badge + variant={getSecurityTypeBadgeVariant(item.docSecuType || 'PERSONAL')} + className="text-xs" + > + {getSecurityTypeText(item.docSecuType || 'PERSONAL')} + </Badge> + </TableCell> + </> + )} + + {type === 'history' && ( + <> + <TableCell> + {item.actionDt ? formatDate(item.actionDt) : '-'} + </TableCell> + <TableCell> + {item.userId || '-'} + </TableCell> + <TableCell> + {item.actionType ? ( + <Badge variant="outline" className="text-xs"> + {getActionTypeText(item.actionType)} + </Badge> + ) : '-'} + </TableCell> + </> + )} + + <TableCell> + <Button + variant="ghost" + size="sm" + onClick={() => handleItemClick(item.apInfId)} + > + <Eye className="w-4 h-4 mr-2" /> + 상세 + </Button> + </TableCell> + </TableRow> + )) + )} + </TableBody> + </Table> + </div> + + {/* 페이지네이션 영역 (향후 구현 예정) */} + {listData.length > 0 && ( + <div className="flex justify-center pt-4"> + <div className="text-sm text-gray-500"> + 페이지네이션 기능은 향후 구현 예정입니다. + </div> + </div> + )} + </CardContent> + </Card> + ); +}
\ No newline at end of file diff --git a/components/knox/approval/ApprovalManager.tsx b/components/knox/approval/ApprovalManager.tsx new file mode 100644 index 00000000..cac534c4 --- /dev/null +++ b/components/knox/approval/ApprovalManager.tsx @@ -0,0 +1,190 @@ +'use client' + +import { useState } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Badge } from '@/components/ui/badge'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { Separator } from '@/components/ui/separator'; +import { FileText, Eye, XCircle, List, History, Settings } from 'lucide-react'; + +// 결재 컴포넌트들 +import ApprovalSubmit from './ApprovalSubmit'; +import ApprovalDetail from './ApprovalDetail'; +import ApprovalCancel from './ApprovalCancel'; +import ApprovalList from './ApprovalList'; + +interface ApprovalManagerProps { + useFakeData?: boolean; + systemId?: string; + defaultTab?: string; +} + +export default function ApprovalManager({ + useFakeData = false, + systemId = 'EVCP_SYSTEM', + defaultTab = 'submit' +}: ApprovalManagerProps) { + const [currentTab, setCurrentTab] = useState(defaultTab); + const [isTestMode, setIsTestMode] = useState(useFakeData); + const [selectedApInfId, setSelectedApInfId] = useState<string>(''); + + const handleSubmitSuccess = (apInfId: string) => { + setSelectedApInfId(apInfId); + setCurrentTab('detail'); + }; + + const handleCancelSuccess = (apInfId: string) => { + setSelectedApInfId(apInfId); + setCurrentTab('detail'); + }; + + const handleListItemClick = (apInfId: string) => { + setSelectedApInfId(apInfId); + setCurrentTab('detail'); + }; + + const handleTestModeChange = (checked: boolean) => { + setIsTestMode(checked); + }; + + return ( + <div className="w-full max-w-7xl mx-auto space-y-6"> + {/* 헤더 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <FileText className="w-6 h-6" /> + Knox 결재 시스템 + </CardTitle> + <CardDescription> + 결재 상신, 조회, 취소 등 모든 결재 업무를 관리할 수 있습니다. + </CardDescription> + </CardHeader> + + <CardContent> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-4"> + <div className="flex items-center gap-2"> + <Label htmlFor="test-mode">테스트 모드</Label> + <Switch + id="test-mode" + checked={isTestMode} + onCheckedChange={handleTestModeChange} + /> + </div> + {isTestMode && ( + <Badge variant="outline" className="text-yellow-600 border-yellow-600"> + 테스트 모드 활성화 + </Badge> + )} + </div> + + <div className="flex items-center gap-2 text-sm text-gray-500"> + <span>시스템 ID:</span> + <Badge variant="outline">{systemId}</Badge> + </div> + </div> + </CardContent> + </Card> + + {/* 메인 탭 */} + <Tabs value={currentTab} onValueChange={setCurrentTab} className="w-full"> + <TabsList className="grid w-full grid-cols-5"> + <TabsTrigger value="submit" className="flex items-center gap-2"> + <FileText className="w-4 h-4" /> + 상신 + </TabsTrigger> + <TabsTrigger value="detail" className="flex items-center gap-2"> + <Eye className="w-4 h-4" /> + 상세조회 + </TabsTrigger> + <TabsTrigger value="cancel" className="flex items-center gap-2"> + <XCircle className="w-4 h-4" /> + 취소 + </TabsTrigger> + <TabsTrigger value="list" className="flex items-center gap-2"> + <List className="w-4 h-4" /> + 상신함 + </TabsTrigger> + <TabsTrigger value="history" className="flex items-center gap-2"> + <History className="w-4 h-4" /> + 이력 + </TabsTrigger> + </TabsList> + + {/* 결재 상신 탭 */} + <TabsContent value="submit" className="space-y-6"> + <ApprovalSubmit + useFakeData={isTestMode} + systemId={systemId} + onSubmitSuccess={handleSubmitSuccess} + /> + </TabsContent> + + {/* 결재 상세 조회 탭 */} + <TabsContent value="detail" className="space-y-6"> + <ApprovalDetail + useFakeData={isTestMode} + systemId={systemId} + initialApInfId={selectedApInfId} + /> + </TabsContent> + + {/* 결재 취소 탭 */} + <TabsContent value="cancel" className="space-y-6"> + <ApprovalCancel + useFakeData={isTestMode} + systemId={systemId} + initialApInfId={selectedApInfId} + onCancelSuccess={handleCancelSuccess} + /> + </TabsContent> + + {/* 상신함 탭 */} + <TabsContent value="list" className="space-y-6"> + <ApprovalList + useFakeData={isTestMode} + systemId={systemId} + type="submission" + onItemClick={handleListItemClick} + /> + </TabsContent> + + {/* 결재 이력 탭 */} + <TabsContent value="history" className="space-y-6"> + <ApprovalList + useFakeData={isTestMode} + systemId={systemId} + type="history" + onItemClick={handleListItemClick} + /> + </TabsContent> + </Tabs> + + {/* 하단 정보 */} + <Card> + <CardContent className="pt-6"> + <div className="flex items-center justify-between text-sm text-gray-500"> + <div className="flex items-center gap-4"> + <div className="flex items-center gap-2"> + <Settings className="w-4 h-4" /> + <span>Knox API 결재 시스템</span> + </div> + <Separator orientation="vertical" className="h-4" /> + <div> + Next.js 15 + shadcn/ui + TypeScript + </div> + </div> + + <div className="flex items-center gap-2"> + <span>버전:</span> + <Badge variant="outline">v1.0.0</Badge> + </div> + </div> + </CardContent> + </Card> + </div> + ); +}
\ No newline at end of file diff --git a/components/knox/approval/ApprovalSubmit.tsx b/components/knox/approval/ApprovalSubmit.tsx new file mode 100644 index 00000000..3b5aa230 --- /dev/null +++ b/components/knox/approval/ApprovalSubmit.tsx @@ -0,0 +1,476 @@ +'use client' + +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; +import { toast } from 'sonner'; +import { Loader2, Plus, Trash2, FileText, AlertCircle } from 'lucide-react'; + +// API 함수 및 타입 +import { submitApproval, createSubmitApprovalRequest, createApprovalLine } from '@/lib/knox-api/approval/approval'; +import type { ApprovalLine, SubmitApprovalRequest } from '@/lib/knox-api/approval/approval'; + +// Mock 데이터 +import { mockApprovalAPI, createMockApprovalLine, getRoleText } from './mocks/approval-mock'; + +const formSchema = z.object({ + subject: z.string().min(1, '제목은 필수입니다'), + contents: z.string().min(1, '내용은 필수입니다'), + contentsType: z.enum(['TEXT', 'HTML', 'MIME']), + docSecuType: z.enum(['PERSONAL', 'CONFIDENTIAL', 'CONFIDENTIAL_STRICT']), + urgYn: z.boolean(), + importantYn: z.boolean(), + notifyOption: z.enum(['0', '1', '2', '3']), + docMngSaveCode: z.enum(['0', '1']), + sbmLang: z.enum(['ko', 'ja', 'zh', 'en']), + timeZone: z.string().default('GMT+9'), + aplns: z.array(z.object({ + userId: z.string().min(1, '사용자 ID는 필수입니다'), + emailAddress: z.string().email('유효한 이메일 주소를 입력해주세요').optional(), + role: z.enum(['0', '1', '2', '3', '4', '7', '9']), + seq: z.string(), + opinion: z.string().optional() + })).min(1, '최소 1개의 결재 경로가 필요합니다') +}); + +type FormData = z.infer<typeof formSchema>; + +interface ApprovalSubmitProps { + useFakeData?: boolean; + systemId?: string; + onSubmitSuccess?: (apInfId: string) => void; +} + +export default function ApprovalSubmit({ + useFakeData = false, + systemId = 'EVCP_SYSTEM', + onSubmitSuccess +}: ApprovalSubmitProps) { + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitResult, setSubmitResult] = useState<{ apInfId: string } | null>(null); + + const form = useForm<FormData>({ + resolver: zodResolver(formSchema), + defaultValues: { + subject: '', + contents: '', + contentsType: 'TEXT', + docSecuType: 'PERSONAL', + urgYn: false, + importantYn: false, + notifyOption: '0', + docMngSaveCode: '0', + sbmLang: 'ko', + timeZone: 'GMT+9', + aplns: [ + { + userId: '', + emailAddress: '', + role: '0', + seq: '1', + opinion: '' + } + ] + } + }); + + const aplns = form.watch('aplns'); + + const addApprovalLine = () => { + const newSeq = (aplns.length + 1).toString(); + form.setValue('aplns', [...aplns, { + userId: '', + emailAddress: '', + role: '1', + seq: newSeq, + opinion: '' + }]); + }; + + const removeApprovalLine = (index: number) => { + if (aplns.length > 1) { + const newAplns = aplns.filter((_, i) => i !== index); + // 순서 재정렬 + const reorderedAplns = newAplns.map((apln, i) => ({ + ...apln, + seq: (i + 1).toString() + })); + form.setValue('aplns', reorderedAplns); + } + }; + + const onSubmit = async (data: FormData) => { + setIsSubmitting(true); + setSubmitResult(null); + + try { + // 결재 경로 생성 + const approvalLines: ApprovalLine[] = await Promise.all( + data.aplns.map(async (apln) => { + if (useFakeData) { + return createMockApprovalLine({ + userId: apln.userId, + emailAddress: apln.emailAddress, + role: apln.role, + seq: apln.seq, + opinion: apln.opinion + }); + } else { + return createApprovalLine( + { userId: apln.userId, emailAddress: apln.emailAddress }, + apln.role, + apln.seq, + { opinion: apln.opinion } + ); + } + }) + ); + + // 상신 요청 생성 + const submitRequest: SubmitApprovalRequest = useFakeData + ? { + ...data, + urgYn: data.urgYn ? 'Y' : 'N', + importantYn: data.importantYn ? 'Y' : 'N', + sbmDt: new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14), + apInfId: 'test-ap-inf-id-' + Date.now(), + aplns: approvalLines + } + : await createSubmitApprovalRequest( + data.contents, + data.subject, + approvalLines, + { + contentsType: data.contentsType, + docSecuType: data.docSecuType, + urgYn: data.urgYn ? 'Y' : 'N', + importantYn: data.importantYn ? 'Y' : 'N', + notifyOption: data.notifyOption, + docMngSaveCode: data.docMngSaveCode, + sbmLang: data.sbmLang, + timeZone: data.timeZone + } + ); + + // API 호출 + const response = useFakeData + ? await mockApprovalAPI.submitApproval(submitRequest) + : await submitApproval(submitRequest, systemId); + + if (response.result === 'SUCCESS') { + setSubmitResult({ apInfId: response.data.apInfId }); + toast.success('결재가 성공적으로 상신되었습니다.'); + onSubmitSuccess?.(response.data.apInfId); + form.reset(); + } else { + toast.error('결재 상신에 실패했습니다.'); + } + } catch (error) { + console.error('결재 상신 오류:', error); + toast.error('결재 상신 중 오류가 발생했습니다.'); + } finally { + setIsSubmitting(false); + } + }; + + return ( + <Card className="w-full max-w-4xl"> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <FileText className="w-5 h-5" /> + 결재 상신 + </CardTitle> + <CardDescription> + 새로운 결재를 상신합니다. {useFakeData && '(테스트 모드)'} + </CardDescription> + </CardHeader> + + <CardContent className="space-y-6"> + {submitResult && ( + <div className="p-4 bg-green-50 border border-green-200 rounded-lg"> + <div className="flex items-center gap-2 text-green-700"> + <AlertCircle className="w-4 h-4" /> + <span className="font-medium">상신 완료</span> + </div> + <p className="text-sm text-green-600 mt-1"> + 결재 ID: {submitResult.apInfId} + </p> + </div> + )} + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> + {/* 기본 정보 */} + <div className="space-y-4"> + <h3 className="text-lg font-semibold">기본 정보</h3> + + <FormField + control={form.control} + name="subject" + render={({ field }) => ( + <FormItem> + <FormLabel>제목 *</FormLabel> + <FormControl> + <Input placeholder="결재 제목을 입력하세요" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="contents" + render={({ field }) => ( + <FormItem> + <FormLabel>내용 *</FormLabel> + <FormControl> + <Textarea + placeholder="결재 내용을 입력하세요" + rows={8} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="contentsType" + render={({ field }) => ( + <FormItem> + <FormLabel>내용 형식</FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="내용 형식 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="TEXT">TEXT</SelectItem> + <SelectItem value="HTML">HTML</SelectItem> + <SelectItem value="MIME">MIME</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="docSecuType" + render={({ field }) => ( + <FormItem> + <FormLabel>보안 등급</FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="보안 등급 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="PERSONAL">개인</SelectItem> + <SelectItem value="CONFIDENTIAL">기밀</SelectItem> + <SelectItem value="CONFIDENTIAL_STRICT">극기밀</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="urgYn" + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3"> + <div className="space-y-0.5"> + <FormLabel>긴급 여부</FormLabel> + <FormDescription>긴급 결재로 처리</FormDescription> + </div> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="importantYn" + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3"> + <div className="space-y-0.5"> + <FormLabel>중요 여부</FormLabel> + <FormDescription>중요 결재로 분류</FormDescription> + </div> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + </FormItem> + )} + /> + </div> + </div> + + <Separator /> + + {/* 결재 경로 */} + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <h3 className="text-lg font-semibold">결재 경로</h3> + <Button + type="button" + variant="outline" + size="sm" + onClick={addApprovalLine} + > + <Plus className="w-4 h-4 mr-2" /> + 결재자 추가 + </Button> + </div> + + <div className="space-y-3"> + {aplns.map((apln, index) => ( + <div key={index} className="flex items-center gap-3 p-3 border rounded-lg"> + <Badge variant="outline">{index + 1}</Badge> + + <div className="flex-1 grid grid-cols-4 gap-3"> + <FormField + control={form.control} + name={`aplns.${index}.userId`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input placeholder="사용자 ID" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name={`aplns.${index}.emailAddress`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input placeholder="이메일 (선택)" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name={`aplns.${index}.role`} + render={({ field }) => ( + <FormItem> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="0">기안</SelectItem> + <SelectItem value="1">결재</SelectItem> + <SelectItem value="2">합의</SelectItem> + <SelectItem value="3">후결</SelectItem> + <SelectItem value="4">병렬합의</SelectItem> + <SelectItem value="7">병렬결재</SelectItem> + <SelectItem value="9">통보</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name={`aplns.${index}.opinion`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input placeholder="의견 (선택)" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <div className="flex items-center gap-2"> + <Badge variant="secondary"> + {getRoleText(apln.role)} + </Badge> + + {aplns.length > 1 && ( + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => removeApprovalLine(index)} + > + <Trash2 className="w-4 h-4" /> + </Button> + )} + </div> + </div> + ))} + </div> + </div> + + <Separator /> + + {/* 제출 버튼 */} + <div className="flex justify-end space-x-3"> + <Button + type="button" + variant="outline" + onClick={() => form.reset()} + disabled={isSubmitting} + > + 초기화 + </Button> + <Button type="submit" disabled={isSubmitting}> + {isSubmitting ? ( + <> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + 상신 중... + </> + ) : ( + '결재 상신' + )} + </Button> + </div> + </form> + </Form> + </CardContent> + </Card> + ); +}
\ No newline at end of file diff --git a/components/knox/approval/index.ts b/components/knox/approval/index.ts new file mode 100644 index 00000000..0bae08f1 --- /dev/null +++ b/components/knox/approval/index.ts @@ -0,0 +1,23 @@ +// Knox 결재 시스템 컴포넌트들 +export { default as ApprovalSubmit } from './ApprovalSubmit'; +export { default as ApprovalDetail } from './ApprovalDetail'; +export { default as ApprovalCancel } from './ApprovalCancel'; +export { default as ApprovalList } from './ApprovalList'; +export { default as ApprovalManager } from './ApprovalManager'; + +// Mock 데이터 및 유틸리티 함수들 +export * from './mocks/approval-mock'; + +// 타입 정의들 (re-export) +export type { + ApprovalLine, + SubmitApprovalRequest, + SubmitApprovalResponse, + ApprovalDetailResponse, + ApprovalContentResponse, + ApprovalStatusResponse, + CancelApprovalResponse, + SubmissionListResponse, + ApprovalHistoryResponse, + BaseResponse +} from '@/lib/knox-api/approval/approval';
\ No newline at end of file diff --git a/components/knox/approval/mocks/approval-mock.ts b/components/knox/approval/mocks/approval-mock.ts new file mode 100644 index 00000000..021eb925 --- /dev/null +++ b/components/knox/approval/mocks/approval-mock.ts @@ -0,0 +1,230 @@ +import { + ApprovalLine, + SubmitApprovalRequest, + SubmitApprovalResponse, + ApprovalDetailResponse, + ApprovalContentResponse, + ApprovalStatusResponse, + CancelApprovalResponse, + SubmissionListResponse, + ApprovalHistoryResponse +} from '@/lib/knox-api/approval/approval'; + +// Mock 데이터 생성 함수들 +export const createMockApprovalLine = (overrides?: Partial<ApprovalLine>): ApprovalLine => ({ + epId: '12345', + userId: 'user123', + emailAddress: 'user@example.com', + seq: '1', + role: '0', // 기안 + aplnStatsCode: '0', // 미결 + arbPmtYn: 'N', + contentsMdfyPmtYn: 'N', + aplnMdfyPmtYn: 'N', + opinion: '결재 요청드립니다.', + ...overrides +}); + +export const createMockSubmitApprovalRequest = (overrides?: Partial<SubmitApprovalRequest>): SubmitApprovalRequest => ({ + contents: '결재 요청 내용입니다.', + contentsType: 'TEXT', + docSecuType: 'PERSONAL', + notifyOption: '0', + urgYn: 'N', + sbmDt: '20241215120000', + timeZone: 'GMT+9', + docMngSaveCode: '0', + subject: '결재 요청 - 테스트', + sbmLang: 'ko', + apInfId: 'test-ap-inf-id-' + Date.now(), + importantYn: 'N', + aplns: [ + createMockApprovalLine({ seq: '1', role: '0' }), // 기안 + createMockApprovalLine({ seq: '2', role: '1', userId: 'approver1' }), // 결재 + createMockApprovalLine({ seq: '3', role: '1', userId: 'approver2' }), // 결재 + ], + ...overrides +}); + +export const mockSubmitApprovalResponse: SubmitApprovalResponse = { + result: 'SUCCESS', + data: { + apInfId: 'test-ap-inf-id-' + Date.now() + } +}; + +export const mockApprovalDetailResponse: ApprovalDetailResponse = { + result: 'SUCCESS', + data: { + contentsType: 'TEXT', + sbmDt: '20241215120000', + sbmLang: 'ko', + apInfId: 'test-ap-inf-id-123', + systemId: 'EVCP_SYSTEM', + notifyOption: '0', + urgYn: 'N', + docSecuType: 'PERSONAL', + status: '1', // 진행중 + timeZone: 'GMT+9', + subject: '결재 요청 - 테스트', + aplns: [ + createMockApprovalLine({ seq: '1', role: '0', aplnStatsCode: '1' }), // 기안 완료 + createMockApprovalLine({ seq: '2', role: '1', aplnStatsCode: '0', userId: 'approver1' }), // 결재 대기 + createMockApprovalLine({ seq: '3', role: '1', aplnStatsCode: '0', userId: 'approver2' }), // 결재 대기 + ], + attachments: [] + } +}; + +export const mockApprovalContentResponse: ApprovalContentResponse = { + result: 'SUCCESS', + data: { + contents: '결재 요청 내용입니다.\n\n상세한 내용은 다음과 같습니다:\n- 항목 1\n- 항목 2\n- 항목 3', + contentsType: 'TEXT', + apInfId: 'test-ap-inf-id-123' + } +}; + +export const mockApprovalStatusResponse: ApprovalStatusResponse = { + result: 'SUCCESS', + data: [ + { + apInfId: 'test-ap-inf-id-123', + docChgNum: '1', + status: '1' // 진행중 + } + ] +}; + +export const mockCancelApprovalResponse: CancelApprovalResponse = { + result: 'SUCCESS', + data: { + apInfId: 'test-ap-inf-id-123' + } +}; + +export const mockSubmissionListResponse: SubmissionListResponse = { + result: 'SUCCESS', + data: [ + { + apInfId: 'test-ap-inf-id-123', + subject: '결재 요청 - 테스트', + sbmDt: '20241215120000', + status: '1', + urgYn: 'N', + docSecuType: 'PERSONAL' + }, + { + apInfId: 'test-ap-inf-id-124', + subject: '결재 요청 - 테스트 2', + sbmDt: '20241214100000', + status: '2', + urgYn: 'Y', + docSecuType: 'CONFIDENTIAL' + } + ] +}; + +export const mockApprovalHistoryResponse: ApprovalHistoryResponse = { + result: 'SUCCESS', + data: [ + { + apInfId: 'test-ap-inf-id-123', + subject: '결재 요청 - 테스트', + sbmDt: '20241215120000', + status: '1', + actionType: 'SUBMIT', + actionDt: '20241215120000', + userId: 'submitter123' + }, + { + apInfId: 'test-ap-inf-id-124', + subject: '결재 요청 - 테스트 2', + sbmDt: '20241214100000', + status: '2', + actionType: 'APPROVE', + actionDt: '20241214150000', + userId: 'approver1' + } + ] +}; + +// Mock 함수들 +export const mockApprovalAPI = { + submitApproval: async (request: SubmitApprovalRequest): Promise<SubmitApprovalResponse> => { + // 실제 API 호출 시뮬레이션 + await new Promise(resolve => setTimeout(resolve, 1000)); + return mockSubmitApprovalResponse; + }, + + getApprovalDetail: async (apInfId: string): Promise<ApprovalDetailResponse> => { + await new Promise(resolve => setTimeout(resolve, 500)); + return mockApprovalDetailResponse; + }, + + getApprovalContent: async (apInfId: string): Promise<ApprovalContentResponse> => { + await new Promise(resolve => setTimeout(resolve, 300)); + return mockApprovalContentResponse; + }, + + getApprovalStatus: async (apInfIds: string[]): Promise<ApprovalStatusResponse> => { + await new Promise(resolve => setTimeout(resolve, 400)); + return mockApprovalStatusResponse; + }, + + cancelApproval: async (apInfId: string): Promise<CancelApprovalResponse> => { + await new Promise(resolve => setTimeout(resolve, 800)); + return mockCancelApprovalResponse; + }, + + getSubmissionList: async (): Promise<SubmissionListResponse> => { + await new Promise(resolve => setTimeout(resolve, 600)); + return mockSubmissionListResponse; + }, + + getApprovalHistory: async (): Promise<ApprovalHistoryResponse> => { + await new Promise(resolve => setTimeout(resolve, 700)); + return mockApprovalHistoryResponse; + } +}; + +// 상태 및 역할 텍스트 변환 함수들 +export const getStatusText = (status: string): string => { + const statusMap: Record<string, string> = { + '-3': '암호화실패', + '-2': '암호화중', + '-1': '예약상신', + '0': '보류', + '1': '진행중', + '2': '완결', + '3': '반려', + '4': '상신취소', + '5': '전결', + '6': '후완결' + }; + return statusMap[status] || '알 수 없음'; +}; + +export const getRoleText = (role: string): string => { + const roleMap: Record<string, string> = { + '0': '기안', + '1': '결재', + '2': '합의', + '3': '후결', + '4': '병렬합의', + '7': '병렬결재', + '9': '통보' + }; + return roleMap[role] || '알 수 없음'; +}; + +export const getApprovalStatusText = (status: string): string => { + const statusMap: Record<string, string> = { + '0': '미결', + '1': '결재', + '2': '반려', + '3': '전결', + '5': '자동결재' + }; + return statusMap[status] || '알 수 없음'; +};
\ No newline at end of file |
