summaryrefslogtreecommitdiff
path: root/components/knox/approval
diff options
context:
space:
mode:
Diffstat (limited to 'components/knox/approval')
-rw-r--r--components/knox/approval/ApprovalCancel.tsx341
-rw-r--r--components/knox/approval/ApprovalDetail.tsx362
-rw-r--r--components/knox/approval/ApprovalList.tsx322
-rw-r--r--components/knox/approval/ApprovalManager.tsx190
-rw-r--r--components/knox/approval/ApprovalSubmit.tsx476
-rw-r--r--components/knox/approval/index.ts23
-rw-r--r--components/knox/approval/mocks/approval-mock.ts230
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