"use client" import * as React from "react" import { CalendarIcon, X, Plus, Trash2, Check, Search } from "lucide-react" import { useForm, useFieldArray } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { format } from "date-fns" import { z } from "zod" import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog" import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription, } from "@/components/ui/form" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { Calendar } from "@/components/ui/calendar" import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover" import { Checkbox } from "@/components/ui/checkbox" import { Badge } from "@/components/ui/badge" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command" import { toast } from "sonner" import { getSiteVisitRequestAction, getUsersForSiteVisitAction } from "@/lib/site-visit/service" import { cn } from "@/lib/utils" import { Dropzone, DropzoneDescription, DropzoneInput, DropzoneTitle, DropzoneUploadIcon, DropzoneZone, } from "@/components/ui/dropzone" // 방문실사 요청 폼 스키마 const siteVisitRequestSchema = z.object({ // 실사 기간 inspectionDuration: z.number().int().positive("실사 기간은 1일 이상이어야 합니다."), // 실사 요청일 requestedStartDate: z.date({ required_error: "실사 시작일을 선택해주세요.", }), requestedEndDate: z.date({ required_error: "실사 종료일을 선택해주세요.", }), // SHI 실사참석 예정부문 shiAttendees: z.object({ technicalSales: z.object({ checked: z.boolean().default(false), attendees: z.array(z.object({ name: z.string().min(1, "이름을 입력해주세요."), department: z.string().optional(), email: z.string().email("유효한 이메일을 입력해주세요.").or(z.string().length(0)), })).default([]), }).default({ checked: false, attendees: [] }), design: z.object({ checked: z.boolean().default(false), attendees: z.array(z.object({ name: z.string().min(1, "이름을 입력해주세요."), department: z.string().optional(), email: z.string().email("유효한 이메일을 입력해주세요.").or(z.string().length(0)), })).default([]), }).default({ checked: false, attendees: [] }), procurement: z.object({ checked: z.boolean().default(false), attendees: z.array(z.object({ name: z.string().min(1, "이름을 입력해주세요."), department: z.string().optional(), email: z.string().email("유효한 이메일을 입력해주세요.").or(z.string().length(0)), })).default([]), }).default({ checked: false, attendees: [] }), quality: z.object({ checked: z.boolean().default(false), attendees: z.array(z.object({ name: z.string().min(1, "이름을 입력해주세요."), department: z.string().optional(), email: z.string().email("유효한 이메일을 입력해주세요.").or(z.string().length(0)), })).default([]), }).default({ checked: false, attendees: [] }), production: z.object({ checked: z.boolean().default(false), attendees: z.array(z.object({ name: z.string().min(1, "이름을 입력해주세요."), department: z.string().optional(), email: z.string().email("유효한 이메일을 입력해주세요.").or(z.string().length(0)), })).default([]), }).default({ checked: false, attendees: [] }), commissioning: z.object({ checked: z.boolean().default(false), attendees: z.array(z.object({ name: z.string().min(1, "이름을 입력해주세요."), department: z.string().optional(), email: z.string().email("유효한 이메일을 입력해주세요.").or(z.string().length(0)), })).default([]), }).default({ checked: false, attendees: [] }), other: z.object({ checked: z.boolean().default(false), attendees: z.array(z.object({ name: z.string().min(1, "이름을 입력해주세요."), department: z.string().optional(), email: z.string().email("유효한 이메일을 입력해주세요.").or(z.string().length(0)), })).default([]), }).default({ checked: false, attendees: [] }), }), // SHI 참석자 정보 (JSON 형태로 저장) - 기존 필드 유지 shiAttendeeDetails: z.string().optional(), // 협력업체 요청정보 및 자료 vendorRequests: z.object({ availableDates: z.boolean().default(false), factoryName: z.boolean().default(false), factoryLocation: z.boolean().default(false), factoryAddress: z.boolean().default(false), factoryPicName: z.boolean().default(false), factoryPicPhone: z.boolean().default(false), factoryPicEmail: z.boolean().default(false), factoryDirections: z.boolean().default(false), accessProcedure: z.boolean().default(false), other: z.boolean().default(false), }), // 기타 요청사항 otherVendorRequests: z.string().optional(), // 추가 요청사항 additionalRequests: z.string().optional(), }).refine((data) => { // 종료일이 시작일보다 이후여야 함 if (data.requestedStartDate && data.requestedEndDate) { return data.requestedEndDate >= data.requestedStartDate; } return true; }, { message: "종료일은 시작일보다 이후여야 합니다.", path: ["requestedEndDate"], }).refine((data) => { // SHI 참석자 정보 검증: 부서 상관없이 전체 참석자가 최소 1명 이상이어야 함 const totalAttendees = Object.values(data.shiAttendees).reduce((total, attendee) => { if (attendee.checked && attendee.attendees.length > 0) { return total + attendee.attendees.length; } return total; }, 0); return totalAttendees >= 1; }, { message: "참석자는 부서 상관없이 최소 1명 이상 필수입니다.", path: ["shiAttendees"], }) export type SiteVisitRequestFormValues = z.infer // 사용자 타입 정의 interface SiteVisitUser { id: number; name: string; email: string; deptName: string | null; } // 참석자 섹션 컴포넌트 function AttendeeSection({ form, itemKey, label, isPending, }: { form: ReturnType> itemKey: keyof SiteVisitRequestFormValues['shiAttendees'] label: string isPending: boolean }) { const { fields, append, remove } = useFieldArray({ control: form.control, name: `shiAttendees.${itemKey}.attendees` as any, }); const isChecked = form.watch(`shiAttendees.${itemKey}.checked`); const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); const [searchQuery, setSearchQuery] = React.useState(""); const [users, setUsers] = React.useState([]); const [isLoadingUsers, setIsLoadingUsers] = React.useState(false); const loadUsers = React.useCallback(async () => { setIsLoadingUsers(true); try { const result = await getUsersForSiteVisitAction( searchQuery.trim() || undefined ); if (result.success && result.data) { setUsers(result.data); } } catch (error) { console.error("사용자 목록 로드 오류:", error); toast.error("사용자 목록을 불러오는데 실패했습니다."); } finally { setIsLoadingUsers(false); } }, [searchQuery]); // 사용자 목록 가져오기 React.useEffect(() => { if (isPopoverOpen && isChecked) { loadUsers(); } }, [isPopoverOpen, isChecked, loadUsers]); // 검색 쿼리 변경 시 사용자 목록 다시 로드 (debounce) React.useEffect(() => { if (!isPopoverOpen || !isChecked) return; const timer = setTimeout(() => { loadUsers(); }, 300); return () => clearTimeout(timer); }, [searchQuery, isPopoverOpen, isChecked, loadUsers]); const handleUserSelect = (user: SiteVisitUser) => { // 현재 폼의 attendees 값 가져오기 const currentAttendees = form.getValues(`shiAttendees.${itemKey}.attendees`) as Array<{ name: string; department?: string; email: string; }>; // 이미 선택된 사용자인지 확인 const existingIndex = currentAttendees.findIndex( (attendee) => attendee.email === user.email ); if (existingIndex >= 0) { // 이미 선택된 경우 제거 remove(existingIndex); } else { // 새로 추가 append({ name: user.name, department: user.deptName || "", email: user.email, }); } }; const isUserSelected = (userEmail: string) => { const currentAttendees = form.getValues(`shiAttendees.${itemKey}.attendees`) as Array<{ name: string; department?: string; email: string; }>; return currentAttendees.some((attendee) => attendee.email === userEmail); }; return (
( { field.onChange(checked); // 체크 해제 시 참석자 목록 초기화 if (!checked) { form.setValue(`shiAttendees.${itemKey}.attendees` as any, []); setIsPopoverOpen(false); } }} disabled={isPending} /> {label} )} />
{isChecked && (
참석인원: {fields.length}명
)}
{isChecked && (
{/* 사용자 선택 UI */} {isLoadingUsers ? "로딩 중..." : "검색 결과가 없습니다."} {users.map((user) => { const selected = isUserSelected(user.email); return ( handleUserSelect(user)} className="cursor-pointer" >
{user.name} {user.deptName && ( ({user.deptName}) )}
{user.email}
); })}
{/* 선택된 사용자 목록 */} {fields.length > 0 && (
{fields.map((fieldItem, index) => { // 폼에서 실제 값을 가져오기 const attendeesArray = form.watch(`shiAttendees.${itemKey}.attendees` as any) as Array<{ name: string; department?: string; email: string; }>; const attendee = attendeesArray[index]; if (!attendee) return null; return (
{attendee.name} {attendee.department && ( ({attendee.department}) )}
{attendee.email}
); })}
)}
)}
); } interface SiteVisitDialogProps { isOpen: boolean onClose: () => void onSubmit: (data: SiteVisitRequestFormValues, attachments?: File[]) => Promise investigation: { id: number investigationMethod?: "PURCHASE_SELF_EVAL" | "DOCUMENT_EVAL" | "PRODUCT_INSPECTION" | "SITE_VISIT_EVAL" investigationAddress?: string investigationNotes?: string vendorName: string vendorCode: string projectName?: string projectCode?: string pqItems?: Array<{itemCode: string, itemName: string}> | null } isReinspection?: boolean // 재실사 모드 플래그 } export function SiteVisitDialog({ isOpen, onClose, onSubmit, investigation, isReinspection = false, }: SiteVisitDialogProps) { const [isPending, setIsPending] = React.useState(false) const [selectedFiles, setSelectedFiles] = React.useState([]) const form = useForm({ resolver: zodResolver(siteVisitRequestSchema), defaultValues: { inspectionDuration: 1, requestedStartDate: undefined, requestedEndDate: undefined, shiAttendees: { technicalSales: { checked: false, attendees: [] }, design: { checked: false, attendees: [] }, procurement: { checked: false, attendees: [] }, quality: { checked: false, attendees: [] }, production: { checked: false, attendees: [] }, commissioning: { checked: false, attendees: [] }, other: { checked: false, attendees: [] }, }, shiAttendeeDetails: "", vendorRequests: { availableDates: false, factoryName: false, factoryLocation: false, factoryAddress: false, factoryPicName: false, factoryPicPhone: false, factoryPicEmail: false, factoryDirections: false, accessProcedure: false, other: false, }, otherVendorRequests: "", additionalRequests: "", }, }) // Dialog가 열릴 때마다 폼 재설정 및 기존 요청 로딩 React.useEffect(() => { if (isOpen) { const loadExistingRequest = async () => { try { // 기존 방문실사 요청이 있는지 확인하고 최신 것을 로드 const existingRequest = await getSiteVisitRequestAction(investigation.id) if (existingRequest.success && existingRequest.data) { // 기존 데이터를 form에 로드 const data = existingRequest.data form.reset({ inspectionDuration: typeof data.inspectionDuration === 'number' ? data.inspectionDuration : (parseFloat(String(data.inspectionDuration || '1')) || 1), requestedStartDate: data.requestedStartDate ? new Date(data.requestedStartDate) : undefined, requestedEndDate: data.requestedEndDate ? new Date(data.requestedEndDate) : undefined, shiAttendees: (() => { // 기존 데이터 형식 변환 (호환성 유지) if (data.shiAttendees) { const converted: any = {}; Object.keys(data.shiAttendees).forEach((key) => { const oldData = (data.shiAttendees as any)[key]; if (oldData && typeof oldData === 'object') { // 기존 형식 {checked, count, details} → 새 형식 {checked, attendees} if (oldData.attendees && Array.isArray(oldData.attendees)) { converted[key] = oldData; // 이미 새 형식 } else { // 기존 형식 변환 converted[key] = { checked: oldData.checked || false, attendees: oldData.count > 0 && oldData.details ? [{ name: oldData.details.split('/')[0]?.trim() || '', department: oldData.details.split('/')[1]?.trim() || '', email: '' }] : [] }; } } else { converted[key] = { checked: false, attendees: [] }; } }); return converted; } return { technicalSales: { checked: false, attendees: [] }, design: { checked: false, attendees: [] }, procurement: { checked: false, attendees: [] }, quality: { checked: false, attendees: [] }, production: { checked: false, attendees: [] }, commissioning: { checked: false, attendees: [] }, other: { checked: false, attendees: [] }, }; })(), shiAttendeeDetails: (data as any).shiAttendeeDetails || "", vendorRequests: (data.vendorRequests && typeof data.vendorRequests === 'object') ? data.vendorRequests : { availableDates: false, factoryName: false, factoryLocation: false, factoryAddress: false, factoryPicName: false, factoryPicPhone: false, factoryPicEmail: false, factoryDirections: false, accessProcedure: false, other: false, }, otherVendorRequests: (data as any).otherVendorRequests || "", additionalRequests: data.additionalRequests || "", }) return } // 기본값으로 폼 초기화 (기존 요청이 없는 경우) form.reset({ inspectionDuration: 1, requestedStartDate: undefined, requestedEndDate: undefined, shiAttendees: { technicalSales: { checked: false, attendees: [] }, design: { checked: false, attendees: [] }, procurement: { checked: false, attendees: [] }, quality: { checked: false, attendees: [] }, production: { checked: false, attendees: [] }, commissioning: { checked: false, attendees: [] }, other: { checked: false, attendees: [] }, }, shiAttendeeDetails: "", vendorRequests: { availableDates: false, factoryName: false, factoryLocation: false, factoryAddress: false, factoryPicName: false, factoryPicPhone: false, factoryPicEmail: false, factoryDirections: false, accessProcedure: false, other: false, }, otherVendorRequests: "", additionalRequests: "", }) } catch (error) { console.error("방문실사 요청 데이터 로드 중 오류:", error) toast.error("방문실사 요청 데이터 로드 중 오류가 발생했습니다.") onClose() return } } loadExistingRequest() setSelectedFiles([]) } }, [isOpen, form, investigation.id, onClose]) async function handleSubmit(data: SiteVisitRequestFormValues) { setIsPending(true) try { await onSubmit(data, selectedFiles) toast.success(isReinspection ? "재실사 요청이 성공적으로 발송되었습니다." : "방문실사 요청이 성공적으로 발송되었습니다.") } catch (error) { toast.error(isReinspection ? "재실사 요청 발송 중 오류가 발생했습니다." : "방문실사 요청 발송 중 오류가 발생했습니다.") console.error("방문실사 요청 오류:", error) } finally { setIsPending(false) } } const handleDropAccepted = (files: File[]) => { setSelectedFiles(prev => [...prev, ...files]) toast.success(`${files.length}개 파일이 추가되었습니다.`) } const handleDropRejected = (files: unknown[]) => { toast.error(`${files.length}개 파일이 거부되었습니다. 파일 크기나 형식을 확인해주세요.`) } const removeFile = (index: number) => { setSelectedFiles(prev => prev.filter((_, i) => i !== index)) } const getInvestigationMethodLabel = (method: string) => { switch (method) { case "PURCHASE_SELF_EVAL": return "구매자체평가" case "DOCUMENT_EVAL": return "서류평가" case "PRODUCT_INSPECTION": return "제품검사평가" case "SITE_VISIT_EVAL": return "방문실사평가" default: return method } } return ( !open && onClose()}> {isReinspection ? "재실사 요청 생성" : "방문실사 요청 생성"} {getInvestigationMethodLabel(investigation.investigationMethod || "")} {isReinspection ? "협력업체에 재실사 요청을 생성하고, 협력업체가 입력할 정보 항목을 설정합니다." : "협력업체에 방문실사 요청을 생성하고, 협력업체가 입력할 정보 항목을 설정합니다." }
{/* QM 의견 (있는 경우에만 표시) */}
QM 의견

{investigation.investigationNotes}

{/* 대상업체 정보 */}
대상업체
{investigation.vendorName}
({investigation.vendorCode})
대상품목
{investigation.pqItems && investigation.pqItems.length > 0 ? investigation.pqItems.map((item, index) => (
{item.itemCode} {item.itemName}
)) : "-" }
{/* 실사방법 */} {/*
실사방법
{getInvestigationMethodLabel(investigation.investigationMethod || "")}
*/}
{/* 실사기간 */} ( 실사기간 (W/D 기준)
{ const value = parseInt(e.target.value, 10); if (Number.isNaN(value) || value < 1) { field.onChange(1); } else { field.onChange(value); // 실사 기간이 변경되면 종료일 자동 계산 const startDate = form.getValues('requestedStartDate'); if (startDate) { const endDate = new Date(startDate); endDate.setDate(endDate.getDate() + value - 1); form.setValue('requestedEndDate', endDate); } } }} disabled={isPending} className="w-24" />
)} /> {/* 실사요청일 */} ( 실사 시작일 { field.onChange(date); // 시작일이 변경되면 종료일 자동 계산 if (date) { const duration = form.getValues('inspectionDuration') || 1; const endDate = new Date(date); endDate.setDate(endDate.getDate() + duration - 1); form.setValue('requestedEndDate', endDate); // 실사 기간도 재계산 const currentEndDate = form.getValues('requestedEndDate'); if (currentEndDate) { const diffTime = currentEndDate.getTime() - date.getTime(); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; if (diffDays > 0) { form.setValue('inspectionDuration', diffDays); } } } }} disabled={(date) => date < new Date()} initialFocus /> )} /> { const startDate = form.watch('requestedStartDate'); return ( 실사 종료일 { field.onChange(date); // 종료일이 변경되면 실사 기간 자동 계산 if (date && startDate) { const diffTime = date.getTime() - startDate.getTime(); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; if (diffDays > 0) { form.setValue('inspectionDuration', diffDays); } } }} disabled={(date) => { const today = new Date(); today.setHours(0, 0, 0, 0); if (date < today) return true; if (startDate && date < startDate) return true; return false; }} initialFocus /> ); }} />
{/* SHI 실사참석 예정부문 */}
SHI 실사 참석 인원 정보 (*)
삼성중공업에 어떤 부문의 담당자가 몇 명 실사 참석 예정인지에 대한 정보를 입력하세요.
부서 상관없이 최소 1명 이상 필수입니다.
{/* 전체 참석자 상세정보 */} ( 전체 참석자 상세정보 (선택사항)