From 53ad72732f781e6c6d5ddb3776ea47aec010af8e Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 4 Aug 2025 09:39:21 +0000 Subject: (최겸) PQ/실사 수정 및 개발 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/site-visit/client-site-visit-wrapper.tsx | 474 +++++++++++++++++++ lib/site-visit/service.ts | 668 +++++++++++++++++++++++++++ lib/site-visit/shi-attendees-dialog.tsx | 152 ++++++ lib/site-visit/site-visit-detail-dialog.tsx | 266 +++++++++++ lib/site-visit/vendor-info-sheet.tsx | 442 ++++++++++++++++++ lib/site-visit/vendor-info-view-dialog.tsx | 279 +++++++++++ 6 files changed, 2281 insertions(+) create mode 100644 lib/site-visit/client-site-visit-wrapper.tsx create mode 100644 lib/site-visit/service.ts create mode 100644 lib/site-visit/shi-attendees-dialog.tsx create mode 100644 lib/site-visit/site-visit-detail-dialog.tsx create mode 100644 lib/site-visit/vendor-info-sheet.tsx create mode 100644 lib/site-visit/vendor-info-view-dialog.tsx (limited to 'lib/site-visit') diff --git a/lib/site-visit/client-site-visit-wrapper.tsx b/lib/site-visit/client-site-visit-wrapper.tsx new file mode 100644 index 00000000..4f056b3a --- /dev/null +++ b/lib/site-visit/client-site-visit-wrapper.tsx @@ -0,0 +1,474 @@ +"use client" + +import * as React from "react" +import { format } from "date-fns" +import { ko } from "date-fns/locale" +import { Building2, Calendar, Users, MessageSquare, Ellipsis, Eye, Edit, Download, Paperclip } from "lucide-react" +import { toast } from "sonner" +import { downloadFile } from "@/lib/file-download" + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { VendorInfoSheet } from "./vendor-info-sheet" +import type { VendorInfoFormValues } from "./vendor-info-sheet" +import { submitVendorInfoAction } from "./service" +import { SiteVisitDetailDialog } from "./site-visit-detail-dialog" +import { ShiAttendeesDialog } from "./shi-attendees-dialog" +// SHI 참석자 총 인원수 계산 함수 +function getTotalShiAttendees(shiAttendees: Record | null): number { + if (!shiAttendees) return 0 + + let total = 0 + Object.entries(shiAttendees).forEach(([, value]) => { + if (value && typeof value === 'object' && 'checked' in value && 'count' in value) { + const attendee = value as { checked: boolean; count: number } + if (attendee.checked) { + total += attendee.count + } + } + }) + return total +} + +interface SiteVisitRequest { + id: number + investigationId: number + requesterId: number | null + inspectionDuration: string | null + requestedStartDate: Date | null + requestedEndDate: Date | null + shiAttendees: Record | null + shiAttendeeDetails?: string | null + vendorRequests: Record | null + additionalRequests: string | null + status: string + sentAt: Date | null + createdAt: Date + updatedAt: Date + + // 실사 정보 + evaluationType: string | null //구매담당자가 작성한 실사방법 + investigationMethod: string | null // QM담당자가 작성한 실사방법 + investigationAddress: string | null + investigationNotes: string | null + forecastedAt: Date | null + actualAt: Date | null + result: string | null + resultNotes: string | null + + // PQ 정보 + pqItems: string | null + + // 요청자 정보 + requesterName: string | null + requesterEmail: string | null + requesterTitle: string | null + + // QM 매니저 정보 + qmManagerName: string | null + qmManagerEmail: string | null + qmManagerTitle: string | null + + // 협력업체 정보 + vendorInfo?: { + id: number + siteVisitRequestId: number + factoryName: string + factoryLocation: string + factoryAddress: string + factoryPicName: string + factoryPicPhone: string + factoryPicEmail: string + factoryDirections: string | null + accessProcedure: string | null + hasAttachments: boolean + otherInfo: string | null + submittedAt: Date + submittedBy: number + createdAt: Date + updatedAt: Date + } | null + + // SHI 첨부파일 + shiAttachments?: Array<{ + id: number + siteVisitRequestId: number + vendorSiteVisitInfoId: number | null + fileName: string + originalFileName: string + filePath: string + fileSize: number + mimeType: string + createdAt: Date + updatedAt: Date + }> | null +} + +interface ClientSiteVisitWrapperProps { + siteVisitRequests: SiteVisitRequest[] + vendorId: number +} + +export function ClientSiteVisitWrapper({ + siteVisitRequests, + vendorId, +}: ClientSiteVisitWrapperProps) { + const [selectedRequest, setSelectedRequest] = React.useState(null) + const [isDetailDialogOpen, setIsDetailDialogOpen] = React.useState(false) + const [isVendorInfoSheetOpen, setIsVendorInfoSheetOpen] = React.useState(false) + const [selectedSiteVisitRequestId, setSelectedSiteVisitRequestId] = React.useState(null) + const [isShiAttendeesDialogOpen, setIsShiAttendeesDialogOpen] = React.useState(false) + + const getInvestigationMethodLabel = (method: string | null) => { + switch (method) { + case "PURCHASE_SELF_EVAL": + return "구매자체평가" + case "DOCUMENT_EVAL": + return "서류평가" + case "PRODUCT_INSPECTION": + return "제품검사평가" + case "SITE_VISIT_EVAL": + return "방문실사평가" + default: + return method || "-" + } + } + + const getStatusLabel = (status: string) => { + switch (status) { + case "REQUESTED": + return "요청됨" + case "SENT": + return "발송됨" + case "COMPLETED": + return "완료" + case "VENDOR_SUBMITTED": + return "협력업체 제출" + default: + return status + } + } + + const getStatusVariant = (status: string) => { + switch (status) { + case "REQUESTED": + return "secondary" + case "SENT": + return "default" + case "COMPLETED": + return "outline" + case "VENDOR_SUBMITTED": + return "default" + default: + return "secondary" + } + } + + const formatDate = (date: Date | null) => { + if (!date) return "-" + return format(date, "yyyy.MM.dd", { locale: ko }) + } + + const formatDateRange = (startDate: Date | null, endDate: Date | null) => { + if (!startDate) return "-" + if (!endDate || startDate.getTime() === endDate.getTime()) { + return formatDate(startDate) + } + return `${formatDate(startDate)} ~ ${formatDate(endDate)}` + } + + return ( +
+ {/* 헤더 */} +
+
+

