diff options
| author | joonhoekim <26rote@gmail.com> | 2025-07-18 03:58:34 +0000 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-07-18 03:58:34 +0000 |
| commit | 3e59693e017742d971f490eb7c58870cb745a98d (patch) | |
| tree | f7f846613e40e4f058de70afca5809b8e6bd0e2d /components/knox/approval/ApprovalSubmit.tsx | |
| parent | 2ef02e27dbe639876fa3b90c30307dda183545ec (diff) | |
(김준회) 결재 모듈 개발
Diffstat (limited to 'components/knox/approval/ApprovalSubmit.tsx')
| -rw-r--r-- | components/knox/approval/ApprovalSubmit.tsx | 476 |
1 files changed, 476 insertions, 0 deletions
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 |
