From 3e59693e017742d971f490eb7c58870cb745a98d Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Fri, 18 Jul 2025 03:58:34 +0000
Subject: (김준회) 결재 모듈 개발
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 components/knox/approval/ApprovalSubmit.tsx | 476 ++++++++++++++++++++++++++++
 1 file changed, 476 insertions(+)
 create mode 100644 components/knox/approval/ApprovalSubmit.tsx

(limited to 'components/knox/approval/ApprovalSubmit.tsx')

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
-- 
cgit v1.2.3


From 7f0be155a7b1e94b56913d42010294d5f14b14c4 Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Fri, 18 Jul 2025 13:47:39 +0900
Subject: 버그픽스-tailwind scanner 정규식 매칭 오류 처리
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 components/knox/approval/ApprovalSubmit.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

(limited to 'components/knox/approval/ApprovalSubmit.tsx')

diff --git a/components/knox/approval/ApprovalSubmit.tsx b/components/knox/approval/ApprovalSubmit.tsx
index 3b5aa230..37779ce3 100644
--- a/components/knox/approval/ApprovalSubmit.tsx
+++ b/components/knox/approval/ApprovalSubmit.tsx
@@ -142,7 +142,7 @@ export default function ApprovalSubmit({
             ...data,
             urgYn: data.urgYn ? 'Y' : 'N',
             importantYn: data.importantYn ? 'Y' : 'N',
-            sbmDt: new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14),
+            sbmDt: new Date().toISOString().replace(/-|:|T/g, '').slice(0, 14),
             apInfId: 'test-ap-inf-id-' + Date.now(),
             aplns: approvalLines
           }
-- 
cgit v1.2.3


From 5cb225e9cd6b0ba2f52572a3afa0de6e5b2a2ece Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Fri, 18 Jul 2025 16:32:22 +0900
Subject: 파일첨부 API 처리, 텍스트 변경
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 app/[lng]/admin/approval-test/page.tsx      |  6 ++--
 components/knox/approval/ApprovalDetail.tsx | 54 +++++++++++++++++++++++++++--
 components/knox/approval/ApprovalSubmit.tsx | 46 ++++++++++++++++++++----
 3 files changed, 94 insertions(+), 12 deletions(-)

(limited to 'components/knox/approval/ApprovalSubmit.tsx')

