From a3525f8bdfcf849cc1716fab81cb8facadbe9a8e Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 27 Oct 2025 10:03:06 +0000 Subject: (최겸) 구매 협력업체 관리(PQ/실사관리, 정기평가 협력업체 제출 상세 dialog 개발, MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/evaluation/table/evaluation-columns.tsx | 46 +- lib/evaluation/table/evaluation-table.tsx | 11 + lib/evaluation/table/vendor-submission-dialog.tsx | 623 +++++++++++++++++++++ lib/evaluation/vendor-submission-service.ts | 369 ++++++++++++ lib/export.ts | 6 +- lib/mail/templates/audit-result-notice.hbs | 3 +- lib/mail/templates/data-room-invitation.hbs | 210 +++++++ lib/pq/pq-criteria/add-pq-dialog.tsx | 2 + lib/pq/pq-criteria/pq-table-column.tsx | 50 +- lib/pq/pq-criteria/pq-table-toolbar-actions.tsx | 48 +- lib/pq/pq-criteria/pq-table.tsx | 10 +- lib/pq/pq-criteria/update-pq-sheet.tsx | 1 + .../edit-investigation-dialog.tsx | 2 +- .../request-investigation-dialog.tsx | 6 +- lib/pq/pq-review-table-new/send-results-dialog.tsx | 8 +- .../pq-review-table-new/vendors-table-columns.tsx | 80 ++- lib/pq/service.ts | 583 +++++++++++-------- lib/pq/table/pq-lists-table.tsx | 2 +- .../table/investigation-table.tsx | 4 +- .../table/update-investigation-sheet.tsx | 6 +- lib/vendors/service.ts | 60 +- 21 files changed, 1771 insertions(+), 359 deletions(-) create mode 100644 lib/evaluation/table/vendor-submission-dialog.tsx create mode 100644 lib/evaluation/vendor-submission-service.ts create mode 100644 lib/mail/templates/data-room-invitation.hbs (limited to 'lib') diff --git a/lib/evaluation/table/evaluation-columns.tsx b/lib/evaluation/table/evaluation-columns.tsx index e8b51b57..b68aa70d 100644 --- a/lib/evaluation/table/evaluation-columns.tsx +++ b/lib/evaluation/table/evaluation-columns.tsx @@ -6,13 +6,19 @@ import { type ColumnDef } from "@tanstack/react-table"; import { Checkbox } from "@/components/ui/checkbox"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Pencil, Eye, MessageSquare, Check, X, Clock, FileText, Circle, Ellipsis, BarChart3 } from "lucide-react"; +import { Pencil, Eye, MessageSquare, Check, X, Clock, FileText, Circle, Ellipsis, BarChart3, ChevronDown } from "lucide-react"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"; import { PeriodicEvaluationView, PeriodicEvaluationAggregatedView } from "@/db/schema"; import { DataTableRowAction } from "@/types/table"; @@ -466,16 +472,34 @@ export function getPeriodicEvaluationsColumns({ cell: ({ row }) => { return (
- + + + + + + setRowAction({ row, type: "view" })} + className="flex items-center gap-2" + > + + 평가 상세 + + setRowAction({ row, type: "vendor-submission" as any })} + className="flex items-center gap-2" + > + + 협력업체 제출 상세 + + +
); }, diff --git a/lib/evaluation/table/evaluation-table.tsx b/lib/evaluation/table/evaluation-table.tsx index 4404967a..1a5b450b 100644 --- a/lib/evaluation/table/evaluation-table.tsx +++ b/lib/evaluation/table/evaluation-table.tsx @@ -37,6 +37,7 @@ import { } from "../service" import { PeriodicEvaluationsTableToolbarActions } from "./periodic-evaluations-toolbar-actions" import { EvaluationDetailsDialog } from "./evaluation-details-dialog" +import { VendorSubmissionDialog } from "./vendor-submission-dialog" import { searchParamsEvaluationsCache } from "../validation" interface PeriodicEvaluationsTableProps { @@ -745,6 +746,16 @@ export function PeriodicEvaluationsTable({ }} evaluation={rowAction?.row.original || null} /> + + { + if (!open) { + setRowAction(null); + } + }} + evaluation={rowAction?.row.original || null} + /> diff --git a/lib/evaluation/table/vendor-submission-dialog.tsx b/lib/evaluation/table/vendor-submission-dialog.tsx new file mode 100644 index 00000000..aff8dc56 --- /dev/null +++ b/lib/evaluation/table/vendor-submission-dialog.tsx @@ -0,0 +1,623 @@ +"use client" + +import * as React from "react" +import { + Eye, + Building2, + User, + Calendar, + CheckCircle2, + Clock, + MessageSquare, + Award, + FileText, + Paperclip, + Download, + File, + BarChart3, + ChevronDown, + ChevronRight +} from "lucide-react" + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Separator } from "@/components/ui/separator" +import { Skeleton } from "@/components/ui/skeleton" +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { PeriodicEvaluationView } from "@/db/schema" +import { getVendorSubmissionDetails, type VendorSubmissionDetail } from "../vendor-submission-service" +import { formatFileSize, getFileInfo } from "@/lib/file-download" + +interface VendorSubmissionDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + evaluation: PeriodicEvaluationView | null +} + +// 상태별 배지 색상 +const getSubmissionStatusBadge = (status: string) => { + switch (status) { + case "submitted": + return 제출완료 + case "draft": + return 임시저장 + case "reviewed": + return 검토완료 + default: + return {status} + } +} + +// 진행률 계산 +const getProgressPercentage = (completed: number, total: number) => { + if (total === 0) return 0 + return Math.round((completed / total) * 100) +} + +export function VendorSubmissionDialog({ + open, + onOpenChange, + evaluation, +}: VendorSubmissionDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [submissionDetails, setSubmissionDetails] = React.useState(null) + const [expandedGeneralItems, setExpandedGeneralItems] = React.useState>(new Set()) + const [expandedEsgItems, setExpandedEsgItems] = React.useState>(new Set()) + + // 첨부파일 다운로드 핸들러 + const handleDownloadAttachment = async (filePath: string, fileName: string) => { + try { + const { downloadFile } = await import('@/lib/file-download') + await downloadFile(filePath, fileName, { + action: 'download', + showToast: true, + showSuccessToast: true, + onError: (error) => { + console.error("파일 다운로드 실패:", error) + }, + }) + } catch (error) { + console.error("파일 다운로드 실패:", error) + } + } + + // 일반평가 항목 토글 + const toggleGeneralItem = (itemId: number) => { + const newExpanded = new Set(expandedGeneralItems) + if (newExpanded.has(itemId)) { + newExpanded.delete(itemId) + } else { + newExpanded.add(itemId) + } + setExpandedGeneralItems(newExpanded) + } + + // ESG 평가 항목 토글 + const toggleEsgItem = (itemId: number) => { + const newExpanded = new Set(expandedEsgItems) + if (newExpanded.has(itemId)) { + newExpanded.delete(itemId) + } else { + newExpanded.add(itemId) + } + setExpandedEsgItems(newExpanded) + } + + // 제출 상세 정보 로드 + React.useEffect(() => { + if (open && evaluation?.id) { + const loadSubmissionDetails = async () => { + try { + setIsLoading(true) + const details = await getVendorSubmissionDetails(evaluation.id) + setSubmissionDetails(details) + } catch (error) { + console.error("Failed to load vendor submission details:", error) + } finally { + setIsLoading(false) + } + } + + loadSubmissionDetails() + } + }, [open, evaluation?.id]) + + // 다이얼로그 닫을 때 데이터 리셋 + React.useEffect(() => { + if (!open) { + setSubmissionDetails(null) + setExpandedGeneralItems(new Set()) + setExpandedEsgItems(new Set()) + } + }, [open]) + + if (!evaluation) return null + + return ( + + + + {/* 고정 헤더 */} + + + + 협력업체 제출 상세 + + + {/* 평가 기본 정보 */} + + + + + 평가 정보 + + + +
+ {/* 협력업체 */} +
+ 협력업체: + {evaluation.vendorName} + ({evaluation.vendorCode}) +
+ + {/* 평가년도 */} +
+ 년도: + {evaluation.evaluationYear}년 +
+ + {/* 구분 */} +
+ 구분: + + {evaluation.division === "PLANT" ? "해양" : "조선"} + +
+ + {/* 진행상태 */} +
+ 상태: + {evaluation.status} +
+
+
+
+
+ + {/* 스크롤 가능한 컨텐츠 영역 */} +
+ {isLoading ? ( +
+ + + + + + + + +
+ ) : submissionDetails ? ( +
+ {/* 제출 정보 요약 */} + + + + + 제출 정보 + + + +
+
+
제출 상태
+
{getSubmissionStatusBadge(submissionDetails.submissionStatus)}
+
+
+
제출일
+
+ {submissionDetails.submittedAt + ? new Date(submissionDetails.submittedAt).toLocaleDateString('ko-KR') + : "-" + } +
+
+ {/*
+
ESG 평균 점수
+
+ {submissionDetails.averageEsgScore + ? `${submissionDetails.averageEsgScore.toFixed(1)}점` + : "-" + } +
+
*/} + {/*
+
검토자
+
+ {submissionDetails.reviewedBy || "-"} +
+
*/} +
+ + {/* 진행률 표시 */} + {/*
+
+
+ 일반평가 진행률 + + {submissionDetails.completedGeneralItems}/{submissionDetails.totalGeneralItems} + ({getProgressPercentage(submissionDetails.completedGeneralItems, submissionDetails.totalGeneralItems)}%) + +
+
+
+
+
+ +
+
+ ESG 평가 진행률 + + {submissionDetails.completedEsgItems}/{submissionDetails.totalEsgItems} + ({getProgressPercentage(submissionDetails.completedEsgItems, submissionDetails.totalEsgItems)}%) + +
+
+
+
+
+
*/} + + + + {/* 탭으로 일반평가와 ESG 평가 구분 */} + + + + + 일반평가 ({submissionDetails.generalEvaluations.length}개) + + {submissionDetails.vendor.country === "KR" && ( + + + ESG 평가 ({submissionDetails.esgEvaluations.length}개) + + )} + + + {/* 일반평가 탭 */} + + {submissionDetails.generalEvaluations.length > 0 ? ( +
+ {submissionDetails.generalEvaluations.map((item) => ( + + + + toggleGeneralItem(item.id)} + > +
+
+ {expandedGeneralItems.has(item.id) ? ( + + ) : ( + + )} + + {item.serialNumber}. {item.category} + +
+
+ {item.response ? ( + 응답완료 + ) : ( + 미응답 + )} + {item.response?.hasAttachments && ( + + + 첨부파일 + + )} +
+
+
+
+ + +
+
평가 항목
+
{item.inspectionItem}
+ {item.remarks && ( +
+
비고
+
{item.remarks}
+
+ )} +
+ + {item.response ? ( +
+ +
+
협력업체 응답
+
+ {item.response.responseText || "응답 내용이 없습니다."} +
+
+ + {/* 첨부파일 */} + {item.response.attachments.length > 0 && ( +
+
첨부파일
+
+ {item.response.attachments.map((attachment) => { + const fileInfo = getFileInfo(attachment.originalFileName) + return ( +
+ {fileInfo.icon} + + + + {attachment.originalFileName} + + + +
+
{attachment.originalFileName}
+
크기: {formatFileSize(attachment.fileSize)}
+
타입: {fileInfo.type}
+
업로드: {attachment.uploadedBy}
+
+
+
+ +
+ ) + })} +
+
+ )} + + {/* 검토자 의견 */} + {item.response.reviewComments && ( +
+
검토자 의견
+
+ {item.response.reviewComments} +
+
+ )} +
+ ) : ( +
+ +
아직 응답하지 않았습니다
+
+ )} +
+
+
+
+ ))} +
+ ) : ( + + +
+ +
일반평가 항목이 없습니다
+
+
+
+ )} +
+ + {/* ESG 평가 탭 */} + + {submissionDetails.esgEvaluations.length > 0 ? ( +
+ {submissionDetails.esgEvaluations.map((esgEvaluation) => ( + + + + toggleEsgItem(esgEvaluation.id)} + > +
+
+ {expandedEsgItems.has(esgEvaluation.id) ? ( + + ) : ( + + )} + + {esgEvaluation.serialNumber}. {esgEvaluation.category} + +
+
+ + {esgEvaluation.evaluationItems.length}개 항목 + +
+
+
+
+ + +
+
평가 항목
+
{esgEvaluation.inspectionItem}
+
+ + + + {/* ESG 평가 세부 항목들 */} +
+ {esgEvaluation.evaluationItems.map((item) => ( +
+
+
{item.evaluationItem}
+ {item.evaluationItemDescription && ( +
+ {item.evaluationItemDescription} +
+ )} + + {item.response ? ( +
+
+ + 선택된 답변 + + + {item.response.answerOption.answerText} + + + {item.response.selectedScore}점 + +
+ {item.response.additionalComments && ( +
+
추가 의견:
+
{item.response.additionalComments}
+
+ )} +
+ ) : ( +
+ +
아직 응답하지 않았습니다
+
+ )} +
+
+ ))} +
+
+
+
+
+ ))} +
+ ) : ( + + +
+ +
ESG 평가 항목이 없습니다
+
+
+
+ )} +
+
+ + {/* 첨부파일 요약 */} + {submissionDetails.attachmentStats.totalFiles > 0 && ( + + + + + 첨부파일 요약 + + + +
+
+
전체 파일 수
+
{submissionDetails.attachmentStats.totalFiles}개
+
+
+
전체 파일 크기
+
{formatFileSize(submissionDetails.attachmentStats.totalSize)}
+
+
+
일반평가 첨부파일
+
{submissionDetails.attachmentStats.generalEvaluationFiles}개
+
+
+
ESG 평가 첨부파일
+
{submissionDetails.attachmentStats.esgEvaluationFiles}개
+
+
+
+
+ )} + + {/* 검토자 의견 */} + {submissionDetails.reviewComments && ( + + + + + 검토자 의견 + + + +
+ {submissionDetails.reviewComments} +
+
+
+ )} +
+ ) : ( + + +
+ +
제출 내용이 없습니다
+
협력업체가 아직 평가를 제출하지 않았습니다
+
+
+
+ )} +
+ + {/* 고정 푸터 */} +
+ +
+ +
+
+ ) +} diff --git a/lib/evaluation/vendor-submission-service.ts b/lib/evaluation/vendor-submission-service.ts new file mode 100644 index 00000000..388f382a --- /dev/null +++ b/lib/evaluation/vendor-submission-service.ts @@ -0,0 +1,369 @@ +'use server' + +import db from "@/db/db" +import { + evaluationSubmissions, + generalEvaluations, + generalEvaluationResponses, + esgEvaluations, + esgEvaluationItems, + esgAnswerOptions, + esgEvaluationResponses, + vendorEvaluationAttachments, + periodicEvaluations, + evaluationTargets, + vendors, +} from "@/db/schema" +import { eq, and, asc, desc, sql } from "drizzle-orm" + +// 협력업체 제출 상세 정보 타입 정의 +export interface VendorSubmissionDetail { + // 제출 기본 정보 + submissionId: string + evaluationYear: number + evaluationRound: string | null + submissionStatus: string + submittedAt: Date | null + reviewedAt: Date | null + reviewedBy: string | null + reviewComments: string | null + averageEsgScore: number | null + + // 진행률 통계 + totalGeneralItems: number + completedGeneralItems: number + totalEsgItems: number + completedEsgItems: number + + // 협력업체 정보 + vendor: { + id: number + vendorCode: string + vendorName: string + email: string | null + country: string | null + } + + // 일반평가 응답 + generalEvaluations: { + id: number + serialNumber: string + category: string + inspectionItem: string + remarks: string | null + response: { + responseText: string + hasAttachments: boolean + reviewComments: string | null + attachments: { + id: number + fileId: string + originalFileName: string + storedFileName: string + filePath: string + fileSize: number + mimeType: string | null + uploadedBy: string + createdAt: Date + }[] + } | null + }[] + + // ESG 평가 응답 + esgEvaluations: { + id: number + serialNumber: string + category: string + inspectionItem: string + evaluationItems: { + id: number + evaluationItem: string + evaluationItemDescription: string | null + orderIndex: number + response: { + selectedScore: number + additionalComments: string | null + answerOption: { + id: number + answerText: string + score: number + } + } | null + }[] + }[] + + // 첨부파일 통계 + attachmentStats: { + totalFiles: number + totalSize: number + generalEvaluationFiles: number + esgEvaluationFiles: number + } +} + +/** + * 특정 정기평가에 대한 협력업체 제출 상세 정보를 조회합니다 + */ +export async function getVendorSubmissionDetails(periodicEvaluationId: number): Promise { + try { + // 1. 제출 정보 조회 + const submissionResult = await db + .select({ + // 제출 기본 정보 + id: evaluationSubmissions.id, + submissionId: evaluationSubmissions.submissionId, + evaluationYear: evaluationSubmissions.evaluationYear, + evaluationRound: evaluationSubmissions.evaluationRound, + submissionStatus: evaluationSubmissions.submissionStatus, + submittedAt: evaluationSubmissions.submittedAt, + reviewedAt: evaluationSubmissions.reviewedAt, + reviewedBy: evaluationSubmissions.reviewedBy, + reviewComments: evaluationSubmissions.reviewComments, + averageEsgScore: evaluationSubmissions.averageEsgScore, + + // 진행률 통계 + totalGeneralItems: evaluationSubmissions.totalGeneralItems, + completedGeneralItems: evaluationSubmissions.completedGeneralItems, + totalEsgItems: evaluationSubmissions.totalEsgItems, + completedEsgItems: evaluationSubmissions.completedEsgItems, + + // 협력업체 정보 + vendorId: vendors.id, + vendorCode: vendors.vendorCode, + vendorName: vendors.vendorName, + vendorEmail: vendors.email, + vendorCountry: vendors.country, + }) + .from(evaluationSubmissions) + .innerJoin(vendors, eq(evaluationSubmissions.companyId, vendors.id)) + .where( + and( + eq(evaluationSubmissions.periodicEvaluationId, periodicEvaluationId), + eq(evaluationSubmissions.isActive, true) + ) + ) + .limit(1) + + if (submissionResult.length === 0) { + return null // 제출 내용이 없음 + } + + const submission = submissionResult[0] + const submissionId = submission.id // evaluationSubmissions.id (integer) + const submissionUuid = submission.submissionId // evaluationSubmissions.submissionId (UUID) + + console.log("=== 협력업체 제출 상세 조회 시작 ===") + console.log("submissionId (integer):", submissionId) + console.log("submissionUuid:", submissionUuid) + console.log("submission:", submission) + + // 2. 일반평가 항목과 응답 조회 + const generalEvaluationsResult = await db + .select({ + // 일반평가 항목 정보 + generalEvaluationId: generalEvaluations.id, + serialNumber: generalEvaluations.serialNumber, + category: generalEvaluations.category, + inspectionItem: generalEvaluations.inspectionItem, + remarks: generalEvaluations.remarks, + + // 응답 정보 + responseId: generalEvaluationResponses.id, + responseText: generalEvaluationResponses.responseText, + hasAttachments: generalEvaluationResponses.hasAttachments, + reviewComments: generalEvaluationResponses.reviewComments, + + // 첨부파일 정보 + attachmentId: vendorEvaluationAttachments.id, + fileId: vendorEvaluationAttachments.fileId, + originalFileName: vendorEvaluationAttachments.originalFileName, + storedFileName: vendorEvaluationAttachments.storedFileName, + filePath: vendorEvaluationAttachments.filePath, + fileSize: vendorEvaluationAttachments.fileSize, + mimeType: vendorEvaluationAttachments.mimeType, + uploadedBy: vendorEvaluationAttachments.uploadedBy, + attachmentCreatedAt: vendorEvaluationAttachments.createdAt, + }) + .from(generalEvaluations) + .leftJoin( + generalEvaluationResponses, + and( + eq(generalEvaluationResponses.generalEvaluationId, generalEvaluations.id), + eq(generalEvaluationResponses.submissionId, submissionId), + eq(generalEvaluationResponses.isActive, true) + ) + ) + .leftJoin( + vendorEvaluationAttachments, + eq(vendorEvaluationAttachments.generalEvaluationResponseId, generalEvaluationResponses.id) + ) + .where(eq(generalEvaluations.isActive, true)) + .orderBy(asc(generalEvaluations.serialNumber)) + + // 3. ESG 평가 항목과 응답 조회 + const esgEvaluationsResult = await db + .select({ + // ESG 평가표 정보 + esgEvaluationId: esgEvaluations.id, + esgSerialNumber: esgEvaluations.serialNumber, + esgCategory: esgEvaluations.category, + esgInspectionItem: esgEvaluations.inspectionItem, + + // ESG 평가항목 정보 + esgEvaluationItemId: esgEvaluationItems.id, + evaluationItem: esgEvaluationItems.evaluationItem, + evaluationItemDescription: esgEvaluationItems.evaluationItemDescription, + orderIndex: esgEvaluationItems.orderIndex, + + // ESG 응답 정보 + esgResponseId: esgEvaluationResponses.id, + selectedScore: esgEvaluationResponses.selectedScore, + additionalComments: esgEvaluationResponses.additionalComments, + + // ESG 답변 옵션 정보 + answerOptionId: esgAnswerOptions.id, + answerText: esgAnswerOptions.answerText, + answerScore: esgAnswerOptions.score, + }) + .from(esgEvaluations) + .innerJoin(esgEvaluationItems, eq(esgEvaluationItems.esgEvaluationId, esgEvaluations.id)) + .leftJoin( + esgEvaluationResponses, + and( + eq(esgEvaluationResponses.esgEvaluationItemId, esgEvaluationItems.id), + eq(esgEvaluationResponses.submissionId, submissionId), + eq(esgEvaluationResponses.isActive, true) + ) + ) + .leftJoin( + esgAnswerOptions, + eq(esgAnswerOptions.id, esgEvaluationResponses.esgAnswerOptionId) + ) + .where( + and( + eq(esgEvaluations.isActive, true), + eq(esgEvaluationItems.isActive, true) + ) + ) + .orderBy( + asc(esgEvaluations.serialNumber), + asc(esgEvaluationItems.orderIndex) + ) + + // 4. 데이터 가공 + // 일반평가 데이터 그룹화 + const generalEvaluationsMap = new Map() + generalEvaluationsResult.forEach(row => { + if (!generalEvaluationsMap.has(row.generalEvaluationId)) { + generalEvaluationsMap.set(row.generalEvaluationId, { + id: row.generalEvaluationId, + serialNumber: row.serialNumber, + category: row.category, + inspectionItem: row.inspectionItem, + remarks: row.remarks, + response: row.responseId ? { + responseText: row.responseText, + hasAttachments: row.hasAttachments, + reviewComments: row.reviewComments, + attachments: [] + } : null + }) + } + + // 첨부파일 추가 + if (row.attachmentId && generalEvaluationsMap.get(row.generalEvaluationId)?.response) { + generalEvaluationsMap.get(row.generalEvaluationId).response.attachments.push({ + id: row.attachmentId, + fileId: row.fileId, + originalFileName: row.originalFileName, + storedFileName: row.storedFileName, + filePath: row.filePath, + fileSize: row.fileSize, + mimeType: row.mimeType, + uploadedBy: row.uploadedBy, + createdAt: new Date(row.attachmentCreatedAt) + }) + } + }) + + // ESG 평가 데이터 그룹화 + const esgEvaluationsMap = new Map() + esgEvaluationsResult.forEach(row => { + if (!esgEvaluationsMap.has(row.esgEvaluationId)) { + esgEvaluationsMap.set(row.esgEvaluationId, { + id: row.esgEvaluationId, + serialNumber: row.esgSerialNumber, + category: row.esgCategory, + inspectionItem: row.esgInspectionItem, + evaluationItems: [] + }) + } + + const esgEvaluation = esgEvaluationsMap.get(row.esgEvaluationId) + + // 평가항목 추가 (중복 방지) + const existingItem = esgEvaluation.evaluationItems.find((item: any) => item.id === row.esgEvaluationItemId) + if (!existingItem) { + esgEvaluation.evaluationItems.push({ + id: row.esgEvaluationItemId, + evaluationItem: row.evaluationItem, + evaluationItemDescription: row.evaluationItemDescription, + orderIndex: row.orderIndex, + response: row.esgResponseId ? { + selectedScore: Number(row.selectedScore), + additionalComments: row.additionalComments, + answerOption: { + id: row.answerOptionId, + answerText: row.answerText, + score: Number(row.answerScore) + } + } : null + }) + } + }) + + // 5. 첨부파일 통계 계산 + const allAttachments = generalEvaluationsResult + .filter(row => row.attachmentId) + .map(row => ({ + id: row.attachmentId, + fileSize: row.fileSize + })) + + const attachmentStats = { + totalFiles: allAttachments.length, + totalSize: allAttachments.reduce((sum, att) => sum + att.fileSize, 0), + generalEvaluationFiles: allAttachments.length, + esgEvaluationFiles: 0 // ESG는 첨부파일 없음 + } + + return { + submissionId: submission.submissionId, + evaluationYear: submission.evaluationYear, + evaluationRound: submission.evaluationRound, + submissionStatus: submission.submissionStatus, + submittedAt: submission.submittedAt, + reviewedAt: submission.reviewedAt, + reviewedBy: submission.reviewedBy, + reviewComments: submission.reviewComments, + averageEsgScore: submission.averageEsgScore ? Number(submission.averageEsgScore) : null, + + + vendor: { + id: submission.vendorId, + vendorCode: submission.vendorCode || "", + vendorName: submission.vendorName, + email: submission.vendorEmail, + country: submission.vendorCountry, + }, + + generalEvaluations: Array.from(generalEvaluationsMap.values()), + esgEvaluations: Array.from(esgEvaluationsMap.values()), + attachmentStats + } + + } catch (error) { + console.error("Error fetching vendor submission details:", error) + throw new Error("협력업체 제출 상세 정보 조회 중 오류가 발생했습니다") + } +} \ No newline at end of file diff --git a/lib/export.ts b/lib/export.ts index 71fae264..c4edbbc8 100644 --- a/lib/export.ts +++ b/lib/export.ts @@ -52,8 +52,8 @@ export async function exportTableToExcel( const maybeGroup = (col.columnDef.meta as any)?.group row1.push(maybeGroup ?? "") - // excelHeader - const maybeExcelHeader = (col.columnDef.meta as any)?.excelHeader + // excelHeader (meta 또는 직접 속성에서 찾기) + const maybeExcelHeader = (col.columnDef.meta as any)?.excelHeader || (col.columnDef as any)?.excelHeader if (typeof maybeExcelHeader === "string") { row2.push(maybeExcelHeader) } else { @@ -79,7 +79,7 @@ export async function exportTableToExcel( } else { // ────────────── 기존 1줄 헤더 ────────────── const headerRow = columns.map((col) => { - const maybeExcelHeader = (col.columnDef.meta as any)?.excelHeader + const maybeExcelHeader = (col.columnDef.meta as any)?.excelHeader || (col.columnDef as any)?.excelHeader return typeof maybeExcelHeader === "string" ? maybeExcelHeader : col.id }) diff --git a/lib/mail/templates/audit-result-notice.hbs b/lib/mail/templates/audit-result-notice.hbs index 1e5f7c65..68907a4e 100644 --- a/lib/mail/templates/audit-result-notice.hbs +++ b/lib/mail/templates/audit-result-notice.hbs @@ -125,8 +125,7 @@

