diff options
Diffstat (limited to 'lib/risk-management/table/risks-mail-dialog.tsx')
| -rw-r--r-- | lib/risk-management/table/risks-mail-dialog.tsx | 560 |
1 files changed, 560 insertions, 0 deletions
diff --git a/lib/risk-management/table/risks-mail-dialog.tsx b/lib/risk-management/table/risks-mail-dialog.tsx new file mode 100644 index 00000000..8bee1191 --- /dev/null +++ b/lib/risk-management/table/risks-mail-dialog.tsx @@ -0,0 +1,560 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +'use client'; + +/* IMPORT */ +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { ChevronsUpDown, X } from 'lucide-react'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog'; +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from '@/components/ui/dropzone'; +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from '@/components/ui/file-list'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { format } from 'date-fns'; +import { getProcurementManagerList, modifyRiskEvents } from '../service'; +import { RISK_ADMIN_COMMENTS_LIST } from '@/config/risksConfig'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Textarea } from '@/components/ui/textarea'; +import { toast } from 'sonner'; +import { type RisksView, type User } from '@/db/schema'; +import { useForm } from 'react-hook-form'; +import { useEffect, useMemo, useState, useTransition } from 'react'; +import UserComboBox from './user-combo-box'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { se } from 'date-fns/locale'; + +// ---------------------------------------------------------------------------------------------------- + +/* TYPES */ +const risksMailFormSchema = z.object({ + managerId: z.number({ required_error: '구매 담당자를 반드시 선택해야 해요.' }), + adminComment: z.string().min(1, { message: '구매 담당자 의견을 반드시 작성해야 해요.' }), + attachment: z + .instanceof(File) + .refine((file) => file.size <= 10485760, { + message: '파일 크기는 10MB를 초과할 수 없어요.', + }) + .optional(), +}); + +type RisksMailFormData = z.infer<typeof risksMailFormSchema>; + +interface RisksMailDialogProps { + open: boolean, + onOpenChange: (open: boolean) => void, + riskDataList: RisksView[], + onSuccess: () => void, +}; + +// ---------------------------------------------------------------------------------------------------- + +/* CONSTATNS */ +const ALWAYS_CHECKED_TYPES = ['종합등급', '신용등급', '현금흐름등급', 'WATCH등급']; + +// ---------------------------------------------------------------------------------------------------- + +/* RISKS MAIL DIALOG COPONENT */ +function RisksMailDialog(props: RisksMailDialogProps) { + const { open, onOpenChange, riskDataList, onSuccess } = props; + const riskDataMap = useMemo(() => { + return riskDataList.reduce((acc, item) => { + if (!acc[item.vendorId]) { + acc[item.vendorId] = []; + } + acc[item.vendorId].push(item); + return acc; + }, {} as Record<string, typeof riskDataList>); + }, [riskDataList]); + const [isPending, startTransition] = useTransition(); + const form = useForm<RisksMailFormData>({ + resolver: zodResolver(risksMailFormSchema), + defaultValues: { + managerId: undefined, + adminComment: '', + attachment: undefined, + }, + }); + const selectedFile = form.watch('attachment'); + const [selectedVendorId, setSelectedVendorId] = useState<number | null>(riskDataList[0]?.vendorId ?? null); + const [selectedCommentType, setSelectedCommentType] = useState(''); + const [managerList, setManagerList] = useState<Partial<User>[]>([]); + const [isLoadingManagerList, setIsLoadingManagerList] = useState(false); + const [riskCheckMap, setRiskCheckMap] = useState<Record<string, boolean>>({}); + + useEffect(() => { + if (!selectedVendorId) { + return; + } + + const eventTypeMap = (riskDataMap[selectedVendorId] || []).reduce<Record<string, RisksView[]>>((acc, item) => { + if (!acc[item.eventType]) { + acc[item.eventType] = []; + } + acc[item.eventType].push(item); + return acc; + }, {}); + + const initialRiskCheckMap: Record<string, boolean> = {}; + Object.keys(eventTypeMap).forEach((type) => { + initialRiskCheckMap[type] = true; + }); + + setRiskCheckMap(initialRiskCheckMap); + setSelectedCommentType('기타'); + form.reset({ + managerId: undefined, + adminComment: '', + attachment: undefined, + }); + }, [open, selectedVendorId]); + + useEffect(() => { + if (open) { + startTransition(async () => { + try { + setIsLoadingManagerList(true); + form.reset({ + managerId: undefined, + adminComment: '', + attachment: undefined, + }); + setSelectedCommentType('기타'); + const managerList = await getProcurementManagerList(); + setManagerList(managerList); + } catch (error) { + console.error('Error in Loading Risk Event for Managing:', error); + toast.error(error instanceof Error ? error.message : '구매 담당자 목록을 불러오는 데 실패했어요.'); + } finally { + setIsLoadingManagerList(false); + } + }); + } + }, [open, form]); + + const formatBusinessNumber = (numberString: string) => { + if (!numberString) { + return '정보 없음'; + } + return /^\d{10}$/.test(numberString) + ? `${numberString.slice(0, 3)}-${numberString.slice(3, 5)}-${numberString.slice(5)}` + : numberString; + }; + + const handleCheckboxChange = (type: string) => { + if (ALWAYS_CHECKED_TYPES.includes(type)) { + return; + } + setRiskCheckMap(prev => ({ + ...prev, + [type]: !prev[type], + })); + }; + + const handleFileChange = (files: File[]) => { + if (files.length === 0) { + return; + } + const file = files[0]; + const maxFileSize = 10 * 1024 * 1024 + if (file.size > maxFileSize) { + toast.error('파일 크기는 10MB를 초과할 수 없어요.'); + return; + } + form.setValue('attachment', file); + form.clearErrors('attachment'); + } + + const removeFile = () => { + form.resetField('attachment'); + } + + const onSubmit = async (data: RisksMailFormData) => { + startTransition(async () => { + try { + if (!selectedVendorId) { + throw Error('선택된 협력업체가 존재하지 않아요.'); + } + + const newRiskEventData = { + managerId: data.managerId , + adminComment: data.adminComment, + }; + + await Promise.all( + (riskDataMap[selectedVendorId] ?? []).map(riskEvent => + modifyRiskEvents(riskEvent.id, newRiskEventData) + ) + ); + + const eventTypeMap = (riskDataMap[selectedVendorId] || []).reduce<Record<string, RisksView[]>>((acc, item) => { + if (!acc[item.eventType]) { + acc[item.eventType] = []; + } + acc[item.eventType].push(item); + return acc; + }, {}); + const filteredEventTypeMap: Record<string, RisksView[]> = {}; + Object.entries(eventTypeMap).forEach(([type, items]) => { + if (ALWAYS_CHECKED_TYPES.includes(type)) { + return; + } + if (riskCheckMap[type]) { + filteredEventTypeMap[type] = items; + } + }); + + const formData = new FormData(); + formData.append('vendorId', String(selectedVendorId)); + formData.append('managerId', String(data.managerId)); + formData.append('adminComment', data.adminComment); + if (data.attachment) { + formData.append('attachment', data.attachment); + } + formData.append('selectedEventTypeMap', JSON.stringify(filteredEventTypeMap)); + + const res = await fetch('/api/risks/send-risk-email', { + method: 'POST', + body: formData, + }); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.message || '리스크 알림 메일 전송에 실패했어요.'); + } + + toast.success('리스크 알림 메일이 구매 담당자에게 발송되었어요.'); + onSuccess(); + } catch (error) { + console.error('Error in Saving Risk Event:', error); + toast.error( + error instanceof Error ? error.message : '리스크 알림 메일 발송 중 오류가 발생했어요.', + ); + } + }) + } + + if (!open) { + return null; + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[90vh] flex flex-col"> + <DialogHeader className="flex-shrink-0"> + <DialogTitle className="font-bold"> + 리스크 알림 메일 발송 + </DialogTitle> + <DialogDescription> + 구매 담당자에게 리스크 알림 메일을 발송합니다. + </DialogDescription> + </DialogHeader> + <Tabs + value={selectedVendorId !== null ? String(selectedVendorId) : undefined} + onValueChange={(value) => setSelectedVendorId(value ? Number(value) : null)} + className="mb-4" + > + <TabsList> + {Object.entries(riskDataMap).map(([vendorId, items]) => ( + <TabsTrigger key={vendorId} value={vendorId}> + {items[0].vendorName} + </TabsTrigger> + ))} + </TabsList> + {Object.entries(riskDataMap).map(([vendorId, items]) => ( + <TabsContent key={vendorId} value={vendorId} className="overflow-y-auto max-h-[60vh]"> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0"> + <ScrollArea className="flex-1 pr-4 overflow-y-auto"> + <div className="space-y-6"> + <Card className="w-full"> + <CardHeader> + <CardTitle>협력업체 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-4 text-sm text-muted-foreground"> + <div className="grid grid-cols-2 gap-4"> + <div> + <span className="font-medium text-foreground">협력업체명: </span> + {items[0].vendorName ?? '정보 없음'} + </div> + <div> + <span className="font-medium text-foreground">사업자등록번호: </span> + {formatBusinessNumber(items[0].businessNumber ?? '')} + </div> + <div> + <span className="font-medium text-foreground">협력업체 코드: </span> + {items[0].vendorCode ?? '정보 없음'} + </div> + </div> + </CardContent> + </Card> + <Card className="w-full"> + <CardHeader> + <CardTitle>리스크 정보</CardTitle> + <CardDescription>메일로 전송할 리스크 정보를 선택하세요.</CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + {Object.entries( + items?.reduce<Record<string, typeof items>>((acc, item) => { + if (!acc[item.eventType]) acc[item.eventType] = []; + acc[item.eventType].push(item); + return acc; + }, {}) || {} + ).map(([eventType, groupedItems]) => ( + <Collapsible key={eventType} defaultOpen={false} className="rounded-md border gap-2 p-4 text-sm"> + <div className="flex items-center justify-between gap-4 px-4"> + <div className="flex items-center gap-2"> + <Checkbox + checked={riskCheckMap[eventType]} + disabled={ALWAYS_CHECKED_TYPES.includes(eventType)} + onCheckedChange={() => handleCheckboxChange(eventType)} + /> + <span className="text-sm font-semibold">{eventType}</span> + </div> + <CollapsibleTrigger className="flex justify-between items-center"> + <Button type="button" variant="ghost" size="icon" className="size-8"> + <ChevronsUpDown /> + </Button> + </CollapsibleTrigger> + </div> + <CollapsibleContent> + {/* Table로 변경할 것 */} + <div className="flex items-center justify-between rounded-md border my-2 px-4 py-2 text-sm"> + <div className="font-bold">신용평가사</div> + <div className="font-bold">상세 내용</div> + <div className="font-bold">발생일자</div> + </div> + {groupedItems.map(item => ( + <div key={item.id} className="flex items-center justify-between rounded-md border gap-2 px-4 py-2 text-sm"> + <Badge variant="secondary">{item.provider}</Badge> + <div className="text-sm text-muted-foreground">{item.content}</div> + <div>{format(item.occuredAt, 'yyyy-MM-dd')}</div> + </div> + ))} + </CollapsibleContent> + </Collapsible> + ))} + </CardContent> + </Card> + <Card className="w-full"> + <CardHeader> + <CardTitle>메일 발송 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="managerId" + render={({ field }) => ( + <FormItem> + <FormLabel>구매 담당자</FormLabel> + <UserComboBox + users={managerList} + value={field.value ?? null} + onChange={field.onChange} + placeholder={isLoadingManagerList ? '구매 담당자 로딩 중...' : '구매 담당자 선택...'} + disabled={isPending || isLoadingManagerList} + /> + <FormControl> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <div className="flex flex-col gap-4"> + <FormItem> + <FormLabel>관리 담당자 의견</FormLabel> + <FormControl> + <Select + onValueChange={(value) => { + setSelectedCommentType(value); + if (value !== '기타') { + form.setValue('adminComment', value); + } else { + form.setValue('adminComment', ''); + } + }} + value={selectedCommentType} + > + <SelectTrigger> + <SelectValue placeholder="의견 선택" /> + </SelectTrigger> + <SelectContent> + {RISK_ADMIN_COMMENTS_LIST.map((comment) => ( + <SelectItem key={comment} value={comment}> + {comment} + </SelectItem> + ))} + </SelectContent> + </Select> + </FormControl> + </FormItem> + {selectedCommentType === '기타' && ( + <FormField + control={form.control} + name="adminComment" + render={({ field }) => ( + <FormItem> + <FormControl> + <Textarea + placeholder="관리 담당자 의견을 입력하세요." + {...field} + value={field.value ?? ''} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + </div> + </div> + <FormField + control={form.control} + name="attachment" + render={() => ( + <FormItem> + <FormLabel>첨부파일</FormLabel> + <FormControl> + <div className="space-y-3"> + <Dropzone + onDrop={(acceptedFiles) => { + handleFileChange(acceptedFiles) + }} + accept={{ + 'application/pdf': ['.pdf'], + 'application/msword': ['.doc'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], + 'application/vnd.ms-excel': ['.xls'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], + 'application/vnd.ms-powerpoint': ['.ppt'], + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'], + 'application/zip': ['.zip'], + 'application/x-rar-compressed': ['.rar'] + }} + maxSize={10 * 1024 * 1024} + multiple={false} + disabled={isPending} + > + <DropzoneZone className="flex flex-col items-center gap-2"> + <DropzoneUploadIcon /> + <DropzoneTitle>클릭하여 파일 선택 또는 드래그 앤 드롭</DropzoneTitle> + <DropzoneDescription> + PDF, DOC, XLS, PPT 등 (최대 10MB, 파일 1개) + </DropzoneDescription> + <DropzoneInput /> + </DropzoneZone> + </Dropzone> + {selectedFile && ( + <div className="space-y-2"> + <FileListHeader> + 선택된 파일 + </FileListHeader> + <FileList> + <FileListItem className="flex items-center justify-between gap-3"> + <FileListIcon /> + <FileListInfo> + <FileListName>{selectedFile.name}</FileListName> + <FileListDescription> + <FileListSize>{selectedFile.size}</FileListSize> + </FileListDescription> + </FileListInfo> + <FileListAction + onClick={removeFile} + disabled={isPending} + > + <X className="h-4 w-4" /> + </FileListAction> + </FileListItem> + </FileList> + </div> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </CardContent> + </Card> + </div> + </ScrollArea> + <DialogFooter className="flex-shrink-0 mt-4 pt-4 border-t"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isPending} + > + 취소 + </Button> + <Button type="submit" disabled={isPending || isLoadingManagerList}> + {isLoadingManagerList ? '로딩 중...' : isPending ? '저장 중...' : '메일 발송'} + </Button> + </DialogFooter> + </form> + </Form> + </TabsContent> + ))} + </Tabs> + </DialogContent> + </Dialog> + ) +} + +// ---------------------------------------------------------------------------------------------------- + +/* EXPORT */ +export default RisksMailDialog; |
