summaryrefslogtreecommitdiff
path: root/lib/risk-management/table/risks-mail-dialog.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/risk-management/table/risks-mail-dialog.tsx')
-rw-r--r--lib/risk-management/table/risks-mail-dialog.tsx560
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;