귀사 일익 번창하심을 기원합니다.

-

당사에선 귀사와의 정기적 거래를 위하여 PQ 검토 및 실사를 진행하였으며,
- 아래와 같이 최종 실사 결과가 확정되어 공유하여 드립니다.

+

귀사와 당사 간의 거래를 위하여 실시한 귀사의 거래 기준 충족 여부 검토 결과를 아래와 같이 공유드립니다

- 아 래 -

diff --git a/lib/mail/templates/data-room-invitation.hbs b/lib/mail/templates/data-room-invitation.hbs new file mode 100644 index 00000000..023173b1 --- /dev/null +++ b/lib/mail/templates/data-room-invitation.hbs @@ -0,0 +1,210 @@ + + + + + + Data Room Access Invitation + + + +
+ +
+

🔐 Data Room Access Granted

+
+ + +
+
+ Hello {{name}}, +
+ +

+ Great news! You've been invited to access a secure Data Room by {{inviterName}}. +

+ + +
+

📁 Data Room Details

+
+ Room Name: + {{dataRoomName}} +
+
+ Your Role: + {{role}} +
+
+ Invited by: + {{inviterName}} +
+
+ + +

+ As a {{role}} member, you now have access to view and manage documents + in this secure data room according to your permission level. +

+ + + + + +
+