diff --git a/app/[lng]/admin/approval-test/page.tsx b/app/[lng]/admin/approval-test/page.tsx
index de0e2b5a..f044d87d 100644
--- a/app/[lng]/admin/approval-test/page.tsx
+++ b/app/[lng]/admin/approval-test/page.tsx
@@ -3,7 +3,7 @@ import ApprovalManager from '@/components/knox/approval/ApprovalManager';
 
 export const metadata: Metadata = {
   title: 'Knox 결재 시스템 테스트 | Admin',
-  description: 'Knox API를 사용한 결재 시스템의 모든 기능을 테스트할 수 있는 페이지입니다.',
+  description: 'Knox API를 사용한 결재 시스템 기능 테스트용',
 };
 
 export default function ApprovalTestPage() {
@@ -14,9 +14,9 @@ export default function ApprovalTestPage() {
         <div className="space-y-2">
           <h1 className="text-3xl font-bold tracking-tight">Knox 결재 시스템 테스트</h1>
           <p className="text-muted-foreground">
-            Knox API를 사용한 결재 시스템의 모든 기능을 테스트할 수 있습니다.
+            Knox API를 사용한 결재 시스템 컴포넌트입니다.
             <br />
-            테스트 모드가 기본적으로 활성화되어 있으며, 실제 API 대신 가짜 데이터를 사용합니다.
+            테스트 모드가 기본적으로 활성화되어 있으며, 테스트 모드에서는 실제 API 대신 모킹 데이터를 사용
           </p>
         </div>
 
diff --git a/components/knox/approval/ApprovalDetail.tsx b/components/knox/approval/ApprovalDetail.tsx
index 034bde7d..6db43cbe 100644
--- a/components/knox/approval/ApprovalDetail.tsx
+++ b/components/knox/approval/ApprovalDetail.tsx
@@ -28,6 +28,15 @@ interface ApprovalDetailData {
   content: ApprovalContentResponse['data'];
 }
 
+// 첨부파일 타입 (가이드에 명확히 정의되어 있지 않아 필드 일부 추정)
+interface ApprovalAttachment {
+  fileName?: string;
+  fileSize?: string;
+  downloadUrl?: string;
+  fileId?: string;
+  [key: string]: unknown; // 기타 필드 허용
+}
+
 export default function ApprovalDetail({ 
   useFakeData = false, 
   systemId = 'EVCP_SYSTEM',
@@ -126,6 +135,43 @@ export default function ApprovalDetail({
     }
   };
 
+  // 첨부파일 다운로드 헬퍼
+  const handleDownload = async (attachment: ApprovalAttachment) => {
+    try {
+      // 1) downloadUrl 이 이미 포함된 경우
+      if (attachment.downloadUrl) {
+        window.open(attachment.downloadUrl, '_blank');
+        return;
+      }
+
+      // 2) fileId + 별도 엔드포인트 조합 (가이드에 명시되지 않았으므로 best-effort 처리)
+      if (attachment.fileId) {
+        const url = `${process.env.KNOX_API_BASE_URL}/approval/api/v2.0/attachments/${attachment.fileId}`;
+        const resp = await fetch(url, {
+          method: 'GET',
+          headers: {
+            'System-ID': systemId,
+          },
+        });
+        if (!resp.ok) throw new Error('다운로드 실패');
+
+        // blob 생성 후 브라우저 다운로드
+        const blob = await resp.blob();
+        const link = document.createElement('a');
+        link.href = URL.createObjectURL(blob);
+        link.download = attachment.fileName || 'attachment';
+        link.click();
+        URL.revokeObjectURL(link.href);
+        return;
+      }
+
+      toast.error('다운로드 URL 정보를 찾을 수 없습니다.');
+    } catch (err) {
+      console.error('첨부파일 다운로드 오류:', err);
+      toast.error('첨부파일 다운로드 중 오류가 발생했습니다.');
+    }
+  };
+
   // 초기 로딩 (initialApInfId가 있는 경우)
   useEffect(() => {
     if (initialApInfId) {
@@ -338,14 +384,18 @@ export default function ApprovalDetail({
                   <h3 className="text-lg font-semibold">첨부파일</h3>
                   
                   <div className="space-y-2">
-                    {approvalData.detail.attachments.map((attachment: any, index: number) => (
+                    {approvalData.detail.attachments.map((attachment: ApprovalAttachment, index: number) => (
                       <div key={index} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
                         <FileText className="w-4 h-4 text-gray-500" />
                         <div className="flex-1">
                           <p className="text-sm font-medium">{attachment.fileName || `첨부파일 ${index + 1}`}</p>
                           <p className="text-xs text-gray-500">{attachment.fileSize || '크기 정보 없음'}</p>
                         </div>
-                        <Button variant="outline" size="sm">
+                        <Button 
+                          variant="outline" 
+                          size="sm" 
+                          onClick={() => handleDownload(attachment)}
+                        >
                           다운로드
                         </Button>
                       </div>
diff --git a/components/knox/approval/ApprovalSubmit.tsx b/components/knox/approval/ApprovalSubmit.tsx
index 37779ce3..526a87f3 100644
--- a/components/knox/approval/ApprovalSubmit.tsx
+++ b/components/knox/approval/ApprovalSubmit.tsx
@@ -17,7 +17,7 @@ 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 { submitApproval, submitSecurityApproval, createSubmitApprovalRequest, createApprovalLine } from '@/lib/knox-api/approval/approval';
 import type { ApprovalLine, SubmitApprovalRequest } from '@/lib/knox-api/approval/approval';
 
 // Mock 데이터
@@ -40,7 +40,9 @@ const formSchema = z.object({
     role: z.enum(['0', '1', '2', '3', '4', '7', '9']),
     seq: z.string(),
     opinion: z.string().optional()
-  })).min(1, '최소 1개의 결재 경로가 필요합니다')
+  })).min(1, '최소 1개의 결재 경로가 필요합니다'),
+  // 첨부파일 (선택)
+  attachments: z.any().optional()
 });
 
 type FormData = z.infer<typeof formSchema>;
@@ -80,7 +82,8 @@ export default function ApprovalSubmit({
           seq: '1',
           opinion: ''
         }
-      ]
+      ],
+      attachments: undefined
     }
   });
 
@@ -137,6 +140,8 @@ export default function ApprovalSubmit({
       );
 
       // 상신 요청 생성
+      const attachmentsArray = data.attachments ? Array.from(data.attachments as FileList) : undefined;
+
       const submitRequest: SubmitApprovalRequest = useFakeData
         ? {
             ...data,
@@ -144,7 +149,8 @@ export default function ApprovalSubmit({
             importantYn: data.importantYn ? 'Y' : 'N',
             sbmDt: new Date().toISOString().replace(/-|:|T/g, '').slice(0, 14),
             apInfId: 'test-ap-inf-id-' + Date.now(),
-            aplns: approvalLines
+            aplns: approvalLines,
+            attachments: attachmentsArray
           }
         : await createSubmitApprovalRequest(
             data.contents,
@@ -158,14 +164,19 @@ export default function ApprovalSubmit({
               notifyOption: data.notifyOption,
               docMngSaveCode: data.docMngSaveCode,
               sbmLang: data.sbmLang,
-              timeZone: data.timeZone
+              timeZone: data.timeZone,
+              attachments: attachmentsArray
             }
           );
 
-      // API 호출
+      // API 호출 (보안 등급에 따라 분기)
+      const isSecure = data.docSecuType === 'CONFIDENTIAL' || data.docSecuType === 'CONFIDENTIAL_STRICT';
+
       const response = useFakeData
         ? await mockApprovalAPI.submitApproval(submitRequest)
-        : await submitApproval(submitRequest, systemId);
+        : isSecure
+          ? await submitSecurityApproval(submitRequest, systemId)
+          : await submitApproval(submitRequest, systemId);
 
       if (response.result === 'SUCCESS') {
         setSubmitResult({ apInfId: response.data.apInfId });
@@ -333,6 +344,27 @@ export default function ApprovalSubmit({
                   )}
                 />
               </div>
+
+              {/* 첨부 파일 */}
+              <FormField
+                control={form.control}
+                name="attachments"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>첨부 파일</FormLabel>
+                    <FormControl>
+                      <Input
+                        type="file"
+                        multiple
+                        onChange={(e) => field.onChange(e.target.files)}
+                      />
+                    </FormControl>
+                    <FormDescription>필요 시 파일을 선택하세요. (다중 선택 가능)</FormDescription>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
             </div>
 
             <Separator />
-- 
cgit v1.2.3

