diff options
Diffstat (limited to 'lib/site-visit/client-site-visit-wrapper.tsx')
| -rw-r--r-- | lib/site-visit/client-site-visit-wrapper.tsx | 474 |
1 files changed, 474 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 |
