summaryrefslogtreecommitdiff
path: root/components/knox/approval/ApprovalSubmit.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'components/knox/approval/ApprovalSubmit.tsx')
-rw-r--r--components/knox/approval/ApprovalSubmit.tsx508
1 files changed, 508 insertions, 0 deletions
diff --git a/components/knox/approval/ApprovalSubmit.tsx b/components/knox/approval/ApprovalSubmit.tsx
new file mode 100644
index 00000000..526a87f3
--- /dev/null
+++ b/components/knox/approval/ApprovalSubmit.tsx
@@ -0,0 +1,508 @@
+'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, submitSecurityApproval, 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개의 결재 경로가 필요합니다'),
+ // 첨부파일 (선택)
+ attachments: z.any().optional()
+});
+
+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: ''
+ }
+ ],
+ attachments: undefined
+ }
+ });
+
+ 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 attachmentsArray = data.attachments ? Array.from(data.attachments as FileList) : undefined;
+
+ 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,
+ attachments: attachmentsArray
+ }
+ : 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,
+ attachments: attachmentsArray
+ }
+ );
+
+ // API 호출 (보안 등급에 따라 분기)
+ const isSecure = data.docSecuType === 'CONFIDENTIAL' || data.docSecuType === 'CONFIDENTIAL_STRICT';
+
+ const response = useFakeData
+ ? await mockApprovalAPI.submitApproval(submitRequest)
+ : isSecure
+ ? await submitSecurityApproval(submitRequest, systemId)
+ : 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>
+
+ {/* 첨부 파일 */}
+ <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 />
+
+ {/* 결재 경로 */}
+ <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