실사정보 관리

+

+ 방문실사 요청 정보를 조회하고 회신할 수 있습니다. +

+
+
+ Vendor ID: {vendorId} +
+
+ + {/* 통계 카드 */} +
+ + + 전체 요청 + + + +
{siteVisitRequests.length}
+
+
+ + + + 발송됨 + + + +
+ {siteVisitRequests.filter(r => r.status === "SENT").length} +
+
+
+ + + + 완료 + + + +
+ {siteVisitRequests.filter(r => r.status === "COMPLETED").length} +
+
+
+ + + + 대기중 + + + +
+ {siteVisitRequests.filter(r => r.status === "REQUESTED").length} +
+
+
+
+ + {/* 테이블 */} + + + 방문실사 요청 목록 + + SHI에서 요청한 방문실사 정보를 확인하고 회신할 수 있습니다. + + + + + + + No. + 상태 + 실사품목 + 실사방법 + 실사기간 + SHI 자료 + 실사요청일 + 실제 실사일 + 실사결과 + SHI참석자 + + 작업 + + + + {siteVisitRequests.map((request, index) => ( + + {index + 1} + + + {getStatusLabel(request.status)} + + + + {/* 실사품목 - PQ에서 가져온 정보 표시 */} + {request.pqItems || "-"} + + + + {getInvestigationMethodLabel(request.investigationMethod)} + + + + {request.inspectionDuration ? `${request.inspectionDuration}일` : "-"} + + + {request.shiAttachments && request.shiAttachments.length > 0 ? ( + + + + + + {request.shiAttachments.map((attachment) => ( + { + downloadFile( + attachment.filePath, + attachment.originalFileName, + { + showToast: true, + onSuccess: (fileName) => { + toast.success(`파일 다운로드 완료: ${fileName}`); + }, + onError: (error) => { + toast.error(`다운로드 실패: ${error}`); + } + } + ); + }} + > + + {attachment.originalFileName} + + ))} + + + ) : "-"} + + + {formatDateRange(request.requestedStartDate, request.requestedEndDate)} + + + {formatDate(request.actualAt)} + + + {request.result ? ( + + {request.result === "APPROVED" ? "통과" : "불가"} + + ) : "-"} + + + {getTotalShiAttendees(request.shiAttendees) > 0 ? ( + + ) : "-"} + + + + + + + + + { + setSelectedRequest(request) + setIsDetailDialogOpen(true) + }} + > + + 상세보기 + + { + setSelectedSiteVisitRequestId(request.id) + setIsVendorInfoSheetOpen(true) + }} + > + + 정보입력 + + + + + + ))} + +
+
+
+ + {/* 상세 정보 다이얼로그 */} + + + {/* 협력업체 정보 입력 Sheet */} + {selectedSiteVisitRequestId && ( + { + setIsVendorInfoSheetOpen(false) + setSelectedSiteVisitRequestId(null) + }} + onSubmit={async (data: VendorInfoFormValues & { attachments?: File[] }) => { + try { + const result = await submitVendorInfoAction({ + siteVisitRequestId: selectedSiteVisitRequestId, + ...data + }) + if (result.success) { + toast.success(result.message || "협력업체 정보가 성공적으로 제출되었습니다.") + // 페이지 새로고침으로 데이터 업데이트 + window.location.reload() + } else { + toast.error(result.error || "협력업체 정보 제출 중 오류가 발생했습니다.") + } + } catch (error) { + console.error("협력업체 정보 제출 오류:", error) + toast.error("협력업체 정보 제출 중 오류가 발생했습니다.") + } + }} + siteVisitRequestId={selectedSiteVisitRequestId} + initialData={siteVisitRequests.find(r => r.id === selectedSiteVisitRequestId)?.vendorInfo ? { + factoryName: siteVisitRequests.find(r => r.id === selectedSiteVisitRequestId)?.vendorInfo?.factoryName || "", + factoryLocation: siteVisitRequests.find(r => r.id === selectedSiteVisitRequestId)?.vendorInfo?.factoryLocation || "", + factoryAddress: siteVisitRequests.find(r => r.id === selectedSiteVisitRequestId)?.vendorInfo?.factoryAddress || "", + factoryPicName: siteVisitRequests.find(r => r.id === selectedSiteVisitRequestId)?.vendorInfo?.factoryPicName || "", + factoryPicPhone: siteVisitRequests.find(r => r.id === selectedSiteVisitRequestId)?.vendorInfo?.factoryPicPhone || "", + factoryPicEmail: siteVisitRequests.find(r => r.id === selectedSiteVisitRequestId)?.vendorInfo?.factoryPicEmail || "", + factoryDirections: siteVisitRequests.find(r => r.id === selectedSiteVisitRequestId)?.vendorInfo?.factoryDirections || "", + accessProcedure: siteVisitRequests.find(r => r.id === selectedSiteVisitRequestId)?.vendorInfo?.accessProcedure || "", + + hasAttachments: siteVisitRequests.find(r => r.id === selectedSiteVisitRequestId)?.vendorInfo?.hasAttachments || false, + otherInfo: siteVisitRequests.find(r => r.id === selectedSiteVisitRequestId)?.vendorInfo?.otherInfo || "", + } : null} + /> + )} + + {/* SHI 참석자 정보 다이얼로그 */} + +
+ ) +} \ No newline at end of file diff --git a/lib/site-visit/service.ts b/lib/site-visit/service.ts new file mode 100644 index 00000000..3b9bcb91 --- /dev/null +++ b/lib/site-visit/service.ts @@ -0,0 +1,668 @@ +"use server" + +import db from "@/db/db" +import { and, eq, isNull, desc, sql} from "drizzle-orm"; +import { revalidatePath} from "next/cache"; +import { format } from "date-fns" +import { vendorInvestigations, vendorPQSubmissions, siteVisitRequests, vendorSiteVisitInfo, siteVisitRequestAttachments } from "@/db/schema/pq" +import { sendEmail } from "../mail/sendEmail"; +import { decryptWithServerAction } from '@/components/drm/drmUtils' + +import { vendors } from "@/db/schema/vendors"; +import { saveFile, saveDRMFile } from "@/lib/file-stroage"; + + +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { users } from "@/db/schema" + + + +// 방문실사 요청 서버 액션 +export async function createSiteVisitRequestAction(input: { + investigationId: number; + inspectionDuration: number; + requestedStartDate: Date; + requestedEndDate: Date; + shiAttendees: Record; + shiAttendeeDetails?: string; + vendorRequests: Record; + otherVendorRequests?: string; + additionalRequests?: string; + attachments?: Array; + }) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + throw new Error("Unauthorized"); + } + const investigationId = Number(input.investigationId) + + // 기존 방문실사 요청이 있는지 확인 + const existingRequest = await db + .select() + .from(siteVisitRequests) + .where(eq(siteVisitRequests.investigationId, investigationId)) + .limit(1); + + if (existingRequest.length > 0) { + return { + success: false, + error: "이미 방문실사 요청이 존재합니다. 추가 요청은 불가능합니다." + }; + } + + // 방문실사 요청 생성 + const [siteVisitRequest] = await db + .insert(siteVisitRequests) + .values({ + investigationId: investigationId, + requesterId: session.user.id, + inspectionDuration: input.inspectionDuration, + requestedStartDate: input.requestedStartDate, + requestedEndDate: input.requestedEndDate, + shiAttendees: input.shiAttendees, + vendorRequests: input.vendorRequests, + additionalRequests: input.additionalRequests, + status: "REQUESTED", + }) + .returning(); + + // SHI 첨부파일 처리 + if (input.attachments && input.attachments.length > 0) { + console.log(`📎 첨부파일 처리 시작: ${input.attachments.length}개 파일`); + + const attachmentValues = []; + + for (const file of input.attachments) { + try { + console.log(`📁 파일 처리 중: ${file.name} (${file.size} bytes)`); + + // saveDRMFile을 사용하여 파일 저장 + const saveResult = await saveDRMFile( + file, + decryptWithServerAction, + `site-visit-requests/${siteVisitRequest.id}`, + session.user.id.toString() + ); + + if (!saveResult.success) { + console.error(`❌ 파일 저장 실패: ${file.name}`, saveResult.error); + throw new Error(`파일 저장 실패: ${file.name} - ${saveResult.error}`); + } + + console.log(`✅ 파일 저장 완료: ${file.name} -> ${saveResult.fileName}`); + + // DB에 첨부파일 레코드 생성 + const attachmentValue = { + siteVisitRequestId: siteVisitRequest.id, + vendorSiteVisitInfoId: null, // SHI 첨부파일은 vendorSiteVisitInfoId가 null + fileName: saveResult.fileName!, + originalFileName: file.name, + filePath: saveResult.publicPath!, + fileSize: file.size, + mimeType: file.type || 'application/octet-stream', + createdAt: new Date(), + updatedAt: new Date(), + }; + + attachmentValues.push(attachmentValue); + + } catch (error) { + console.error(`❌ 첨부파일 처리 오류: ${file.name}`, error); + throw new Error(`첨부파일 처리 중 오류가 발생했습니다: ${file.name}`); + } + } + + if (attachmentValues.length > 0) { + await db.insert(siteVisitRequestAttachments).values(attachmentValues); + console.log(`✅ 첨부파일 DB 저장 완료: ${attachmentValues.length}개`); + } + } + + // 이메일 발송 + try { + // 실사, 협력업체, 발송자 정보 조회 + const investigationResult = await db + .select() + .from(vendorInvestigations) + .where(eq(vendorInvestigations.id, siteVisitRequest.investigationId)) + .limit(1); + + const investigation = investigationResult[0]; + if (!investigation) { + throw new Error('실사 정보를 찾을 수 없습니다.'); + } + + const vendorResult = await db + .select() + .from(vendors) + .where(eq(vendors.id, investigation.vendorId)) + .limit(1); + + const vendor = vendorResult[0]; + if (!vendor) { + throw new Error('협력업체 정보를 찾을 수 없습니다.'); + } + + const senderResult = await db + .select() + .from(users) + .where(eq(users.id, siteVisitRequest.requesterId)) + .limit(1); + + const sender = senderResult[0]; + if (!sender) { + throw new Error('발송자 정보를 찾을 수 없습니다.'); + } + + // 평가 유형 라벨 및 설명 + const getEvaluationTypeInfo = (type: string) => { + switch (type) { + case 'PRODUCT_INSPECTION': + return { + label: '제품검사평가', + description: '제품의 품질, 성능, 안전성 등을 직접 검사하는 평가' + }; + case 'SITE_VISIT_EVAL': + return { + label: '방문실사평가', + description: '공장 시설, 생산능력, 품질관리체계 등을 현장에서 점검하는 평가' + }; + default: + return { + label: type, + description: '' + }; + } + }; + + const evaluationTypeInfo = getEvaluationTypeInfo(investigation.evaluationType || ''); + + // 마감일 계산 (발송일 + 7일) + const deadlineDate = format(new Date(), 'yyyy.MM.dd'); + + // SHI 참석자 정보 파싱 (새로운 구조에 맞게) + const shiAttendees = input.shiAttendees as Record; + + // 메일 제목 + const subject = `[SHI Audit] 방문실사 시행 안내 및 실사 관련 추가정보 요청 _ ${vendor.vendorName} (${vendor.vendorCode}, 사업자번호: ${vendor.taxId})`; + + // 메일 컨텍스트 + const context = { + // 기본 정보 + vendorName: vendor.vendorName, + vendorContactName: vendor.vendorName || '', + requesterName: sender.name, + requesterTitle: 'Procurement Manager', + requesterEmail: sender.email, + + // 실사 정보 + evaluationType: evaluationTypeInfo.label, + evaluationTypeDescription: evaluationTypeInfo.description, + requestedStartDate: format(siteVisitRequest.requestedStartDate!, 'yyyy.MM.dd'), + requestedEndDate: format(siteVisitRequest.requestedEndDate!, 'yyyy.MM.dd'), + inspectionDuration: siteVisitRequest.inspectionDuration, + + // 마감일 + deadlineDate, + + // SHI 참석자 정보 (새로운 구조) + shiAttendees: Object.entries(shiAttendees) + .filter(([, value]) => value.checked) + .map(([key, value]) => { + const departmentLabels: Record = { + technicalSales: "기술영업", + design: "설계", + procurement: "구매", + quality: "품질", + production: "생산", + commissioning: "시운전", + other: "기타" + }; + const departmentName = departmentLabels[key] || key; + const details = value.details ? ` (${value.details})` : ''; + return `${departmentName} ${value.count}명${details}`; + }), + shiAttendeeDetails: input.shiAttendeeDetails || null, + + // 협력업체 요청 정보 + vendorRequests: Object.keys(siteVisitRequest.vendorRequests as Record) + .filter(key => (siteVisitRequest.vendorRequests as Record)[key]) + .map(key => { + const requestLabels = { + 'factoryName': '○ 실사공장명', + 'factoryLocation': '○ 실사공장 주소', + 'factoryDirections': '○ 실사공장 가는 방법', + 'factoryPicName': '○ 실사공장 Contact Point', + 'factoryPicPhone': '○ 실사공장 연락처', + 'factoryPicEmail': '○ 실사공장 이메일', + 'attendees': '○ 실사 참석 예정인력', + 'accessProcedure': '○ 공장 출입절차 및 준비물' + }; + return requestLabels[key as keyof typeof requestLabels] || key; + }), + otherVendorRequests: input.otherVendorRequests, + + // 추가 요청사항 + additionalRequests: siteVisitRequest.additionalRequests, + + // 포털 URL + portalUrl: `${process.env.NEXTAUTH_URL}/ko/partners/site-visit`, + + // 현재 연도 + currentYear: new Date().getFullYear() + }; + + // 메일 발송 (벤더 이메일로 직접 발송) + await sendEmail({ + to: vendor.email, + subject, + template: 'site-visit-request' as string, + context, + cc: vendor.email !== sender.email ? sender.email : undefined + }); + + console.log('방문실사 요청 메일 발송 완료:', { + to: vendor.email, + subject, + vendorName: vendor.vendorName + }); + + // 메일 발송 성공 시 상태 업데이트 + await db + .update(siteVisitRequests) + .set({ + status: "SENT", + sentAt: new Date() + }) + .where(eq(siteVisitRequests.id, siteVisitRequest.id)); + + } catch (emailError) { + console.error('방문실사 요청 메일 발송 실패:', emailError); + } + + revalidatePath("/evcp/pq_new"); + + return { + success: true, + data: siteVisitRequest, + message: "방문실사 요청이 성공적으로 생성되었습니다." + }; + + } catch (error) { + console.error("방문실사 요청 생성 오류:", error); + return { + success: false, + error: "방문실사 요청 생성 중 오류가 발생했습니다." + }; + } + } + + // 방문실사 요청 조회 서버 액션 +export async function getSiteVisitRequestAction(investigationId: number) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + throw new Error("Unauthorized"); + } + + const siteVisitRequest = await db + .select() + .from(siteVisitRequests) + .where(eq(siteVisitRequests.investigationId, investigationId)) + .limit(1); + + if (!siteVisitRequest[0]) { + return { + success: true, + data: null + }; + } + + // SHI 첨부파일 조회 (vendorSiteVisitInfoId가 null인 것들) + const shiAttachments = await db + .select() + .from(siteVisitRequestAttachments) + .where( + and( + eq(siteVisitRequestAttachments.siteVisitRequestId, siteVisitRequest[0].id), + isNull(siteVisitRequestAttachments.vendorSiteVisitInfoId) + ) + ); + + return { + success: true, + data: { + ...siteVisitRequest[0], + shiAttachments + } + }; + + } catch (error) { + console.error("방문실사 요청 조회 오류:", error); + return { + success: false, + error: "방문실사 요청 조회 중 오류가 발생했습니다." + }; + } + } + + // 협력업체용 방문실사 요청 조회 + export async function getSiteVisitRequestsByVendorId(vendorId: number) { + try { + const result = await db + .select({ + id: siteVisitRequests.id, + investigationId: siteVisitRequests.investigationId, + requesterId: siteVisitRequests.requesterId, + inspectionDuration: siteVisitRequests.inspectionDuration, + requestedStartDate: siteVisitRequests.requestedStartDate, + requestedEndDate: siteVisitRequests.requestedEndDate, + shiAttendees: siteVisitRequests.shiAttendees, + vendorRequests: siteVisitRequests.vendorRequests, + additionalRequests: siteVisitRequests.additionalRequests, + status: siteVisitRequests.status, + sentAt: siteVisitRequests.sentAt, + createdAt: siteVisitRequests.createdAt, + updatedAt: siteVisitRequests.updatedAt, + + // 실사 정보 + evaluationType: vendorInvestigations.evaluationType, + investigationMethod: vendorInvestigations.investigationMethod, + investigationAddress: vendorInvestigations.investigationAddress, + investigationNotes: vendorInvestigations.investigationNotes, + forecastedAt: vendorInvestigations.forecastedAt, + actualAt: vendorInvestigations.completedAt, + result: vendorInvestigations.evaluationResult, + resultNotes: vendorInvestigations.purchaseComment, + + // PQ 정보 + pqItems: vendorPQSubmissions.pqItems, + + + // 협력업체 정보 + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + vendorEmail: vendors.email, + }) + .from(siteVisitRequests) + .leftJoin( + vendorInvestigations, + eq(siteVisitRequests.investigationId, vendorInvestigations.id) + ) + .leftJoin( + sql`users AS requester`, + eq(siteVisitRequests.requesterId, sql`requester.id`) + ) + .leftJoin( + vendors, + eq(vendorInvestigations.vendorId, vendors.id) + ) + .leftJoin( + vendorPQSubmissions, + eq(vendorInvestigations.pqSubmissionId, vendorPQSubmissions.id) + ) + .where(eq(vendorInvestigations.vendorId, vendorId)) + .orderBy(desc(siteVisitRequests.createdAt)); + + // 각 방문실사 요청에 대해 협력업체 정보 조회 + const resultWithVendorInfo = await Promise.all( + result.map(async (item) => { + const vendorInfoResult = await db + .select({ + id: vendorSiteVisitInfo.id, + siteVisitRequestId: vendorSiteVisitInfo.siteVisitRequestId, + factoryName: vendorSiteVisitInfo.factoryName, + factoryLocation: vendorSiteVisitInfo.factoryLocation, + factoryAddress: vendorSiteVisitInfo.factoryAddress, + factoryPicName: vendorSiteVisitInfo.factoryPicName, + factoryPicPhone: vendorSiteVisitInfo.factoryPicPhone, + factoryPicEmail: vendorSiteVisitInfo.factoryPicEmail, + factoryDirections: vendorSiteVisitInfo.factoryDirections, + accessProcedure: vendorSiteVisitInfo.accessProcedure, + + hasAttachments: vendorSiteVisitInfo.hasAttachments, + otherInfo: vendorSiteVisitInfo.otherInfo, + submittedAt: vendorSiteVisitInfo.submittedAt, + submittedBy: vendorSiteVisitInfo.submittedBy, + createdAt: vendorSiteVisitInfo.createdAt, + updatedAt: vendorSiteVisitInfo.updatedAt, + }) + .from(vendorSiteVisitInfo) + .where(eq(vendorSiteVisitInfo.siteVisitRequestId, item.id)) + .limit(1); + + const vendorInfo = vendorInfoResult.length > 0 ? vendorInfoResult[0] : null; + + // SHI 첨부파일 조회 (vendorSiteVisitInfoId가 null인 것들) + const shiAttachments = await db + .select() + .from(siteVisitRequestAttachments) + .where( + and( + eq(siteVisitRequestAttachments.siteVisitRequestId, item.id), + isNull(siteVisitRequestAttachments.vendorSiteVisitInfoId) + ) + ); + + return { + ...item, + shiAttendees: item.shiAttendees as Record | null, + vendorRequests: item.vendorRequests as Record | null, + vendorInfo, + shiAttachments, + }; + }) + ); + + console.log(`📊 방문실사 요청 조회 완료 - 총 ${resultWithVendorInfo.length}개 요청`) + console.log(`🔍 실제실사일/실사결과 데이터 확인:`, resultWithVendorInfo.map(item => ({ + id: item.id, + actualAt: item.actualAt, + result: item.result, + investigationId: item.investigationId + }))) + + return resultWithVendorInfo; + } catch (error) { + console.error("방문실사 요청 조회 오류:", error); + return []; + } + } + + // 협력업체 정보 제출 서버 액션 + export async function submitVendorInfoAction(input: { + siteVisitRequestId: number; + factoryName: string; + factoryLocation: string; + factoryAddress: string; + factoryPicName: string; + factoryPicPhone: string; + factoryPicEmail: string; + factoryDirections: string; + accessProcedure: string; + + hasAttachments: boolean; + otherInfo?: string; + attachments?: Array; + }) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + throw new Error("Unauthorized"); + } + + // 기존 협력업체 정보가 있는지 확인 + const existingInfo = await db + .select() + .from(vendorSiteVisitInfo) + .where(eq(vendorSiteVisitInfo.siteVisitRequestId, input.siteVisitRequestId)) + .limit(1); + + if (existingInfo.length > 0) { + // 기존 정보 업데이트 + await db + .update(vendorSiteVisitInfo) + .set({ + factoryName: input.factoryName, + factoryLocation: input.factoryLocation, + factoryAddress: input.factoryAddress, + factoryPicName: input.factoryPicName, + factoryPicPhone: input.factoryPicPhone, + factoryPicEmail: input.factoryPicEmail, + factoryDirections: input.factoryDirections, + accessProcedure: input.accessProcedure, + + hasAttachments: input.hasAttachments, + otherInfo: input.otherInfo, + // submittedBy: session.user.id, + submittedAt: new Date(), + }) + .where(eq(vendorSiteVisitInfo.siteVisitRequestId, input.siteVisitRequestId)); + } else { + // 새로운 정보 삽입 + await db + .insert(vendorSiteVisitInfo) + .values({ + siteVisitRequestId: input.siteVisitRequestId, + factoryName: input.factoryName, + factoryLocation: input.factoryLocation, + factoryAddress: input.factoryAddress, + factoryPicName: input.factoryPicName, + factoryPicPhone: input.factoryPicPhone, + factoryPicEmail: input.factoryPicEmail, + factoryDirections: input.factoryDirections, + accessProcedure: input.accessProcedure, + + hasAttachments: input.hasAttachments, + otherInfo: input.otherInfo, + submittedBy: session.user.id, + }); + } + + // 첨부파일 처리 + if (input.attachments && input.attachments.length > 0) { + console.log(`📎 협력업체 첨부파일 처리 시작: ${input.attachments.length}개 파일`); + + // 기존 첨부파일 삭제 (업데이트 시) + if (existingInfo.length > 0) { + console.log(`🗑️ 기존 첨부파일 삭제: vendorSiteVisitInfoId ${existingInfo[0].id}`); + await db + .delete(siteVisitRequestAttachments) + .where(eq(siteVisitRequestAttachments.vendorSiteVisitInfoId, existingInfo[0].id)); + } + + const attachmentValues = []; + + for (const file of input.attachments) { + try { + console.log(`📁 협력업체 파일 처리 중: ${file.name} (${file.size} bytes)`); + + // saveFile을 사용하여 파일 저장 (협력업체 첨부파일은 일반 파일로 처리) + const saveResult = await saveFile({ + file, + directory: `site-visit-vendor-info/${input.siteVisitRequestId}`, + originalName: file.name, + userId: session.user.id.toString() + }); + + if (!saveResult.success) { + console.error(`❌ 협력업체 파일 저장 실패: ${file.name}`, saveResult.error); + throw new Error(`파일 저장 실패: ${file.name} - ${saveResult.error}`); + } + + console.log(`✅ 협력업체 파일 저장 완료: ${file.name} -> ${saveResult.fileName}`); + + // DB에 첨부파일 레코드 생성 + const attachmentValue = { + siteVisitRequestId: input.siteVisitRequestId, + vendorSiteVisitInfoId: existingInfo.length > 0 ? existingInfo[0].id : undefined, + fileName: saveResult.fileName!, + originalFileName: file.name, + filePath: saveResult.publicPath!, + fileSize: file.size, + mimeType: file.type || 'application/octet-stream', + createdAt: new Date(), + updatedAt: new Date(), + }; + + attachmentValues.push(attachmentValue); + + } catch (error) { + console.error(`❌ 협력업체 첨부파일 처리 오류: ${file.name}`, error); + throw new Error(`첨부파일 처리 중 오류가 발생했습니다: ${file.name}`); + } + } + + if (attachmentValues.length > 0) { + await db.insert(siteVisitRequestAttachments).values(attachmentValues); + console.log(`✅ 협력업체 첨부파일 DB 저장 완료: ${attachmentValues.length}개`); + } + } + + // 방문실사 요청 상태 업데이트 + await db + .update(siteVisitRequests) + .set({ + status: "VENDOR_SUBMITTED" + }) + .where(eq(siteVisitRequests.id, input.siteVisitRequestId)); + + revalidatePath("/partners/site-visit"); + + return { + success: true, + message: "협력업체 정보가 성공적으로 제출되었습니다." + }; + + } catch (error) { + console.error("협력업체 정보 제출 오류:", error); + return { + success: false, + error: "협력업체 정보 제출 중 오류가 발생했습니다." + }; + } + } + + // SHI eVCP에서 협력업체 방문실사 정보 조회 + export async function getVendorSiteVisitInfoAction(siteVisitRequestId: number) { + try { + // 새로운 테이블에서 협력업체 정보 조회 + const vendorInfoResult = await db + .select() + .from(vendorSiteVisitInfo) + .where(eq(vendorSiteVisitInfo.siteVisitRequestId, siteVisitRequestId)) + .limit(1); + + const vendorInfo = vendorInfoResult.length > 0 ? vendorInfoResult[0] : null; + + if (!vendorInfo) { + return { + success: false, + error: "해당 방문실사 요청에 대한 협력업체 정보가 없습니다." + }; + } + + // 첨부파일 조회 + const attachments = await db + .select() + .from(siteVisitRequestAttachments) + .where(eq(siteVisitRequestAttachments.vendorSiteVisitInfoId, vendorInfo.id)); + + return { + success: true, + data: { + vendorInfo, + attachments + } + }; + + } catch (error) { + console.error("협력업체 방문실사 정보 조회 오류:", error); + return { + success: false, + error: "협력업체 방문실사 정보 조회 중 오류가 발생했습니다." + }; + } + } \ No newline at end of file diff --git a/lib/site-visit/shi-attendees-dialog.tsx b/lib/site-visit/shi-attendees-dialog.tsx new file mode 100644 index 00000000..b90689f4 --- /dev/null +++ b/lib/site-visit/shi-attendees-dialog.tsx @@ -0,0 +1,152 @@ +"use client" + +import * as React from "react" +import { Badge } from "@/components/ui/badge" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" + +interface SiteVisitRequest { + id: number + investigationId: number + requesterId: number | null + inspectionDuration: string | null + requestedStartDate: Date | null + requestedEndDate: Date | null + shiAttendees: Record | null + shiAttendeeDetails?: string | null + vendorRequests: Record | null + additionalRequests: string | null + status: string + sentAt: Date | null + createdAt: Date + updatedAt: Date + + // 실사 정보 + evaluationType: string | null //구매담당자가 작성한 실사방법 + investigationMethod: string | null // QM담당자가 작성한 실사방법 + investigationAddress: string | null + investigationNotes: string | null + forecastedAt: Date | null + actualAt: Date | null + result: string | null + resultNotes: string | null + + // PQ 정보 + pqItems: string | null + + // 요청자 정보 + requesterName: string | null + requesterEmail: string | null + requesterTitle: string | null + + // QM 매니저 정보 + qmManagerName: string | null + qmManagerEmail: string | null + qmManagerTitle: string | null + + // 협력업체 정보 + vendorInfo?: { + id: number + siteVisitRequestId: number + factoryName: string + factoryLocation: string + factoryAddress: string + factoryPicName: string + factoryPicPhone: string + factoryPicEmail: string + factoryDirections: string | null + accessProcedure: string | null + hasAttachments: boolean + otherInfo: string | null + submittedAt: Date + submittedBy: number + createdAt: Date + updatedAt: Date + } | null + + // SHI 첨부파일 + shiAttachments?: Array<{ + id: number + siteVisitRequestId: number + vendorSiteVisitInfoId: number | null + fileName: string + originalFileName: string + filePath: string + fileSize: number + mimeType: string + createdAt: Date + updatedAt: Date + }> | null +} + +interface ShiAttendeesDialogProps { + isOpen: boolean + onOpenChange: (open: boolean) => void + selectedRequest: SiteVisitRequest | null +} + +export function ShiAttendeesDialog({ + isOpen, + onOpenChange, + selectedRequest, +}: ShiAttendeesDialogProps) { + return ( + + + + SHI 참석자 정보 + + 방문실사에 참석 예정인 SHI 인력 정보입니다. + + + + {selectedRequest && selectedRequest.shiAttendees && ( +
+ {Object.entries(selectedRequest.shiAttendees as Record).map(([key, value]) => { + if (value && typeof value === 'object' && 'checked' in value && value.checked) { + const attendee = value as { checked: boolean; count: number; details?: string } + const departmentLabels: Record = { + technicalSales: "기술영업", + design: "설계", + procurement: "구매", + quality: "품질", + production: "생산", + commissioning: "시운전", + other: "기타" + } + + return ( +
+
+

{departmentLabels[key] || key}

+ {attendee.count}명 +
+ {attendee.details && ( +
+ 참석자 정보: {attendee.details} +
+ )} +
+ ) + } + return null + })} + + {/* 전체 참석자 상세정보 */} + {selectedRequest.shiAttendeeDetails && ( +
+

전체 참석자 상세정보

+

{selectedRequest.shiAttendeeDetails}

+
+ )} +
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/lib/site-visit/site-visit-detail-dialog.tsx b/lib/site-visit/site-visit-detail-dialog.tsx new file mode 100644 index 00000000..714ca3e3 --- /dev/null +++ b/lib/site-visit/site-visit-detail-dialog.tsx @@ -0,0 +1,266 @@ +"use client" + +import * as React from "react" +import { format } from "date-fns" +import { ko } from "date-fns/locale" +import { FileText, Download } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Separator } from "@/components/ui/separator" + +interface SiteVisitRequest { + id: number + investigationId: number + requesterId: number | null + inspectionDuration: string | null + requestedStartDate: Date | null + requestedEndDate: Date | null + shiAttendees: Record | null + shiAttendeeDetails?: string | null + vendorRequests: Record | null + additionalRequests: string | null + status: string + sentAt: Date | null + createdAt: Date + updatedAt: Date + + // 실사 정보 + evaluationType: string | null //구매담당자가 작성한 실사방법 + investigationMethod: string | null // QM담당자가 작성한 실사방법 + investigationAddress: string | null + investigationNotes: string | null + forecastedAt: Date | null + actualAt: Date | null + result: string | null + resultNotes: string | null + + // PQ 정보 + pqItems: string | null + + // 요청자 정보 + requesterName: string | null + requesterEmail: string | null + requesterTitle: string | null + + // QM 매니저 정보 + qmManagerName: string | null + qmManagerEmail: string | null + qmManagerTitle: string | null + + // 협력업체 정보 + vendorInfo?: { + id: number + siteVisitRequestId: number + factoryName: string + factoryLocation: string + factoryAddress: string + factoryPicName: string + factoryPicPhone: string + factoryPicEmail: string + factoryDirections: string | null + accessProcedure: string | null + hasAttachments: boolean + otherInfo: string | null + submittedAt: Date + submittedBy: number + createdAt: Date + updatedAt: Date + } | null + + // SHI 첨부파일 + shiAttachments?: Array<{ + id: number + siteVisitRequestId: number + vendorSiteVisitInfoId: number | null + fileName: string + originalFileName: string + filePath: string + fileSize: number + mimeType: string + createdAt: Date + updatedAt: Date + }> | null +} + +interface SiteVisitDetailDialogProps { + isOpen: boolean + onOpenChange: (open: boolean) => void + selectedRequest: SiteVisitRequest | null +} + +export function SiteVisitDetailDialog({ + isOpen, + onOpenChange, + selectedRequest, +}: SiteVisitDetailDialogProps) { + + const formatDate = (date: Date | null) => { + if (!date) return "-" + return format(date, "yyyy.MM.dd", { locale: ko }) + } + + return ( + + + + 방문실사 상세 정보 + + 작성한 방문실사 정보의 상세 내용입니다. + + + + {selectedRequest && ( +
+ {/* 기본 정보 */} + + {/* 협력업체 정보 */} + {selectedRequest.vendorInfo && ( + <> + +
+

작성한 협력업체 정보

+
+
+
+
+

공장 기본 정보

+
+
공장명: {selectedRequest.vendorInfo.factoryName}
+
공장위치: {selectedRequest.vendorInfo.factoryLocation}
+
공장주소: {selectedRequest.vendorInfo.factoryAddress}
+
+
+ +
+

공장 PIC 정보

+
+
이름: {selectedRequest.vendorInfo.factoryPicName}
+
전화번호: {selectedRequest.vendorInfo.factoryPicPhone}
+
이메일: {selectedRequest.vendorInfo.factoryPicEmail}
+
+
+
+ +
+ {selectedRequest.vendorInfo.factoryDirections && ( +
+

공장 가는 법

+
+

{selectedRequest.vendorInfo.factoryDirections}

+
+
+ )} + + {selectedRequest.vendorInfo.accessProcedure && ( +
+

공장 출입절차

+
+

{selectedRequest.vendorInfo.accessProcedure}

+
+
+ )} +
+
+ + {/* 기타 정보 */} + {selectedRequest.vendorInfo.otherInfo && ( +
+

기타 정보

+
+

{selectedRequest.vendorInfo.otherInfo}

+
+
+ )} + + {/* 제출 정보 */} +
+

제출 정보

+
+
제출일: {formatDate(selectedRequest.vendorInfo.submittedAt)}
+
첨부파일: {selectedRequest.vendorInfo.hasAttachments ? "있음" : "없음"}
+
+
+
+
+ + )} + + + + {/* SHI 첨부파일 */} + {selectedRequest.shiAttachments && selectedRequest.shiAttachments.length > 0 && ( + <> +
+

SHI 첨부파일 ({selectedRequest.shiAttachments.length}개)

+
+
+ {selectedRequest.shiAttachments.map((attachment) => ( +
+
+ + {attachment.originalFileName} + + ({Math.round((attachment.fileSize || 0) / 1024)}KB) + +
+ +
+ ))} +
+
+
+ + )} + + {/* 추가 요청사항 */} + {selectedRequest.additionalRequests && ( + <> +
+

SHI 추가 요청사항

+
+

{selectedRequest.additionalRequests}

+
+
+ + + )} +
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/lib/site-visit/vendor-info-sheet.tsx b/lib/site-visit/vendor-info-sheet.tsx new file mode 100644 index 00000000..c0b1ab7e --- /dev/null +++ b/lib/site-visit/vendor-info-sheet.tsx @@ -0,0 +1,442 @@ +"use client" + +import * as React 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 { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" + +import { toast } from "sonner" +import { Upload, X, FileText } from "lucide-react" + +// 협력업체 정보 입력 스키마 +const vendorInfoSchema = z.object({ + // 공장 정보 + factoryName: z.string().min(1, "공장명을 입력해주세요."), + factoryLocation: z.string().min(1, "공장위치를 입력해주세요."), + factoryAddress: z.string().min(1, "공장주소를 입력해주세요."), + + // 공장 PIC 정보 + factoryPicName: z.string().min(1, "공장 PIC 이름을 입력해주세요."), + factoryPicPhone: z.string().min(1, "공장 PIC 전화번호를 입력해주세요."), + factoryPicEmail: z.string().email("올바른 이메일 주소를 입력해주세요."), + + // 공장 가는 법 + factoryDirections: z.string().min(1, "공장 가는 법을 입력해주세요."), + + // 공장 출입절차 + accessProcedure: z.string().min(1, "공장 출입절차를 입력해주세요."), + + // 첨부파일 + hasAttachments: z.boolean().default(false), + + // 기타 정보 + otherInfo: z.string().optional(), +}) + +export type VendorInfoFormValues = z.infer + +interface VendorInfoSheetProps { + isOpen: boolean + onClose: () => void + onSubmit: (data: VendorInfoFormValues & { attachments?: File[] }) => Promise + siteVisitRequestId: number + initialData?: VendorInfoFormValues | null +} + +export function VendorInfoSheet({ + isOpen, + onClose, + onSubmit, + siteVisitRequestId, + initialData, +}: VendorInfoSheetProps) { + const [isPending, setIsPending] = React.useState(false) + const [selectedFiles, setSelectedFiles] = React.useState([]) + const fileInputRef = React.useRef(null) + + const form = useForm({ + resolver: zodResolver(vendorInfoSchema), + defaultValues: { + factoryName: "", + factoryLocation: "", + factoryAddress: "", + factoryPicName: "", + factoryPicPhone: "", + factoryPicEmail: "", + factoryDirections: "", + accessProcedure: "", + + hasAttachments: false, + otherInfo: "", + }, + }) + + // Sheet가 열릴 때마다 폼 재설정 + React.useEffect(() => { + if (isOpen) { + if (initialData) { + form.reset(initialData) + } else { + form.reset({ + factoryName: "", + factoryLocation: "", + factoryAddress: "", + factoryPicName: "", + factoryPicPhone: "", + factoryPicEmail: "", + factoryDirections: "", + accessProcedure: "", + + hasAttachments: false, + otherInfo: "", + }) + } + } + }, [isOpen, form, initialData]) + + // 파일 업로드 핸들러 + const handleFileUpload = (event: React.ChangeEvent) => { + const files = event.target.files + if (!files || files.length === 0) return + + const newFiles = Array.from(files) + + // 파일 크기 체크 (10MB) + const validFiles = newFiles.filter(file => { + if (file.size > 10 * 1024 * 1024) { + toast.error(`${file.name}: 파일 크기가 10MB를 초과합니다.`) + return false + } + return true + }) + + if (validFiles.length > 0) { + setSelectedFiles(prev => [...prev, ...validFiles]) + form.setValue("hasAttachments", true) + toast.success(`${validFiles.length}개 파일이 추가되었습니다.`) + } + } + + // 파일 삭제 핸들러 + const handleRemoveFile = (index: number) => { + setSelectedFiles(prev => prev.filter((_, i) => i !== index)) + const newFileCount = selectedFiles.length - 1 + form.setValue("hasAttachments", newFileCount > 0) + } + + async function handleSubmit(data: VendorInfoFormValues) { + setIsPending(true) + try { + // 첨부파일 정보를 포함하여 제출 + const submitData = { + ...data, + siteVisitRequestId, + attachments: selectedFiles + } + await onSubmit(submitData) + toast.success("협력업체 정보가 성공적으로 제출되었습니다.") + onClose() + } catch (error) { + toast.error("협력업체 정보 제출 중 오류가 발생했습니다.") + console.error("협력업체 정보 제출 오류:", error) + } finally { + setIsPending(false) + } + } + + return ( + !open && onClose()}> + + + 협력업체 정보 입력 + + 방문실사 관련 협력업체 정보를 입력해주세요. + + + +
+ + {/* 공장 정보 */} +
+

공장 정보

+ +
+ ( + + 공장명 * + + + + + + )} + /> + + ( + + 공장위치 * + + + + + + )} + /> + + ( + + 공장주소 * + +