🔒 Security Reminder

+
    +
  • This data room contains confidential information
  • +
  • Please do not share your access credentials with others
  • +
  • All activities within the data room are logged for security purposes
  • +
  • If you're a new user, you may need to create an account first
  • +
+
+ +
+ + +

+ Need help?
+ If you have any questions about accessing the data room or your permissions, + please contact the person who invited you or your system administrator. +

+ +

+ First time user?
+ If this is your first time accessing our platform, you'll need to create an account + using this email address ({{email}}) to gain access to the data room. +

+
+ + + +
+ + diff --git a/lib/pq/pq-criteria/add-pq-dialog.tsx b/lib/pq/pq-criteria/add-pq-dialog.tsx index 33e656c2..660eb360 100644 --- a/lib/pq/pq-criteria/add-pq-dialog.tsx +++ b/lib/pq/pq-criteria/add-pq-dialog.tsx @@ -68,7 +68,9 @@ const inputFormatOptions = [ { value: "FILE", label: "파일" }, { value: "EMAIL", label: "이메일" }, { value: "PHONE", label: "전화번호" }, + { value: "FAX", label: "팩스번호" }, { value: "NUMBER", label: "숫자" }, + { value: "NUMBER_WITH_UNIT", label: "숫자+단위" }, { value: "TEXT_FILE", label: "텍스트 + 파일" }, ]; diff --git a/lib/pq/pq-criteria/pq-table-column.tsx b/lib/pq/pq-criteria/pq-table-column.tsx index 32d6cc32..ed1180f7 100644 --- a/lib/pq/pq-criteria/pq-table-column.tsx +++ b/lib/pq/pq-criteria/pq-table-column.tsx @@ -18,13 +18,16 @@ import { Button } from "@/components/ui/button" import { Ellipsis } from "lucide-react" import { Badge } from "@/components/ui/badge" import { PqCriterias } from "@/db/schema/pq" +import { toast } from "sonner" interface GetColumnsProps { setRowAction: React.Dispatch | null>> + pqListInfo?: Awaited> } export function getColumns({ setRowAction, + pqListInfo, }: GetColumnsProps): ColumnDef[] { return [ { @@ -205,6 +208,12 @@ export function getColumns({ id: "actions", enableHiding: false, cell: function Cell({ row }) { + const isActive = pqListInfo?.success && pqListInfo.data.status === "ACTIVE"; + + const handleRestrictedAction = () => { + toast.error("활성화된 PQ 목록은 수정할 수 없습니다. 먼저 PQ 목록을 비활성화해주세요."); + }; + return ( @@ -217,18 +226,37 @@ export function getColumns({ - setRowAction({ row, type: "update" })} - > - Edit - + {isActive ? ( + + Edit + + ) : ( + setRowAction({ row, type: "update" })} + > + Edit + + )} - setRowAction({ row, type: "delete" })} - > - Delete - ⌘⌫ - + {isActive ? ( + + Delete + ⌘⌫ + + ) : ( + setRowAction({ row, type: "delete" })} + > + Delete + ⌘⌫ + + )} ) diff --git a/lib/pq/pq-criteria/pq-table-toolbar-actions.tsx b/lib/pq/pq-criteria/pq-table-toolbar-actions.tsx index f168b83d..cdc4f813 100644 --- a/lib/pq/pq-criteria/pq-table-toolbar-actions.tsx +++ b/lib/pq/pq-criteria/pq-table-toolbar-actions.tsx @@ -17,37 +17,59 @@ import { type Table } from "@tanstack/react-table" import { DeletePqsDialog } from "./delete-pqs-dialog" import { AddPqDialog } from "./add-pq-dialog" import { PqCriterias } from "@/db/schema/pq" +import { toast } from "sonner" // import { ImportPqButton } from "./import-pq-button" // import { exportPqTemplate } from "./pq-excel-template" interface PqTableToolbarActionsProps { table: Table pqListId: number + pqListInfo: Awaited> } export function PqTableToolbarActions({ table, - pqListId + pqListId, + pqListInfo }: PqTableToolbarActionsProps) { - // const [refreshKey, setRefreshKey] = React.useState(0) + // PQ 리스트가 ACTIVE 상태인지 확인 + const isActive = pqListInfo.success && pqListInfo.data.status === "ACTIVE"; - // // Import 성공 후 테이블 갱신 - // const handleImportSuccess = () => { - // setRefreshKey(prev => prev + 1) - // } + // ACTIVE 상태일 때 기능 제한 + const handleRestrictedAction = () => { + toast.error("활성화된 PQ 목록은 수정할 수 없습니다. 먼저 PQ 목록을 비활성화해주세요."); + }; return (
{table.getFilteredSelectedRowModel().rows.length > 0 ? ( - row.original)} - onSuccess={() => table.toggleAllRowsSelected(false)} - /> + isActive ? ( + + ) : ( + row.original)} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + ) ) : null} - + {isActive ? ( + + ) : ( + + )} {/* Import 버튼 */} {/* >]> + promises: Promise<[Awaited>, Awaited>]> pqListId: number } @@ -27,14 +27,14 @@ export function PqsTable({ pqListId }: DocumentListTableProps) { // 1) 데이터를 가져옴 (server component -> use(...) pattern) - const [{ data, pageCount }] = React.use(promises) + const [{ data, pageCount }, pqListInfo] = React.use(promises) const [rowAction, setRowAction] = React.useState | null>(null) const columns = React.useMemo( - () => getColumns({ setRowAction }), - [setRowAction] + () => getColumns({ setRowAction, pqListInfo }), + [setRowAction, pqListInfo] ) // Filter fields @@ -105,7 +105,7 @@ export function PqsTable({ filterFields={advancedFilterFields} shallow={false} > - + diff --git a/lib/pq/pq-criteria/update-pq-sheet.tsx b/lib/pq/pq-criteria/update-pq-sheet.tsx index 245627e6..fb298e9b 100644 --- a/lib/pq/pq-criteria/update-pq-sheet.tsx +++ b/lib/pq/pq-criteria/update-pq-sheet.tsx @@ -63,6 +63,7 @@ const inputFormatOptions = [ { value: "EMAIL", label: "이메일" }, { value: "PHONE", label: "전화번호" }, { value: "NUMBER", label: "숫자" }, + { value: "NUMBER_WITH_UNIT", label: "숫자+단위" }, { value: "TEXT_FILE", label: "텍스트 + 파일" } ]; diff --git a/lib/pq/pq-review-table-new/edit-investigation-dialog.tsx b/lib/pq/pq-review-table-new/edit-investigation-dialog.tsx index c5470e47..c4057798 100644 --- a/lib/pq/pq-review-table-new/edit-investigation-dialog.tsx +++ b/lib/pq/pq-review-table-new/edit-investigation-dialog.tsx @@ -224,7 +224,7 @@ export function EditInvestigationDialog({ name="confirmedAt" render={({ field }) => ( - 실사 확정일 + 실사 계획 확정일 diff --git a/lib/pq/pq-review-table-new/request-investigation-dialog.tsx b/lib/pq/pq-review-table-new/request-investigation-dialog.tsx index 6941adbb..aaf10a71 100644 --- a/lib/pq/pq-review-table-new/request-investigation-dialog.tsx +++ b/lib/pq/pq-review-table-new/request-investigation-dialog.tsx @@ -55,7 +55,7 @@ const requestInvestigationFormSchema = z.object({ required_error: "QM 담당자를 선택해주세요.", }), forecastedAt: z.date({ - required_error: "실사 예정일을 선택해주세요.", + required_error: "실사 수행 예정일을 선택해주세요.", }), investigationAddress: z.string().min(1, "실사 장소를 입력해주세요."), investigationMethod: z.string().optional(), @@ -189,7 +189,7 @@ export function RequestInvestigationDialog({ name="forecastedAt" render={({ field }) => ( - 실사 예정일 + 실사 수행 예정일 @@ -201,7 +201,7 @@ export function RequestInvestigationDialog({ {field.value ? ( format(field.value, "yyyy년 MM월 dd일") ) : ( - 실사 예정일을 선택하세요 + 실사 수행 예정일을 선택하세요 )} diff --git a/lib/pq/pq-review-table-new/send-results-dialog.tsx b/lib/pq/pq-review-table-new/send-results-dialog.tsx index 3c8614cc..6c75e6ca 100644 --- a/lib/pq/pq-review-table-new/send-results-dialog.tsx +++ b/lib/pq/pq-review-table-new/send-results-dialog.tsx @@ -123,7 +123,9 @@ export function SendResultsDialog({
Vendor
-
{result.vendorCode} | {result.vendorName}
+
+ {(result.vendorCode === "N/A" ? "미등록" : result.vendorCode)} | {result.vendorName} +
수신자
@@ -151,7 +153,9 @@ export function SendResultsDialog({
{result.investigationNotes && (
-
실사합격조건
+
+ {result.auditResult.includes("Pass") ? "QM 의견" : "실사합격조건"} +
{result.investigationNotes}
)} diff --git a/lib/pq/pq-review-table-new/vendors-table-columns.tsx b/lib/pq/pq-review-table-new/vendors-table-columns.tsx index ffa15e56..30b1c83f 100644 --- a/lib/pq/pq-review-table-new/vendors-table-columns.tsx +++ b/lib/pq/pq-review-table-new/vendors-table-columns.tsx @@ -3,6 +3,11 @@ import * as React from "react" import { type DataTableRowAction } from "@/types/table" import { type ColumnDef } from "@tanstack/react-table" + +// ColumnDef 타입 확장 +type ExtendedColumnDef = ColumnDef & { + excelHeader?: string; +} import { Ellipsis, Eye, FileEdit, Trash2, Building2, FileText, Edit } from "lucide-react" import { formatDate } from "@/lib/utils" @@ -116,11 +121,11 @@ function getStatusBadge(status: string) { /** * tanstack table 컬럼 정의 */ -export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef[] { +export function getColumns({ setRowAction, router }: GetColumnsProps): ExtendedColumnDef[] { // ---------------------------------------------------------------- // 1) select 컬럼 (체크박스) // ---------------------------------------------------------------- - const selectColumn: ColumnDef = { + const selectColumn: ExtendedColumnDef = { id: "select", header: ({ table }) => { const selectedRows = table.getSelectedRowModel().rows; @@ -180,7 +185,7 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef // -------------------------- // -------------------------------------- - const pqNoColumn: ColumnDef = { + const pqNoColumn: ExtendedColumnDef = { accessorKey: "pqNumber", header: ({ column }) => ( @@ -190,10 +195,11 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef {row.getValue("pqNumber")} ), + excelHeader: "PQ No.", } // 협력업체 컬럼 - const vendorColumn: ColumnDef = { + const vendorColumn: ExtendedColumnDef = { accessorKey: "vendorName", header: ({ column }) => ( @@ -206,10 +212,11 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef ), enableSorting: true, enableHiding: true, + excelHeader: "협력업체", } // PQ 유형 컬럼 - const typeColumn: ColumnDef = { + const typeColumn: ExtendedColumnDef = { accessorKey: "type", header: ({ column }) => ( @@ -233,10 +240,11 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef }, enableSorting: true, enableHiding: true, + excelHeader: "PQ 유형", } // 프로젝트 컬럼 - const projectColumn: ColumnDef = { + const projectColumn: ExtendedColumnDef = { accessorKey: "projectName", header: ({ column }) => ( @@ -260,10 +268,11 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef }, enableSorting: true, enableHiding: true, + excelHeader: "프로젝트", } // 상태 컬럼 - const statusColumn: ColumnDef = { + const statusColumn: ExtendedColumnDef = { accessorKey: "combinedStatus", header: ({ column }) => ( @@ -278,6 +287,7 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef }, enableSorting: true, enableHiding: true, + excelHeader: "진행현황", }; // PQ 상태와 실사 상태를 결합하는 헬퍼 함수 @@ -371,7 +381,7 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef // }; - const evaluationResultColumn: ColumnDef = { + const evaluationResultColumn: ExtendedColumnDef = { accessorKey: "evaluationResult", header: ({ column }) => ( @@ -401,10 +411,11 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef }, enableSorting: true, enableHiding: true, + excelHeader: "평가 결과", }; // 답변 수 컬럼 - const answerCountColumn: ColumnDef = { + const answerCountColumn: ExtendedColumnDef = { accessorKey: "answerCount", header: ({ column }) => ( @@ -416,9 +427,10 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef ) }, + excelHeader: "답변 수", } - const investigationAddressColumn: ColumnDef = { + const investigationAddressColumn: ExtendedColumnDef = { accessorKey: "investigationAddress", header: ({ column }) => ( @@ -436,8 +448,9 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef ) }, + excelHeader: "실사 주소", } - const investigationRequestedAtColumn: ColumnDef = { + const investigationRequestedAtColumn: ExtendedColumnDef = { accessorKey: "investigationRequestedAt", header: ({ column }) => ( @@ -456,9 +469,10 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef ) }, + excelHeader: "실사 의뢰일", } - const investigationNotesColumn: ColumnDef = { + const investigationNotesColumn: ExtendedColumnDef = { accessorKey: "investigationNotes", header: ({ column }) => ( @@ -476,8 +490,9 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef ) }, + excelHeader: "QM 의견", } - const investigationMethodColumn: ColumnDef = { + const investigationMethodColumn: ExtendedColumnDef = { accessorKey: "investigationMethod", header: ({ column }) => ( @@ -501,10 +516,11 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef return {investigation.investigationMethod}; } }, + excelHeader: "실사품목", } // 실사품목 컬럼 - const pqItemsColumn: ColumnDef = { + const pqItemsColumn: ExtendedColumnDef = { accessorKey: "pqItems", header: ({ column }) => ( @@ -536,10 +552,10 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef - const investigationForecastedAtColumn: ColumnDef = { + const investigationForecastedAtColumn: ExtendedColumnDef = { accessorKey: "investigationForecastedAt", header: ({ column }) => ( - + ), cell: ({ row }) => { const investigation = row.original.investigation; @@ -555,12 +571,13 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef ) }, + excelHeader: "실사 수행 예정일", } - const investigationConfirmedAtColumn: ColumnDef = { + const investigationConfirmedAtColumn: ExtendedColumnDef = { accessorKey: "investigationConfirmedAt", header: ({ column }) => ( - + ), cell: ({ row }) => { const investigation = row.original.investigation; @@ -576,9 +593,10 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef ) }, + excelHeader: "실사 계획 확정일", } - const investigationCompletedAtColumn: ColumnDef = { + const investigationCompletedAtColumn: ExtendedColumnDef = { accessorKey: "investigationCompletedAt", header: ({ column }) => ( @@ -597,10 +615,11 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef ) }, + excelHeader: "실제 실사일", } // 제출일 컬럼 - const createdAtColumn: ColumnDef = { + const createdAtColumn: ExtendedColumnDef = { accessorKey: "createdAt", header: ({ column }) => ( @@ -609,10 +628,11 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef const dateVal = row.original.createdAt as Date return formatDate(dateVal, 'KR') }, + excelHeader: "PQ 전송일", } // 제출일 컬럼 - const submittedAtColumn: ColumnDef = { + const submittedAtColumn: ExtendedColumnDef = { accessorKey: "submittedAt", header: ({ column }) => ( @@ -621,10 +641,11 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef const dateVal = row.original.submittedAt as Date return dateVal ? formatDate(dateVal, 'KR') : "-" }, + excelHeader: "PQ 회신일", } // 승인/거부일 컬럼 - const approvalDateColumn: ColumnDef = { + const approvalDateColumn: ExtendedColumnDef = { accessorKey: "approvedAt", header: ({ column }) => ( @@ -638,12 +659,13 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef } return "-" }, + excelHeader: "PQ 승인/거부일", } // ---------------------------------------------------------------- // 3) actions 컬럼 (Dropdown 메뉴) // ---------------------------------------------------------------- - const actionsColumn: ColumnDef = { + const actionsColumn: ExtendedColumnDef = { id: "actions", enableHiding: false, cell: function Cell({ row }) { @@ -676,7 +698,7 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef ) : ( <> - 보기 + PQ 현황 )} @@ -697,7 +719,7 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef }} > - 방문실사 + 실사 정보 전달 및 요청 { @@ -710,7 +732,7 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef }} > - 협력업체 정보 조회 + 실사 실시 확정 정보 )} @@ -758,7 +780,7 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef } // 요청자 컬럼 추가 -const requesterColumn: ColumnDef = { +const requesterColumn: ExtendedColumnDef = { accessorKey: "requesterName", header: ({ column }) => ( @@ -779,8 +801,9 @@ const requesterColumn: ColumnDef = { ? {pqRequesterName} : -; }, + excelHeader: "PQ/실사 요청자", }; -const qmManagerColumn: ColumnDef = { +const qmManagerColumn: ExtendedColumnDef = { accessorKey: "qmManager", header: ({ column }) => ( @@ -801,6 +824,7 @@ const qmManagerColumn: ColumnDef = { ); }, + excelHeader: "QM 담당자", }; diff --git a/lib/pq/service.ts b/lib/pq/service.ts index 7296b836..54459a6c 100644 --- a/lib/pq/service.ts +++ b/lib/pq/service.ts @@ -5,7 +5,7 @@ import { CopyPqListInput, CreatePqListInput, UpdatePqValidToInput, copyPqListSch import { unstable_cache } from "@/lib/unstable-cache"; import { filterColumns } from "@/lib/filter-columns"; import { getErrorMessage } from "@/lib/handle-error"; -import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, ne, count,isNull,SQL, sql, lt, isNotNull} from "drizzle-orm"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, ne, count,isNull,SQL, sql, lt, gt, isNotNull} from "drizzle-orm"; import { z } from "zod" import { revalidateTag, unstable_noStore, revalidatePath} from "next/cache"; import { format } from "date-fns" @@ -91,13 +91,25 @@ export async function getPQProjectsByVendorId(vendorId: number): Promise { try { // 파라미터 유효성 검증 if (isNaN(vendorId)) { throw new Error("Invalid vendorId parameter"); } + + // 타입 결정 로직 + let finalPqType: "GENERAL" | "PROJECT" | "NON_INSPECTION"; + if (pqType) { + finalPqType = pqType; + } else if (projectId) { + finalPqType = "PROJECT"; + } else { + finalPqType = "GENERAL"; + } + // 기본 쿼리 구성 const selectObj = { criteriaId: pqCriterias.id, @@ -127,65 +139,45 @@ export async function getPQDataByVendorId( fileSize: vendorCriteriaAttachments.fileSize, }; - // Create separate queries for each case instead of modifying the same query variable - if (projectId) { - // 프로젝트별 PQ 쿼리 - PQ 리스트 기반으로 변경 - const rows = await db - .select(selectObj) - .from(pqCriterias) - .innerJoin( - pqLists, - and( - eq(pqCriterias.pqListId, pqLists.id), - eq(pqLists.projectId, projectId), - eq(pqLists.type, "PROJECT"), - eq(pqLists.isDeleted, false) - ) - ) - .leftJoin( - vendorPqCriteriaAnswers, - and( - eq(pqCriterias.id, vendorPqCriteriaAnswers.criteriaId), - eq(vendorPqCriteriaAnswers.vendorId, vendorId), - eq(vendorPqCriteriaAnswers.projectId, projectId) - ) - ) - .leftJoin( - vendorCriteriaAttachments, - eq(vendorPqCriteriaAnswers.id, vendorCriteriaAttachments.vendorCriteriaAnswerId) - ) - .orderBy(pqCriterias.groupName, pqCriterias.code); - - return processQueryResults(rows); - } else { - // 일반 PQ 쿼리 - PQ 리스트 기반으로 변경 - const rows = await db - .select(selectObj) - .from(pqCriterias) - .innerJoin( - pqLists, - and( - eq(pqCriterias.pqListId, pqLists.id), - eq(pqLists.type, "GENERAL"), - eq(pqLists.isDeleted, false) - ) - ) - .leftJoin( - vendorPqCriteriaAnswers, - and( - eq(pqCriterias.id, vendorPqCriteriaAnswers.criteriaId), - eq(vendorPqCriteriaAnswers.vendorId, vendorId), - isNull(vendorPqCriteriaAnswers.projectId) - ) - ) - .leftJoin( - vendorCriteriaAttachments, - eq(vendorPqCriteriaAnswers.id, vendorCriteriaAttachments.vendorCriteriaAnswerId) - ) - .orderBy(pqCriterias.groupName, pqCriterias.code); - - return processQueryResults(rows); + // 타입별 쿼리 조건 구성 + const pqListConditions = [ + eq(pqCriterias.pqListId, pqLists.id), + eq(pqLists.type, finalPqType), + eq(pqLists.isDeleted, false) + ]; + + const answerConditions = [ + eq(pqCriterias.id, vendorPqCriteriaAnswers.criteriaId), + eq(vendorPqCriteriaAnswers.vendorId, vendorId) + ]; + + // 프로젝트별 조건 추가 + if (finalPqType === "PROJECT" && projectId) { + pqListConditions.push(eq(pqLists.projectId, projectId)); + answerConditions.push(eq(vendorPqCriteriaAnswers.projectId, projectId)); + } else if (finalPqType === "GENERAL" || finalPqType === "NON_INSPECTION") { + pqListConditions.push(isNull(pqLists.projectId)); + answerConditions.push(isNull(vendorPqCriteriaAnswers.projectId)); } + + const rows = await db + .select(selectObj) + .from(pqCriterias) + .innerJoin( + pqLists, + and(...pqListConditions) + ) + .leftJoin( + vendorPqCriteriaAnswers, + and(...answerConditions) + ) + .leftJoin( + vendorCriteriaAttachments, + eq(vendorPqCriteriaAnswers.id, vendorCriteriaAttachments.vendorCriteriaAnswerId) + ) + .orderBy(pqCriterias.groupName, pqCriterias.code); + + return processQueryResults(rows); } catch (error) { console.error("Error fetching PQ data:", error); return []; @@ -790,199 +782,199 @@ export async function uploadSHIMultipleFilesAction(files: File[], userId?: strin } } -export async function getVendorsInPQ(input: GetVendorsSchema) { - return unstable_cache( - async () => { - try { - const offset = (input.page - 1) * input.perPage; - - // 1) 고급 필터 - const advancedWhere = filterColumns({ - table: vendors, - filters: input.filters, - joinOperator: input.joinOperator, - }); - - // 2) 글로벌 검색 - let globalWhere: SQL | undefined = undefined; - if (input.search) { - const s = `%${input.search}%`; - globalWhere = or( - ilike(vendors.vendorName, s), - ilike(vendors.vendorCode, s), - ilike(vendors.email, s), - ilike(vendors.status, s) - ); - } - - // 트랜잭션 내에서 데이터 조회 - const { data, total } = await db.transaction(async (tx) => { - // 협력업체 ID 모음 (중복 제거용) - const vendorIds = new Set(); +// export async function getVendorsInPQ(input: GetVendorsSchema) { +// return unstable_cache( +// async () => { +// try { +// const offset = (input.page - 1) * input.perPage; + +// // 1) 고급 필터 +// const advancedWhere = filterColumns({ +// table: vendors, +// filters: input.filters, +// joinOperator: input.joinOperator, +// }); + +// // 2) 글로벌 검색 +// let globalWhere: SQL | undefined = undefined; +// if (input.search) { +// const s = `%${input.search}%`; +// globalWhere = or( +// ilike(vendors.vendorName, s), +// ilike(vendors.vendorCode, s), +// ilike(vendors.email, s), +// ilike(vendors.status, s) +// ); +// } + +// // 트랜잭션 내에서 데이터 조회 +// const { data, total } = await db.transaction(async (tx) => { +// // 협력업체 ID 모음 (중복 제거용) +// const vendorIds = new Set(); - // 1-A) 일반 PQ 답변이 있는 협력업체 찾기 (status와 상관없이) - const generalPqVendors = await tx - .select({ - vendorId: vendorPqCriteriaAnswers.vendorId - }) - .from(vendorPqCriteriaAnswers) - .innerJoin( - vendors, - eq(vendorPqCriteriaAnswers.vendorId, vendors.id) - ) - .where( - and( - isNull(vendorPqCriteriaAnswers.projectId), // 일반 PQ만 (프로젝트 PQ 아님) - advancedWhere, - globalWhere - ) - ) - .groupBy(vendorPqCriteriaAnswers.vendorId); // 각 벤더당 한 번만 카운트 +// // 1-A) 일반 PQ 답변이 있는 협력업체 찾기 (status와 상관없이) +// const generalPqVendors = await tx +// .select({ +// vendorId: vendorPqCriteriaAnswers.vendorId +// }) +// .from(vendorPqCriteriaAnswers) +// .innerJoin( +// vendors, +// eq(vendorPqCriteriaAnswers.vendorId, vendors.id) +// ) +// .where( +// and( +// isNull(vendorPqCriteriaAnswers.projectId), // 일반 PQ만 (프로젝트 PQ 아님) +// advancedWhere, +// globalWhere +// ) +// ) +// .groupBy(vendorPqCriteriaAnswers.vendorId); // 각 벤더당 한 번만 카운트 - generalPqVendors.forEach(v => vendorIds.add(v.vendorId)); +// generalPqVendors.forEach(v => vendorIds.add(v.vendorId)); - // 1-B) 프로젝트 PQ 답변이 있는 협력업체 ID 조회 (status와 상관없이) - const projectPqVendors = await tx - .select({ - vendorId: vendorPQSubmissions.vendorId - }) - .from(vendorPQSubmissions) - .innerJoin( - vendors, - eq(vendorPQSubmissions.vendorId, vendors.id) - ) - .where( - and( - eq(vendorPQSubmissions.type, "PROJECT"), - // 최소한 IN_PROGRESS부터는 작업이 시작된 상태이므로 포함 - not(eq(vendorPQSubmissions.status, "REQUESTED")), // REQUESTED 상태는 제외 - advancedWhere, - globalWhere - ) - ); +// // 1-B) 프로젝트 PQ 답변이 있는 협력업체 ID 조회 (status와 상관없이) +// const projectPqVendors = await tx +// .select({ +// vendorId: vendorPQSubmissions.vendorId +// }) +// .from(vendorPQSubmissions) +// .innerJoin( +// vendors, +// eq(vendorPQSubmissions.vendorId, vendors.id) +// ) +// .where( +// and( +// eq(vendorPQSubmissions.type, "PROJECT"), +// // 최소한 IN_PROGRESS부터는 작업이 시작된 상태이므로 포함 +// not(eq(vendorPQSubmissions.status, "REQUESTED")), // REQUESTED 상태는 제외 +// advancedWhere, +// globalWhere +// ) +// ); - projectPqVendors.forEach(v => vendorIds.add(v.vendorId)); +// projectPqVendors.forEach(v => vendorIds.add(v.vendorId)); - // 중복 제거된 협력업체 ID 배열 - const uniqueVendorIds = Array.from(vendorIds); +// // 중복 제거된 협력업체 ID 배열 +// const uniqueVendorIds = Array.from(vendorIds); - // 총 개수 (중복 제거 후) - const total = uniqueVendorIds.length; +// // 총 개수 (중복 제거 후) +// const total = uniqueVendorIds.length; - if (total === 0) { - return { data: [], total: 0 }; - } +// if (total === 0) { +// return { data: [], total: 0 }; +// } - // 페이징 처리 (정렬 후 limit/offset 적용) - const paginatedIds = uniqueVendorIds.slice(offset, offset + input.perPage); +// // 페이징 처리 (정렬 후 limit/offset 적용) +// const paginatedIds = uniqueVendorIds.slice(offset, offset + input.perPage); - // 2) 페이징된 협력업체 상세 정보 조회 - const vendorsData = await selectVendors(tx, { - where: inArray(vendors.id, paginatedIds), - orderBy: input.sort.length > 0 - ? input.sort.map((item) => - item.desc ? desc(vendors.vendorName) : asc(vendors.vendorName) - ) - : [asc(vendors.createdAt)], - }); +// // 2) 페이징된 협력업체 상세 정보 조회 +// const vendorsData = await selectVendors(tx, { +// where: inArray(vendors.id, paginatedIds), +// orderBy: input.sort.length > 0 +// ? input.sort.map((item) => +// item.desc ? desc(vendors.vendorName) : asc(vendors.vendorName) +// ) +// : [asc(vendors.createdAt)], +// }); - // 3) 각 벤더별 PQ 상태 정보 추가 - const vendorsWithPqInfo = await Promise.all( - vendorsData.map(async (vendor) => { - // 3-A) 첨부 파일 조회 - const attachments = await tx - .select({ - id: vendorAttachments.id, - fileName: vendorAttachments.fileName, - filePath: vendorAttachments.filePath, - }) - .from(vendorAttachments) - .where(eq(vendorAttachments.vendorId, vendor.id)); +// // 3) 각 벤더별 PQ 상태 정보 추가 +// const vendorsWithPqInfo = await Promise.all( +// vendorsData.map(async (vendor) => { +// // 3-A) 첨부 파일 조회 +// const attachments = await tx +// .select({ +// id: vendorAttachments.id, +// fileName: vendorAttachments.fileName, +// filePath: vendorAttachments.filePath, +// }) +// .from(vendorAttachments) +// .where(eq(vendorAttachments.vendorId, vendor.id)); - // 3-B) 일반 PQ 제출 여부 확인 (PQ 답변이 있는지) - const generalPqAnswers = await tx - .select({ count: count() }) - .from(vendorPqCriteriaAnswers) - .where( - and( - eq(vendorPqCriteriaAnswers.vendorId, vendor.id), - isNull(vendorPqCriteriaAnswers.projectId) - ) - ); +// // 3-B) 일반 PQ 제출 여부 확인 (PQ 답변이 있는지) +// const generalPqAnswers = await tx +// .select({ count: count() }) +// .from(vendorPqCriteriaAnswers) +// .where( +// and( +// eq(vendorPqCriteriaAnswers.vendorId, vendor.id), +// isNull(vendorPqCriteriaAnswers.projectId) +// ) +// ); - const hasGeneralPq = generalPqAnswers[0]?.count > 0; +// const hasGeneralPq = generalPqAnswers[0]?.count > 0; - // 3-C) 프로젝트 PQ 정보 조회 (모든 상태 포함) - const projectPqs = await tx - .select({ - projectId: vendorPQSubmissions.projectId, - projectName: projects.name, - status: vendorPQSubmissions.status, - submittedAt: vendorPQSubmissions.submittedAt, - approvedAt: vendorPQSubmissions.approvedAt, - rejectedAt: vendorPQSubmissions.rejectedAt - }) - .from(vendorPQSubmissions) - .innerJoin( - projects, - eq(vendorPQSubmissions.projectId, projects.id) - ) - .where( - and( - eq(vendorPQSubmissions.vendorId, vendor.id), - eq(vendorPQSubmissions.type, "PROJECT"), - not(eq(vendorPQSubmissions.status, "REQUESTED")) // REQUESTED 상태는 제외 - ) - ); +// // 3-C) 프로젝트 PQ 정보 조회 (모든 상태 포함) +// const projectPqs = await tx +// .select({ +// projectId: vendorPQSubmissions.projectId, +// projectName: projects.name, +// status: vendorPQSubmissions.status, +// submittedAt: vendorPQSubmissions.submittedAt, +// approvedAt: vendorPQSubmissions.approvedAt, +// rejectedAt: vendorPQSubmissions.rejectedAt +// }) +// .from(vendorPQSubmissions) +// .innerJoin( +// projects, +// eq(vendorPQSubmissions.projectId, projects.id) +// ) +// .where( +// and( +// eq(vendorPQSubmissions.vendorId, vendor.id), +// eq(vendorPQSubmissions.type, "PROJECT"), +// not(eq(vendorPQSubmissions.status, "REQUESTED")) // REQUESTED 상태는 제외 +// ) +// ); - const hasProjectPq = projectPqs.length > 0; +// const hasProjectPq = projectPqs.length > 0; - // 프로젝트 PQ 상태별 카운트 - const projectPqStatusCounts = { - inProgress: projectPqs.filter(p => p.status === "IN_PROGRESS").length, - submitted: projectPqs.filter(p => p.status === "SUBMITTED").length, - approved: projectPqs.filter(p => p.status === "APPROVED").length, - rejected: projectPqs.filter(p => p.status === "REJECTED").length, - total: projectPqs.length - }; +// // 프로젝트 PQ 상태별 카운트 +// const projectPqStatusCounts = { +// inProgress: projectPqs.filter(p => p.status === "IN_PROGRESS").length, +// submitted: projectPqs.filter(p => p.status === "SUBMITTED").length, +// approved: projectPqs.filter(p => p.status === "APPROVED").length, +// rejected: projectPqs.filter(p => p.status === "REJECTED").length, +// total: projectPqs.length +// }; - // 3-D) PQ 상태 정보 추가 - return { - ...vendor, - hasAttachments: attachments.length > 0, - attachmentsList: attachments, - pqInfo: { - hasGeneralPq, - hasProjectPq, - projectPqs, - projectPqStatusCounts, - // 현재 PQ 상태 (UI에 표시 용도) - pqStatus: getPqStatusDisplay(vendor.status, hasGeneralPq, hasProjectPq, projectPqStatusCounts) - } - }; - }) - ); +// // 3-D) PQ 상태 정보 추가 +// return { +// ...vendor, +// hasAttachments: attachments.length > 0, +// attachmentsList: attachments, +// pqInfo: { +// hasGeneralPq, +// hasProjectPq, +// projectPqs, +// projectPqStatusCounts, +// // 현재 PQ 상태 (UI에 표시 용도) +// pqStatus: getPqStatusDisplay(vendor.status, hasGeneralPq, hasProjectPq, projectPqStatusCounts) +// } +// }; +// }) +// ); - return { data: vendorsWithPqInfo, total }; - }); - - // 페이지 수 - const pageCount = Math.ceil(total / input.perPage); - - return { data, pageCount }; - } catch (err) { - console.error("Error in getVendorsInPQ:", err); - // 에러 발생 시 - return { data: [], pageCount: 0 }; - } - }, - [JSON.stringify(input)], // 캐싱 키 - { - revalidate: 3600, - tags: ["vendors-in-pq", "project-pqs"], // revalidateTag 호출 시 무효화 - } - )(); -} +// return { data: vendorsWithPqInfo, total }; +// }); + +// // 페이지 수 +// const pageCount = Math.ceil(total / input.perPage); + +// return { data, pageCount }; +// } catch (err) { +// console.error("Error in getVendorsInPQ:", err); +// // 에러 발생 시 +// return { data: [], pageCount: 0 }; +// } +// }, +// [JSON.stringify(input)], // 캐싱 키 +// { +// revalidate: 3600, +// tags: ["vendors-in-pq", "project-pqs"], // revalidateTag 호출 시 무효화 +// } +// )(); +// } // PQ 상태 표시 함수 function getPqStatusDisplay( @@ -3105,6 +3097,66 @@ export async function togglePQListsAction(ids: number[], newIsDeleted: boolean) const session = await getServerSession(authOptions); const userId = session?.user?.id ? Number(session.user.id) : null; const now = new Date(); + + // 활성화하려는 경우 중복 활성화 체크 + if (!newIsDeleted) { + // 선택된 PQ 리스트들의 정보를 먼저 가져옴 + const selectedPqLists = await db + .select({ + id: pqLists.id, + name: pqLists.name, + type: pqLists.type, + projectId: pqLists.projectId, + }) + .from(pqLists) + .where(inArray(pqLists.id, ids)); + + // 현재 활성화된 PQ 리스트 확인 + const activePqLists = await db + .select({ + id: pqLists.id, + name: pqLists.name, + type: pqLists.type, + projectId: pqLists.projectId, + }) + .from(pqLists) + .where(and( + eq(pqLists.isDeleted, false), + not(inArray(pqLists.id, ids)) + )); + + // 각 선택된 PQ 리스트에 대해 중복 체크 + for (const selectedPq of selectedPqLists) { + // 일반 PQ 또는 미실사 PQ인 경우 + if (selectedPq.type === "GENERAL" || selectedPq.type === "NON_INSPECTION") { + const activeSameType = activePqLists.filter(pq => pq.type === selectedPq.type); + + if (activeSameType.length > 0) { + const activeNames = activeSameType.map(pq => pq.name).join(", "); + return { + success: false, + error: `${selectedPq.type === "GENERAL" ? "일반" : "미실사"} PQ는 하나만 활성화할 수 있습니다.먼저 활성화된 ${selectedPq.type === "GENERAL" ? "일반" : "미실사"} PQ를 비활성화한 후 활성화해주세요.` + }; + } + } + + // 프로젝트 PQ인 경우 + if (selectedPq.type === "PROJECT" && selectedPq.projectId) { + const activeSameProject = activePqLists.filter(pq => + pq.type === "PROJECT" && pq.projectId === selectedPq.projectId + ); + + if (activeSameProject.length > 0) { + const activeNames = activeSameProject.map(pq => pq.name).join(", "); + return { + success: false, + error: `프로젝트 PQ는 프로젝트별로 하나만 활성화할 수 있습니다. 먼저 활성화된 프로젝트 PQ를 비활성화한 후 활성화해주세요.` + }; + } + } + } + } + const updated = await db .update(pqLists) .set({ isDeleted: newIsDeleted, updatedAt: now, updatedBy: userId }) @@ -3726,6 +3778,65 @@ export async function deletePQSubmissionAction(pqSubmissionId: number) { } // PQ 목록별 항목 조회 (특정 pqListId에 속한 PQ 항목들) +// PQ 리스트 정보 조회 (상태 포함) +export async function getPQListInfo(pqListId: number) { + return unstable_cache( + async () => { + try { + const pqList = await db + .select({ + id: pqLists.id, + name: pqLists.name, + type: pqLists.type, + projectId: pqLists.projectId, + validTo: pqLists.validTo, + isDeleted: pqLists.isDeleted, + createdAt: pqLists.createdAt, + updatedAt: pqLists.updatedAt, + }) + .from(pqLists) + .where(and( + eq(pqLists.id, pqListId), + eq(pqLists.isDeleted, false) + )) + .limit(1) + .then(rows => rows[0]); + + if (!pqList) { + return { + success: false, + error: "PQ 목록을 찾을 수 없습니다" + }; + } + + // 현재 시간과 비교하여 상태 결정 + const now = new Date(); + const isValid = !pqList.validTo || pqList.validTo > now; + const status = isValid ? "ACTIVE" : "INACTIVE"; + + return { + success: true, + data: { + ...pqList, + status + } + }; + } catch (error) { + console.error("Error in getPQListInfo:", error); + return { + success: false, + error: "PQ 목록 정보를 가져오는 중 오류가 발생했습니다" + }; + } + }, + [`pq-list-info-${pqListId}`], + { + tags: ["pq-lists"], + revalidate: 3600, // 1시간 + } + )(); +} + export async function getPQsByListId(pqListId: number, input: GetPQSchema) { return unstable_cache( async () => { diff --git a/lib/pq/table/pq-lists-table.tsx b/lib/pq/table/pq-lists-table.tsx index 1be0a1c7..e7c0ab0d 100644 --- a/lib/pq/table/pq-lists-table.tsx +++ b/lib/pq/table/pq-lists-table.tsx @@ -85,7 +85,7 @@ export function PqListsTable({ promises }: PqListsTableProps) { toast.success(newIsDeleted ? "PQ 목록이 비활성화되었습니다" : "PQ 목록이 활성화되었습니다") router.refresh() } else { - toast.error("PQ 목록 상태 변경 실패") + toast.error(result.error || "PQ 목록 상태 변경 실패") } }) } diff --git a/lib/vendor-investigation/table/investigation-table.tsx b/lib/vendor-investigation/table/investigation-table.tsx index fcd2d0be..b7663629 100644 --- a/lib/vendor-investigation/table/investigation-table.tsx +++ b/lib/vendor-investigation/table/investigation-table.tsx @@ -127,9 +127,9 @@ export function VendorsInvestigationTable({ promises }: VendorsTableProps) { }, // 주요 날짜 필터 - { id: "forecastedAt", label: "실사 예정일", type: "date" }, + { id: "forecastedAt", label: "실사 수행 예정일", type: "date" }, { id: "requestedAt", label: "실사 의뢰일", type: "date" }, - { id: "confirmedAt", label: "실사 확정일", type: "date" }, + { id: "confirmedAt", label: "실사 계획 확정일", type: "date" }, { id: "completedAt", label: "실제 실사일", type: "date" }, // 메모 필터 diff --git a/lib/vendor-investigation/table/update-investigation-sheet.tsx b/lib/vendor-investigation/table/update-investigation-sheet.tsx index 37d1b2cd..9f7c8994 100644 --- a/lib/vendor-investigation/table/update-investigation-sheet.tsx +++ b/lib/vendor-investigation/table/update-investigation-sheet.tsx @@ -627,13 +627,13 @@ export function UpdateVendorInvestigationSheet({ )} /> - {/* 실사 예정일 */} + {/* 실사 수행 예정일 */} ( - 실사 예정일 + 실사 수행 예정일 @@ -670,7 +670,7 @@ export function UpdateVendorInvestigationSheet({ name="confirmedAt" render={({ field }) => ( - 실사 확정일 + 실사 계획 확정일 diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts index 6813f717..0c61c270 100644 --- a/lib/vendors/service.ts +++ b/lib/vendors/service.ts @@ -2935,6 +2935,9 @@ export async function requestPQVendors(input: ApproveVendorsInput & { const session = await getServerSession(authOptions); const requesterId = session?.user?.id ? Number(session.user.id) : null; + // 타입 기본값 설정 + const pqType = input.type || "GENERAL"; + try { let projectInfo = null; if (input.projectId) { @@ -2954,7 +2957,6 @@ export async function requestPQVendors(input: ApproveVendorsInput & { } // PQ 리스트 정보 조회 및 문항 검사 - const pqType = input.type || "GENERAL"; const pqListConditions = [ eq(pqLists.type, pqType), eq(pqLists.isDeleted, false) @@ -3008,48 +3010,30 @@ export async function requestPQVendors(input: ApproveVendorsInput & { .from(vendors) .where(inArray(vendors.id, input.ids)); - const pqType = input.type; const currentDate = new Date(); - const existingSubmissions = await tx - .select({ vendorId: vendorPQSubmissions.vendorId }) - .from(vendorPQSubmissions) - .where( - and( - inArray(vendorPQSubmissions.vendorId, input.ids), - pqType ? eq(vendorPQSubmissions.type, pqType) : undefined, - input.projectId - ? eq(vendorPQSubmissions.projectId, input.projectId) - : isNull(vendorPQSubmissions.projectId) - ) - ); - - const existingVendorIds = new Set(existingSubmissions.map((s) => s.vendorId)); - const newVendorIds = input.ids.filter((id) => !existingVendorIds.has(id)); - - if (newVendorIds.length > 0) { - const vendorPQDataPromises = newVendorIds.map(async (vendorId) => { - const pqNumber = await generatePQNumber(pqType === "PROJECT"); + // 중복 체크 제거 - 같은 벤더에게 같은 타입의 PQ를 여러 번 요청 가능 + const vendorPQDataPromises = input.ids.map(async (vendorId) => { + const pqNumber = await generatePQNumber(pqType === "PROJECT"); - return { - vendorId, - pqNumber, - projectId: input.projectId || null, - type: pqType, - status: "REQUESTED", - requesterId: input.userId || requesterId, - dueDate: input.dueDate ? new Date(input.dueDate) : null, - agreements: input.agreements ?? {}, - pqItems: input.pqItems || null, - createdAt: currentDate, - updatedAt: currentDate, - }; - }); + return { + vendorId, + pqNumber, + projectId: input.projectId || null, + type: pqType, + status: "REQUESTED", + requesterId: input.userId || requesterId, + dueDate: input.dueDate ? new Date(input.dueDate) : null, + agreements: input.agreements ?? {}, + pqItems: input.pqItems || null, + createdAt: currentDate, + updatedAt: currentDate, + }; + }); - const vendorPQData = await Promise.all(vendorPQDataPromises); + const vendorPQData = await Promise.all(vendorPQDataPromises); - await tx.insert(vendorPQSubmissions).values(vendorPQData); - } + await tx.insert(vendorPQSubmissions).values(vendorPQData); await Promise.all( vendorsBeforeUpdate.map(async (vendorBefore) => { -- cgit v1.2.3