summaryrefslogtreecommitdiff
path: root/lib/site-visit
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-08-04 09:39:21 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-08-04 09:39:21 +0000
commit53ad72732f781e6c6d5ddb3776ea47aec010af8e (patch)
treee676287827f8634be767a674b8ad08b6ed7eb3e6 /lib/site-visit
parent3e4d15271322397764601dee09441af8a5b3adf5 (diff)
(최겸) PQ/실사 수정 및 개발
Diffstat (limited to 'lib/site-visit')
-rw-r--r--lib/site-visit/client-site-visit-wrapper.tsx474
-rw-r--r--lib/site-visit/service.ts668
-rw-r--r--lib/site-visit/shi-attendees-dialog.tsx152
-rw-r--r--lib/site-visit/site-visit-detail-dialog.tsx266
-rw-r--r--lib/site-visit/vendor-info-sheet.tsx442
-rw-r--r--lib/site-visit/vendor-info-view-dialog.tsx279
6 files changed, 2281 insertions, 0 deletions
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<string, unknown> | 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<string, unknown> | null
+ shiAttendeeDetails?: string | null
+ vendorRequests: Record<string, unknown> | 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<SiteVisitRequest | null>(null)
+ const [isDetailDialogOpen, setIsDetailDialogOpen] = React.useState(false)
+ const [isVendorInfoSheetOpen, setIsVendorInfoSheetOpen] = React.useState(false)
+ const [selectedSiteVisitRequestId, setSelectedSiteVisitRequestId] = React.useState<number | null>(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 (
+ <div className="container mx-auto py-6 space-y-6">
+ {/* 헤더 */}
+ <div className="flex items-center justify-between">
+ <div>
+ <h1 className="text-3xl font-bold">실사정보 관리</h1>
+ <p className="text-muted-foreground mt-2">
+ 방문실사 요청 정보를 조회하고 회신할 수 있습니다.
+ </p>
+ </div>
+ <div className="flex items-center gap-2">
+ <Badge variant="outline">Vendor ID: {vendorId}</Badge>
+ </div>
+ </div>
+
+ {/* 통계 카드 */}
+ <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">전체 요청</CardTitle>
+ <Building2 className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">{siteVisitRequests.length}</div>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">발송됨</CardTitle>
+ <MessageSquare className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">
+ {siteVisitRequests.filter(r => r.status === "SENT").length}
+ </div>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">완료</CardTitle>
+ <Calendar className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">
+ {siteVisitRequests.filter(r => r.status === "COMPLETED").length}
+ </div>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">대기중</CardTitle>
+ <Users className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">
+ {siteVisitRequests.filter(r => r.status === "REQUESTED").length}
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+
+ {/* 테이블 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>방문실사 요청 목록</CardTitle>
+ <CardDescription>
+ SHI에서 요청한 방문실사 정보를 확인하고 회신할 수 있습니다.
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-12">No.</TableHead>
+ <TableHead>상태</TableHead>
+ <TableHead>실사품목</TableHead>
+ <TableHead>실사방법</TableHead>
+ <TableHead>실사기간</TableHead>
+ <TableHead>SHI 자료</TableHead>
+ <TableHead>실사요청일</TableHead>
+ <TableHead>실제 실사일</TableHead>
+ <TableHead>실사결과</TableHead>
+ <TableHead>SHI참석자</TableHead>
+
+ <TableHead className="w-20">작업</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {siteVisitRequests.map((request, index) => (
+ <TableRow key={request.id}>
+ <TableCell className="font-medium">{index + 1}</TableCell>
+ <TableCell>
+ <Badge variant={getStatusVariant(request.status)}>
+ {getStatusLabel(request.status)}
+ </Badge>
+ </TableCell>
+ <TableCell>
+ {/* 실사품목 - PQ에서 가져온 정보 표시 */}
+ {request.pqItems || "-"}
+ </TableCell>
+ <TableCell>
+ <Badge variant="outline">
+ {getInvestigationMethodLabel(request.investigationMethod)}
+ </Badge>
+ </TableCell>
+ <TableCell>
+ {request.inspectionDuration ? `${request.inspectionDuration}일` : "-"}
+ </TableCell>
+ <TableCell>
+ {request.shiAttachments && request.shiAttachments.length > 0 ? (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-auto p-1 text-xs hover:bg-blue-50"
+ >
+ <Paperclip className="h-4 w-4 text-blue-600 mr-1" />
+ {request.shiAttachments.length}개
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-64">
+ {request.shiAttachments.map((attachment) => (
+ <DropdownMenuItem
+ key={attachment.id}
+ onSelect={() => {
+ downloadFile(
+ attachment.filePath,
+ attachment.originalFileName,
+ {
+ showToast: true,
+ onSuccess: (fileName) => {
+ toast.success(`파일 다운로드 완료: ${fileName}`);
+ },
+ onError: (error) => {
+ toast.error(`다운로드 실패: ${error}`);
+ }
+ }
+ );
+ }}
+ >
+ <Download className="mr-2 h-4 w-4" />
+ {attachment.originalFileName}
+ </DropdownMenuItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ ) : "-"}
+ </TableCell>
+ <TableCell>
+ {formatDateRange(request.requestedStartDate, request.requestedEndDate)}
+ </TableCell>
+ <TableCell>
+ {formatDate(request.actualAt)}
+ </TableCell>
+ <TableCell>
+ {request.result ? (
+ <Badge variant={request.result === "APPROVED" ? "default" : "destructive"}>
+ {request.result === "APPROVED" ? "통과" : "불가"}
+ </Badge>
+ ) : "-"}
+ </TableCell>
+ <TableCell>
+ {getTotalShiAttendees(request.shiAttendees) > 0 ? (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-auto p-1 text-xs"
+ onClick={() => {
+ setSelectedRequest(request)
+ setIsShiAttendeesDialogOpen(true)
+ }}
+ >
+ {getTotalShiAttendees(request.shiAttendees)}명
+ </Button>
+ ) : "-"}
+ </TableCell>
+
+ <TableCell>
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => {
+ setSelectedRequest(request)
+ setIsDetailDialogOpen(true)
+ }}
+ >
+ <Eye className="mr-2 h-4 w-4" />
+ 상세보기
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onSelect={() => {
+ setSelectedSiteVisitRequestId(request.id)
+ setIsVendorInfoSheetOpen(true)
+ }}
+ >
+ <Edit className="mr-2 h-4 w-4" />
+ 정보입력
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </CardContent>
+ </Card>
+
+ {/* 상세 정보 다이얼로그 */}
+ <SiteVisitDetailDialog
+ isOpen={isDetailDialogOpen}
+ onOpenChange={setIsDetailDialogOpen}
+ selectedRequest={selectedRequest}
+ />
+
+ {/* 협력업체 정보 입력 Sheet */}
+ {selectedSiteVisitRequestId && (
+ <VendorInfoSheet
+ isOpen={isVendorInfoSheetOpen}
+ onClose={() => {
+ 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 참석자 정보 다이얼로그 */}
+ <ShiAttendeesDialog
+ isOpen={isShiAttendeesDialogOpen}
+ onOpenChange={setIsShiAttendeesDialogOpen}
+ selectedRequest={selectedRequest}
+ />
+ </div>
+ )
+} \ 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<string, boolean>;
+ shiAttendeeDetails?: string;
+ vendorRequests: Record<string, boolean>;
+ otherVendorRequests?: string;
+ additionalRequests?: string;
+ attachments?: Array<File>;
+ }) {
+ 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<string, { checked: boolean; count: number; details?: string }>;
+
+ // 메일 제목
+ 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<string, string> = {
+ 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<string, boolean>)
+ .filter(key => (siteVisitRequest.vendorRequests as Record<string, boolean>)[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<string, unknown> | null,
+ vendorRequests: item.vendorRequests as Record<string, unknown> | 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<File>;
+ }) {
+ 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<string, unknown> | null
+ shiAttendeeDetails?: string | null
+ vendorRequests: Record<string, unknown> | 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 (
+ <Dialog open={isOpen} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle>SHI 참석자 정보</DialogTitle>
+ <DialogDescription>
+ 방문실사에 참석 예정인 SHI 인력 정보입니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {selectedRequest && selectedRequest.shiAttendees && (
+ <div className="space-y-4">
+ {Object.entries(selectedRequest.shiAttendees as Record<string, unknown>).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<string, string> = {
+ technicalSales: "기술영업",
+ design: "설계",
+ procurement: "구매",
+ quality: "품질",
+ production: "생산",
+ commissioning: "시운전",
+ other: "기타"
+ }
+
+ return (
+ <div key={key} className="border rounded-lg p-4">
+ <div className="flex items-center justify-between mb-2">
+ <h4 className="font-semibold">{departmentLabels[key] || key}</h4>
+ <Badge variant="outline">{attendee.count}명</Badge>
+ </div>
+ {attendee.details && (
+ <div className="text-sm text-muted-foreground">
+ <span className="font-medium">참석자 정보:</span> {attendee.details}
+ </div>
+ )}
+ </div>
+ )
+ }
+ return null
+ })}
+
+ {/* 전체 참석자 상세정보 */}
+ {selectedRequest.shiAttendeeDetails && (
+ <div className="border rounded-lg p-4">
+ <h4 className="font-semibold mb-2">전체 참석자 상세정보</h4>
+ <p className="text-sm whitespace-pre-wrap">{selectedRequest.shiAttendeeDetails}</p>
+ </div>
+ )}
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+} \ 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<string, unknown> | null
+ shiAttendeeDetails?: string | null
+ vendorRequests: Record<string, unknown> | 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 (
+ <Dialog open={isOpen} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>방문실사 상세 정보</DialogTitle>
+ <DialogDescription>
+ 작성한 방문실사 정보의 상세 내용입니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {selectedRequest && (
+ <div className="space-y-6">
+ {/* 기본 정보 */}
+
+ {/* 협력업체 정보 */}
+ {selectedRequest.vendorInfo && (
+ <>
+ <Separator />
+ <div>
+ <h3 className="font-semibold mb-2">작성한 협력업체 정보</h3>
+ <div className="bg-muted p-4 rounded-md">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
+ <div className="space-y-4">
+ <div>
+ <h4 className="font-semibold mb-2">공장 기본 정보</h4>
+ <div className="space-y-2 text-sm">
+ <div><span className="font-medium">공장명:</span> {selectedRequest.vendorInfo.factoryName}</div>
+ <div><span className="font-medium">공장위치:</span> {selectedRequest.vendorInfo.factoryLocation}</div>
+ <div><span className="font-medium">공장주소:</span> {selectedRequest.vendorInfo.factoryAddress}</div>
+ </div>
+ </div>
+
+ <div>
+ <h4 className="font-semibold mb-2">공장 PIC 정보</h4>
+ <div className="space-y-2 text-sm">
+ <div><span className="font-medium">이름:</span> {selectedRequest.vendorInfo.factoryPicName}</div>
+ <div><span className="font-medium">전화번호:</span> {selectedRequest.vendorInfo.factoryPicPhone}</div>
+ <div><span className="font-medium">이메일:</span> {selectedRequest.vendorInfo.factoryPicEmail}</div>
+ </div>
+ </div>
+ </div>
+
+ <div className="space-y-4">
+ {selectedRequest.vendorInfo.factoryDirections && (
+ <div>
+ <h4 className="font-semibold mb-2">공장 가는 법</h4>
+ <div className="bg-background p-3 rounded-md">
+ <p className="text-sm whitespace-pre-wrap">{selectedRequest.vendorInfo.factoryDirections}</p>
+ </div>
+ </div>
+ )}
+
+ {selectedRequest.vendorInfo.accessProcedure && (
+ <div>
+ <h4 className="font-semibold mb-2">공장 출입절차</h4>
+ <div className="bg-background p-3 rounded-md">
+ <p className="text-sm whitespace-pre-wrap">{selectedRequest.vendorInfo.accessProcedure}</p>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+
+ {/* 기타 정보 */}
+ {selectedRequest.vendorInfo.otherInfo && (
+ <div className="mt-6">
+ <h4 className="font-semibold mb-2">기타 정보</h4>
+ <div className="bg-background p-3 rounded-md">
+ <p className="text-sm whitespace-pre-wrap">{selectedRequest.vendorInfo.otherInfo}</p>
+ </div>
+ </div>
+ )}
+
+ {/* 제출 정보 */}
+ <div className="mt-6">
+ <h4 className="font-semibold mb-2">제출 정보</h4>
+ <div className="space-y-2 text-sm">
+ <div><span className="font-medium">제출일:</span> {formatDate(selectedRequest.vendorInfo.submittedAt)}</div>
+ <div><span className="font-medium">첨부파일:</span> {selectedRequest.vendorInfo.hasAttachments ? "있음" : "없음"}</div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </>
+ )}
+
+ <Separator />
+
+ {/* SHI 첨부파일 */}
+ {selectedRequest.shiAttachments && selectedRequest.shiAttachments.length > 0 && (
+ <>
+ <div>
+ <h3 className="font-semibold mb-2">SHI 첨부파일 ({selectedRequest.shiAttachments.length}개)</h3>
+ <div className="bg-muted p-4 rounded-md">
+ <div className="space-y-2">
+ {selectedRequest.shiAttachments.map((attachment) => (
+ <div key={attachment.id} className="flex items-center justify-between p-2 border rounded-md">
+ <div className="flex items-center space-x-2 flex-1 min-w-0">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm truncate">{attachment.originalFileName}</span>
+ <span className="text-xs text-muted-foreground">
+ ({Math.round((attachment.fileSize || 0) / 1024)}KB)
+ </span>
+ </div>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ className="p-2"
+ onClick={async () => {
+ try {
+ const { downloadFile } = await import('@/lib/file-download')
+ await downloadFile(attachment.filePath, attachment.originalFileName || '', {
+ showToast: true,
+ onError: (error) => {
+ console.error('다운로드 오류:', error)
+ toast.error("다운로드 실패: " + error)
+ },
+ onSuccess: (fileName, fileSize) => {
+ console.log(`다운로드 성공: ${fileName} (${fileSize} bytes)`)
+ }
+ })
+ } catch (error) {
+ console.error('다운로드 오류:', error)
+ toast.error("파일 다운로드 중 오류가 발생했습니다.")
+ }
+ }}
+ aria-label="파일 다운로드"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ </div>
+ </>
+ )}
+
+ {/* 추가 요청사항 */}
+ {selectedRequest.additionalRequests && (
+ <>
+ <div>
+ <h3 className="font-semibold mb-2">SHI 추가 요청사항</h3>
+ <div className="bg-muted p-4 rounded-md">
+ <p className="text-sm whitespace-pre-wrap">{selectedRequest.additionalRequests}</p>
+ </div>
+ </div>
+ <Separator />
+ </>
+ )}
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+} \ 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<typeof vendorInfoSchema>
+
+interface VendorInfoSheetProps {
+ isOpen: boolean
+ onClose: () => void
+ onSubmit: (data: VendorInfoFormValues & { attachments?: File[] }) => Promise<void>
+ 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<File[]>([])
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ const form = useForm<VendorInfoFormValues>({
+ 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<HTMLInputElement>) => {
+ 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 (
+ <Sheet open={isOpen} onOpenChange={(open) => !open && onClose()}>
+ <SheetContent className="w-[600px] sm:w-[700px] overflow-y-auto">
+ <SheetHeader>
+ <SheetTitle>협력업체 정보 입력</SheetTitle>
+ <SheetDescription>
+ 방문실사 관련 협력업체 정보를 입력해주세요.
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
+ {/* 공장 정보 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">공장 정보</h3>
+
+ <div className="space-y-4">
+ <FormField
+ control={form.control}
+ name="factoryName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>공장명 *</FormLabel>
+ <FormControl>
+ <Input placeholder="공장명을 입력하세요" {...field} disabled={isPending} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="factoryLocation"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>공장위치 *</FormLabel>
+ <FormControl>
+ <Input placeholder="국가 또는 지역 (예: Finland, 부산)" {...field} disabled={isPending} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="factoryAddress"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>공장주소 *</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="상세 주소를 입력하세요"
+ {...field}
+ disabled={isPending}
+ className="min-h-[80px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ {/* 공장 PIC 정보 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">공장 PIC 정보</h3>
+
+ <div className="space-y-4">
+ <FormField
+ control={form.control}
+ name="factoryPicName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>이름 *</FormLabel>
+ <FormControl>
+ <Input placeholder="PIC 이름" {...field} disabled={isPending} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="factoryPicPhone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>전화번호 *</FormLabel>
+ <FormControl>
+ <Input placeholder="전화번호" {...field} disabled={isPending} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="factoryPicEmail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>이메일 *</FormLabel>
+ <FormControl>
+ <Input placeholder="이메일 주소" {...field} disabled={isPending} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ {/* 공장 가는 법 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">공장 가는 법</h3>
+
+ <FormField
+ control={form.control}
+ name="factoryDirections"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>공장 가는 법 *</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="공항에서 공장까지 가는 방법, 대중교통 정보 등을 상세히 입력하세요"
+ {...field}
+ disabled={isPending}
+ className="min-h-[100px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 공장 출입절차 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">공장 출입절차</h3>
+
+ <FormField
+ control={form.control}
+ name="accessProcedure"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>공장 출입절차 *</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="신분증 제출, 출입증 교환, 준비물 등 출입 절차를 상세히 입력하세요"
+ {...field}
+ disabled={isPending}
+ className="min-h-[100px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+
+
+ {/* 첨부파일 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">첨부파일</h3>
+
+ {/* 파일 업로드 */}
+ <div className="space-y-2">
+ <FormLabel>파일 업로드</FormLabel>
+ <div className="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center">
+ <input
+ ref={fileInputRef}
+ type="file"
+ multiple
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png"
+ onChange={handleFileUpload}
+ className="hidden"
+ disabled={isPending}
+ />
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => fileInputRef.current?.click()}
+ disabled={isPending}
+ className="w-full"
+ >
+ <Upload className="h-4 w-4 mr-2" />
+ 파일 선택
+ </Button>
+ <p className="text-xs text-muted-foreground mt-2">
+ PDF, Word, Excel, 이미지 파일 (최대 10MB)
+ </p>
+ </div>
+ </div>
+
+ {/* 첨부된 파일 목록 */}
+ <div>
+ <FormLabel>첨부된 파일</FormLabel>
+ <div className="space-y-2">
+ {selectedFiles.length > 0 ? (
+ selectedFiles.map((file, index) => (
+ <div key={index} className="flex items-center justify-between p-2 border rounded-md">
+ <div className="flex items-center space-x-2 flex-1 min-w-0">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm truncate">{file.name}</span>
+ <span className="text-xs text-muted-foreground">
+ ({Math.round(file.size / 1024)}KB)
+ </span>
+ </div>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleRemoveFile(index)}
+ disabled={isPending}
+ className="text-destructive hover:text-destructive"
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ ))
+ ) : (
+ <div className="text-sm text-muted-foreground text-center py-4">
+ 첨부된 파일이 없습니다.
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+
+ {/* 기타 정보 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">기타 정보</h3>
+
+ <FormField
+ control={form.control}
+ name="otherInfo"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>기타 정보 (선택사항)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="추가로 전달하고 싶은 정보가 있다면 입력하세요"
+ {...field}
+ disabled={isPending}
+ className="min-h-[80px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <SheetFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={onClose}
+ disabled={isPending}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isPending}>
+ {isPending ? "처리 중..." : "정보입력"}
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/site-visit/vendor-info-view-dialog.tsx b/lib/site-visit/vendor-info-view-dialog.tsx
new file mode 100644
index 00000000..b9daf83e
--- /dev/null
+++ b/lib/site-visit/vendor-info-view-dialog.tsx
@@ -0,0 +1,279 @@
+"use client"
+
+import * as React from "react"
+import { format } from "date-fns"
+import { ko } from "date-fns/locale"
+import { Building2, User, Phone, Mail, FileText, Calendar } from "lucide-react"
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { toast } from "sonner"
+
+interface 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
+}
+
+interface Attachment {
+ id: number
+ siteVisitRequestId: number
+ vendorSiteVisitInfoId: number | null
+ fileName: string
+ originalFileName: string | null
+ filePath: string
+ fileSize: number | null
+ mimeType: string | null
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface VendorInfoViewDialogProps {
+ isOpen: boolean
+ onClose: () => void
+ siteVisitRequestId: number | null
+}
+
+export function VendorInfoViewDialog({
+ isOpen,
+ onClose,
+ siteVisitRequestId,
+}: VendorInfoViewDialogProps) {
+ const [data, setData] = React.useState<VendorInfo | null>(null)
+ const [attachments, setAttachments] = React.useState<Attachment[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ // 데이터 로드
+ React.useEffect(() => {
+ if (isOpen && siteVisitRequestId) {
+ loadVendorInfo()
+ }
+ }, [isOpen, siteVisitRequestId])
+
+ const loadVendorInfo = async () => {
+ if (!siteVisitRequestId) return
+
+ setIsLoading(true)
+ try {
+ const { getVendorSiteVisitInfoAction } = await import("./service")
+ const result = await getVendorSiteVisitInfoAction(siteVisitRequestId)
+
+ if (result.success && result.data) {
+ setData(result.data.vendorInfo)
+ setAttachments(result.data.attachments || [])
+ } else {
+ toast.error("협력업체 정보를 불러올 수 없습니다.")
+ }
+ } catch (error) {
+ console.error("협력업체 정보 로드 오류:", error)
+ toast.error("협력업체 정보를 불러오는 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const formatDate = (date: Date | null) => {
+ if (!date) return "-"
+ return format(date, "yyyy.MM.dd", { locale: ko })
+ }
+
+ return (
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
+ <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>협력업체 방문실사 정보</DialogTitle>
+ <DialogDescription>
+ 협력업체가 입력한 방문실사 관련 정보를 확인할 수 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {isLoading ? (
+ <div className="flex items-center justify-center py-8">
+ <div className="text-center">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
+ <p className="text-muted-foreground">협력업체 정보를 불러오는 중...</p>
+ </div>
+ </div>
+ ) : data ? (
+ <div className="space-y-6">
+ {/* 협력업체 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Building2 className="h-5 w-5" />
+ 협력업체 공장 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
+ <div className="space-y-4">
+ <div>
+ <h4 className="font-semibold mb-2">공장 기본 정보</h4>
+ <div className="space-y-2 text-sm">
+ <div><span className="font-medium">공장명:</span> {data.factoryName}</div>
+ <div><span className="font-medium">공장위치:</span> {data.factoryLocation}</div>
+ <div><span className="font-medium">공장주소:</span> {data.factoryAddress}</div>
+ </div>
+ </div>
+
+ <div>
+ <h4 className="font-semibold mb-2">공장 PIC 정보</h4>
+ <div className="space-y-2 text-sm">
+ <div className="flex items-center gap-2">
+ <User className="h-4 w-4" />
+ <span>{data.factoryPicName}</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <Phone className="h-4 w-4" />
+ <span>{data.factoryPicPhone}</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <Mail className="h-4 w-4" />
+ <span>{data.factoryPicEmail}</span>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div className="space-y-4">
+ {data.factoryDirections && (
+ <div>
+ <h4 className="font-semibold mb-2">공장 가는 법</h4>
+ <div className="bg-muted p-3 rounded-md">
+ <p className="text-sm whitespace-pre-wrap">{data.factoryDirections}</p>
+ </div>
+ </div>
+ )}
+
+ {data.accessProcedure && (
+ <div>
+ <h4 className="font-semibold mb-2">공장 출입절차</h4>
+ <div className="bg-muted p-3 rounded-md">
+ <p className="text-sm whitespace-pre-wrap">{data.accessProcedure}</p>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 첨부파일 */}
+ {attachments.length > 0 && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ 협력업체 첨부파일 ({attachments.length}개)
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-2">
+ {attachments.map((attachment) => (
+ <div key={attachment.id} className="flex items-center justify-between p-2 border rounded-md">
+ <div className="flex items-center space-x-2 flex-1 min-w-0">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm truncate">{attachment.originalFileName}</span>
+ <span className="text-xs text-muted-foreground">
+ ({Math.round((attachment.fileSize || 0) / 1024)}KB)
+ </span>
+ </div>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={async () => {
+ try {
+ const { downloadFile } = await import('@/lib/file-download')
+ await downloadFile(attachment.filePath, attachment.originalFileName || '', {
+ showToast: true,
+ onError: (error) => {
+ console.error('다운로드 오류:', error)
+ toast.error(error)
+ },
+ onSuccess: (fileName, fileSize) => {
+ console.log(`다운로드 성공: ${fileName} (${fileSize} bytes)`)
+ }
+ })
+ } catch (error) {
+ console.error('다운로드 오류:', error)
+ toast.error('파일 다운로드 중 오류가 발생했습니다.')
+ }
+ }}
+ >
+ 다운로드
+ </Button>
+ </div>
+ ))}
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 기타 정보 */}
+ {data.otherInfo && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ 기타 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <p className="text-sm whitespace-pre-wrap">{data.otherInfo}</p>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 제출 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Calendar className="h-5 w-5" />
+ 제출 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <div className="space-y-2 text-sm">
+ <div><span className="font-medium">제출일:</span> {formatDate(data.submittedAt)}</div>
+ <div><span className="font-medium">첨부파일:</span> {data.hasAttachments ? "있음" : "없음"}</div>
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ ) : (
+ <div className="text-center py-8">
+ <div className="text-muted-foreground">
+ <FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
+ <p>협력업체가 아직 정보를 입력하지 않았습니다.</p>
+ </div>
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file