diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-12-09 05:31:04 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-12-09 05:31:04 +0000 |
| commit | 3462d754574e2558c791c7958d3e5da013a7a573 (patch) | |
| tree | 6eb26fddda5e4081fdead977c0ec6b152286d164 | |
| parent | 3f11179b2c50d7ee56b0cea38778191e3259b941 (diff) | |
(최겸) 구매 pq 내 안전 담당자 평가 기능 추가, vendor 안전적격성 평가 컬럼 추가
| -rw-r--r-- | app/[lng]/evcp/(evcp)/(procurement)/pq_new/[vendorId]/[submissionId]/page.tsx | 50 | ||||
| -rw-r--r-- | components/pq-input/pq-review-wrapper.tsx | 326 | ||||
| -rw-r--r-- | config/vendorColumnsConfig.ts | 9 | ||||
| -rw-r--r-- | db/schema/pq.ts | 10 | ||||
| -rw-r--r-- | db/schema/vendors.ts | 6 | ||||
| -rw-r--r-- | lib/mail/templates/safety-pq-approved.hbs | 15 | ||||
| -rw-r--r-- | lib/mail/templates/safety-pq-rejected.hbs | 18 | ||||
| -rw-r--r-- | lib/pq/pq-review-table-new/vendors-table-columns.tsx | 63 | ||||
| -rw-r--r-- | lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx | 20 | ||||
| -rw-r--r-- | lib/pq/service.ts | 317 | ||||
| -rw-r--r-- | lib/vendors/table/vendors-table-columns.tsx | 21 |
11 files changed, 688 insertions, 167 deletions
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/pq_new/[vendorId]/[submissionId]/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/pq_new/[vendorId]/[submissionId]/page.tsx index 93302f87..974550c6 100644 --- a/app/[lng]/evcp/(evcp)/(procurement)/pq_new/[vendorId]/[submissionId]/page.tsx +++ b/app/[lng]/evcp/(evcp)/(procurement)/pq_new/[vendorId]/[submissionId]/page.tsx @@ -64,7 +64,6 @@ export default async function PQReviewPage(props: PQReviewPageProps) { vendorCountry: pqSubmission.vendorCountry,
vendorEmail: pqSubmission.vendorEmail,
vendorPhone: pqSubmission.vendorPhone,
- vendorFax: pqSubmission.vendorFax,
}
// 프로젝트 정보 (프로젝트 PQ인 경우)
@@ -114,7 +113,30 @@ export default async function PQReviewPage(props: PQReviewPageProps) { <Alert>
<AlertTitle>제출 완료</AlertTitle>
<AlertDescription>
- 협력업체가 {formatDate(pqSubmission.submittedAt, "kr")}에 PQ를 제출했습니다. 검토 후 승인 또는 거부할 수 있습니다.
+ 협력업체가 {pqSubmission.submittedAt ? formatDate(pqSubmission.submittedAt) : "N/A"}에 PQ를 제출했습니다. 검토 후 승인 또는 거부할 수 있습니다.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {pqSubmission.status === "SAFETY_APPROVED" && (
+ <Alert variant="success">
+ <AlertTitle>안전 검토 승인됨</AlertTitle>
+ <AlertDescription>
+ 안전팀 검토가 완료되었습니다. 구매 승인 단계를 진행해주세요.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {pqSubmission.status === "SAFETY_REJECTED" && (
+ <Alert variant="destructive">
+ <AlertTitle>안전 검토 거절됨</AlertTitle>
+ <AlertDescription>
+ 안전팀에서 PQ를 거절했습니다.
+ {pqSubmission.rejectReason && (
+ <div className="mt-2">
+ <strong>사유:</strong> {pqSubmission.rejectReason}
+ </div>
+ )}
</AlertDescription>
</Alert>
)}
@@ -123,7 +145,7 @@ export default async function PQReviewPage(props: PQReviewPageProps) { <Alert variant="success">
<AlertTitle>승인됨</AlertTitle>
<AlertDescription>
- {formatDate(pqSubmission.approvedAt, "kr")}에 승인되었습니다.
+ {pqSubmission.approvedAt ? formatDate(pqSubmission.approvedAt) : "N/A"}에 승인되었습니다.
</AlertDescription>
</Alert>
)}
@@ -132,7 +154,7 @@ export default async function PQReviewPage(props: PQReviewPageProps) { <Alert variant="destructive">
<AlertTitle>거부됨</AlertTitle>
<AlertDescription>
- {formatDate(pqSubmission.rejectedAt, "kr")}에 거부되었습니다.
+ {pqSubmission.rejectedAt ? formatDate(pqSubmission.rejectedAt) : "N/A"}에 거부되었습니다.
{pqSubmission.rejectReason && (
<div className="mt-2">
<strong>사유:</strong> {pqSubmission.rejectReason}
@@ -213,10 +235,20 @@ function getStatusLabel(status: string): string { return "진행 중";
case "SUBMITTED":
return "제출됨";
+ case "SAFETY_APPROVED":
+ return "안전 승인됨";
+ case "SAFETY_REJECTED":
+ return "안전 거절됨";
case "APPROVED":
return "승인됨";
case "REJECTED":
return "거부됨";
+ case "QM_REVIEWING":
+ return "QM 검토중";
+ case "QM_APPROVED":
+ return "QM 승인됨";
+ case "QM_REJECTED":
+ return "QM 거절됨";
default:
return status;
}
@@ -231,10 +263,20 @@ function getStatusVariant(status: string): "default" | "outline" | "secondary" | return "secondary";
case "SUBMITTED":
return "default";
+ case "SAFETY_APPROVED":
+ return "secondary";
+ case "SAFETY_REJECTED":
+ return "destructive";
case "APPROVED":
return "success";
case "REJECTED":
return "destructive";
+ case "QM_REVIEWING":
+ return "secondary";
+ case "QM_APPROVED":
+ return "default";
+ case "QM_REJECTED":
+ return "destructive";
default:
return "outline";
}
diff --git a/components/pq-input/pq-review-wrapper.tsx b/components/pq-input/pq-review-wrapper.tsx index efb078e0..ac9629cb 100644 --- a/components/pq-input/pq-review-wrapper.tsx +++ b/components/pq-input/pq-review-wrapper.tsx @@ -23,7 +23,7 @@ import { import { useToast } from "@/hooks/use-toast" import { CheckCircle, AlertCircle, Paperclip, Square, Download } from "lucide-react" import { PQGroupData } from "@/lib/pq/service" -import { approvePQAction, rejectPQAction, updateSHICommentAction, approveQMReviewAction, rejectQMReviewAction, requestPqSupplementAction } from "@/lib/pq/service" +import { approvePQAction, rejectPQAction, updateSHICommentAction, approveQMReviewAction, rejectQMReviewAction, requestPqSupplementAction, approveSafetyPQAction, rejectSafetyPQAction } from "@/lib/pq/service" import { FileList, FileListHeader, FileListInfo, FileListItem, FileListName, FileListDescription, FileListAction, FileListIcon } from "@/components/ui/file-list" // import * as ExcelJS from 'exceljs'; // import { saveAs } from "file-saver"; @@ -62,15 +62,20 @@ export function PQReviewWrapper({ }: PQReviewWrapperProps) { const router = useRouter() const { toast } = useToast() + const [isSafetyApproving, setIsSafetyApproving] = React.useState(false) + const [isSafetyRejecting, setIsSafetyRejecting] = React.useState(false) const [isApproving, setIsApproving] = React.useState(false) const [isRejecting, setIsRejecting] = React.useState(false) const [isQMApproving, setIsQMApproving] = React.useState(false) const [isQMRejecting, setIsQMRejecting] = React.useState(false) + const [showSafetyApproveDialog, setShowSafetyApproveDialog] = React.useState(false) + const [showSafetyRejectDialog, setShowSafetyRejectDialog] = React.useState(false) const [showApproveDialog, setShowApproveDialog] = React.useState(false) const [showRejectDialog, setShowRejectDialog] = React.useState(false) const [showQMApproveDialog, setShowQMApproveDialog] = React.useState(false) const [showQMRejectDialog, setShowQMRejectDialog] = React.useState(false) const [showSupplementDialog, setShowSupplementDialog] = React.useState(false) + const [safetyRejectReason, setSafetyRejectReason] = React.useState("") const [rejectReason, setRejectReason] = React.useState("") const [qmRejectReason, setQmRejectReason] = React.useState("") const [supplementComment, setSupplementComment] = React.useState("") @@ -140,6 +145,87 @@ export function PQReviewWrapper({ setShiComments(initialComments) }, [pqData]) + // 안전 PQ 승인 처리 + const handleSafetyApprove = async () => { + try { + setIsSafetyApproving(true) + const result = await approveSafetyPQAction({ + pqSubmissionId: pqSubmission.id, + vendorId: vendorId, + }) + + if (result.ok) { + toast({ + title: "안전 PQ 승인 완료", + description: "안전 검토가 승인되었습니다.", + }) + router.refresh() + } else { + toast({ + title: "안전 승인 실패", + description: result.error || "안전 PQ 승인 중 오류가 발생했습니다.", + variant: "destructive", + }) + } + } catch (error) { + console.error("안전 PQ 승인 오류:", error) + toast({ + title: "안전 승인 실패", + description: "안전 PQ 승인 중 오류가 발생했습니다.", + variant: "destructive", + }) + } finally { + setIsSafetyApproving(false) + setShowSafetyApproveDialog(false) + } + } + + // 안전 PQ 거절 처리 + const handleSafetyReject = async () => { + if (!safetyRejectReason.trim()) { + toast({ + title: "거절 사유 필요", + description: "안전 거절 사유를 입력해주세요.", + variant: "destructive", + }) + return + } + + try { + setIsSafetyRejecting(true) + const result = await rejectSafetyPQAction({ + pqSubmissionId: pqSubmission.id, + vendorId: vendorId, + rejectReason: safetyRejectReason, + }) + + if (result.ok) { + toast({ + title: "안전 PQ 거절 완료", + description: "안전 검토에서 거절되었습니다.", + }) + router.refresh() + } else { + toast({ + title: "안전 거절 실패", + description: result.error || "안전 PQ 거절 중 오류가 발생했습니다.", + variant: "destructive", + }) + } + } catch (error) { + console.error("안전 PQ 거절 오류:", error) + toast({ + title: "안전 거절 실패", + description: "안전 PQ 거절 중 오류가 발생했습니다.", + variant: "destructive", + }) + } finally { + setSafetyRejectReason("") + setIsSafetyRejecting(false) + setShowSafetyRejectDialog(false) + } + } + // PQ 승인 처리 const handleApprove = async () => { try { @@ -327,141 +413,6 @@ export function PQReviewWrapper({ } } - // // Excel export 처리 - // const handleExportToExcel = async () => { - // try { - // setIsExporting(true) - - // // 워크북 생성 - // const workbook = new ExcelJS.Workbook() - // workbook.creator = 'PQ Management System' - // workbook.created = new Date() - - // // 메인 시트 생성 - // const worksheet = workbook.addWorksheet("PQ 항목") - - // // 헤더 정의 - // const headers = [ - // "그룹명", - // "코드", - // "체크포인트", - // "설명", - // "입력형식", - // "필수여부", - // "벤더답변", - // "SHI 코멘트", - // "벤더 답변", - // ] - - // // 헤더 추가 - // worksheet.addRow(headers) - - // // 헤더 스타일 적용 - // const headerRow = worksheet.getRow(1) - // headerRow.font = { bold: true } - // headerRow.fill = { - // type: 'pattern', - // pattern: 'solid', - // fgColor: { argb: 'FFE0E0E0' } - // } - // headerRow.alignment = { vertical: 'middle', horizontal: 'center' } - - // // 컬럼 너비 설정 - // worksheet.columns = [ - // { header: "그룹명", key: "groupName", width: 15 }, - // { header: "코드", key: "code", width: 12 }, - // { header: "체크포인트", key: "checkPoint", width: 30 }, - // { header: "설명", key: "description", width: 40 }, - // { header: "입력형식", key: "inputFormat", width: 12 }, - - // { header: "벤더답변", key: "answer", width: 30 }, - // { header: "SHI 코멘트", key: "shiComment", width: 30 }, - // { header: "벤더 답변", key: "vendorReply", width: 30 }, - // ] - - // // 데이터 추가 - // pqData.forEach(group => { - // group.items.forEach(item => { - // const rowData = [ - // group.groupName, - // item.code, - // item.checkPoint, - // item.description || "", - // item.inputFormat || "", - - // item.answer || "", - // item.shiComment || "", - // item.vendorReply || "", - // ] - // worksheet.addRow(rowData) - // }) - // }) - - // // 전체 셀에 테두리 추가 - // worksheet.eachRow((row, rowNumber) => { - // row.eachCell((cell) => { - // cell.border = { - // top: { style: 'thin' }, - // left: { style: 'thin' }, - // bottom: { style: 'thin' }, - // right: { style: 'thin' } - // } - // // 긴 텍스트는 자동 줄바꿈 - // cell.alignment = { - // vertical: 'top', - // horizontal: 'left', - // wrapText: true - // } - // }) - // }) - - // // 정보 시트 생성 - // const infoSheet = workbook.addWorksheet("정보") - // infoSheet.addRow(["벤더명", pqSubmission.vendorName]) - // if (pqSubmission.projectName) { - // infoSheet.addRow(["프로젝트명", pqSubmission.projectName]) - // } - // infoSheet.addRow(["생성일", new Date().toLocaleDateString('ko-KR')]) - // infoSheet.addRow(["총 항목 수", pqData.reduce((total, group) => total + group.items.length, 0)]) - - // // 정보 시트 스타일링 - // infoSheet.columns = [ - // { header: "항목", key: "item", width: 20 }, - // { header: "값", key: "value", width: 40 } - // ] - - // const infoHeaderRow = infoSheet.getRow(1) - // infoHeaderRow.font = { bold: true } - // infoHeaderRow.fill = { - // type: 'pattern', - // pattern: 'solid', - // fgColor: { argb: 'FFE6F3FF' } - // } - - // // 파일명 생성 - // const defaultFilename = pqSubmission.projectName - // ? `${pqSubmission.vendorName}_${pqSubmission.projectName}_PQ_${new Date().toISOString().slice(0, 10)}` - // : `${pqSubmission.vendorName}_PQ_${new Date().toISOString().slice(0, 10)}` - // const finalFilename = defaultFilename - - // // 파일 다운로드 - // const buffer = await workbook.xlsx.writeBuffer() - // const blob = new Blob([buffer], { - // type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - // }) - // saveAs(blob, `${finalFilename}.xlsx`) - // } catch (error) { - // console.error("Excel export 오류:", error) - // toast({ - // title: "내보내기 실패", - // description: "Excel 내보내기 중 오류가 발생했습니다.", - // variant: "destructive" - // }) - // } finally { - // setIsExporting(false) - // } - // } - // PQ 거부 처리 const handleReject = async () => { if (!rejectReason.trim()) { @@ -839,15 +790,42 @@ export function PQReviewWrapper({ {/* 검토 버튼 - 상태에 따라 다른 버튼 표시 */} <div className="fixed bottom-4 right-4 bg-background p-4 rounded-lg shadow-md border"> <div className="flex gap-2"> - {/* SUBMITTED 상태: 구매 담당자 승인/거절 */} + {/* SUBMITTED 상태: 안전 담당자 승인/거절 */} {pqSubmission.status === "SUBMITTED" && ( <> <Button variant="outline" + onClick={() => setShowSafetyRejectDialog(true)} + disabled={isSafetyRejecting} + > + {isSafetyRejecting ? "안전 거절 중..." : "안전 거절"} + </Button> + <Button + variant="secondary" + onClick={() => setShowSupplementDialog(true)} + disabled={isSendingSupplement} + > + 보완요청 + </Button> + <Button + variant="default" + onClick={() => setShowSafetyApproveDialog(true)} + disabled={isSafetyApproving} + > + {isSafetyApproving ? "안전 승인 중..." : "안전 승인"} + </Button> + </> + )} + + {/* SAFETY_APPROVED 상태: 구매 담당자 승인/거절 */} + {pqSubmission.status === "SAFETY_APPROVED" && ( + <> + <Button + variant="outline" onClick={() => setShowRejectDialog(true)} disabled={isRejecting} > - {isRejecting ? "거부 중..." : "거부"} + {isRejecting ? "거부 중..." : "구매 거부"} </Button> <Button variant="secondary" @@ -916,10 +894,82 @@ export function PQReviewWrapper({ <span>거절됨</span> </div> )} + + {/* SAFETY_REJECTED 상태: 안전 거절 표시 */} + {pqSubmission.status === "SAFETY_REJECTED" && ( + <div className="flex items-center gap-2 text-red-600"> + <AlertCircle className="h-4 w-4" /> + <span>안전 거절됨</span> + </div> + )} </div> </div> + {/* 안전 승인 확인 다이얼로그 */} + <Dialog open={showSafetyApproveDialog} onOpenChange={setShowSafetyApproveDialog}> + <DialogContent> + <DialogHeader> + <DialogTitle>안전 PQ 승인 확인</DialogTitle> + <DialogDescription> + {pqSubmission.vendorName || "알 수 없는 업체"}의 { + pqSubmission.type === "GENERAL" ? "일반" : + pqSubmission.type === "PROJECT" ? "프로젝트" : + pqSubmission.type === "NON_INSPECTION" ? "미실사" : "일반" + } PQ를 안전 검토에서 승인하시겠습니까? + {pqSubmission.projectId && ( + <span> 프로젝트: {pqSubmission.projectName}</span> + )} + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button variant="outline" onClick={() => setShowSafetyApproveDialog(false)}> + 취소 + </Button> + <Button onClick={handleSafetyApprove} disabled={isSafetyApproving}> + {isSafetyApproving ? "안전 승인 중..." : "안전 승인"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* 안전 거부 확인 다이얼로그 */} + <Dialog open={showSafetyRejectDialog} onOpenChange={setShowSafetyRejectDialog}> + <DialogContent> + <DialogHeader> + <DialogTitle>안전 PQ 거부</DialogTitle> + <DialogDescription> + {pqSubmission.vendorName || "알 수 없는 업체"}의 { + pqSubmission.type === "GENERAL" ? "일반" : + pqSubmission.type === "PROJECT" ? "프로젝트" : + pqSubmission.type === "NON_INSPECTION" ? "미실사" : "일반" + } PQ를 안전 검토에서 거부하는 이유를 입력해주세요. + {pqSubmission.projectId && ( + <span> 프로젝트: {pqSubmission.projectName}</span> + )} + </DialogDescription> + </DialogHeader> + <Textarea + value={safetyRejectReason} + onChange={(e) => setSafetyRejectReason(e.target.value)} + placeholder="안전 거부 사유를 입력하세요" + className="min-h-24" + /> + <DialogFooter> + <Button variant="outline" onClick={() => setShowSafetyRejectDialog(false)}> + 취소 + </Button> + <Button + variant="destructive" + onClick={handleSafetyReject} + disabled={isSafetyRejecting || !safetyRejectReason.trim()} + > + {isSafetyRejecting ? "안전 거절 중..." : "안전 거절"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + {/* 승인 확인 다이얼로그 */} <Dialog open={showApproveDialog} onOpenChange={setShowApproveDialog}> <DialogContent> diff --git a/config/vendorColumnsConfig.ts b/config/vendorColumnsConfig.ts index dbf25c2c..911554b0 100644 --- a/config/vendorColumnsConfig.ts +++ b/config/vendorColumnsConfig.ts @@ -103,6 +103,15 @@ export const vendorColumnsConfig: VendorColumnConfig[] = [ }, { + id: "safetyQualificationPassed", + label: "안전적격성 평가", + excelHeader: "안전적격성 평가", + type: "string", + width: 120, + minWidth: 100, + }, + + { id: "primaryMaterial1", label: "업체대표품목1", excelHeader: "업체대표품목1", diff --git a/db/schema/pq.ts b/db/schema/pq.ts index e273f656..33f095c4 100644 --- a/db/schema/pq.ts +++ b/db/schema/pq.ts @@ -149,10 +149,12 @@ export const vendorPQSubmissions = pgTable("vendor_pq_submissions", { status: varchar("status", { length: 20, enum: [ - "REQUESTED", // PQ 요청됨 - "SUBMITTED", // PQ 제출됨 - "APPROVED", // PQ 승인됨 - "REJECTED", // PQ 거절됨 + "REQUESTED", // PQ 요청됨 + "SUBMITTED", // PQ 제출됨 + "SAFETY_APPROVED", // 안전 승인됨 + "SAFETY_REJECTED", // 안전 거절됨 + "APPROVED", // PQ 승인됨 + "REJECTED", // PQ 거절됨 "QM_REVIEWING", // QM 검토중 "QM_APPROVED", // QM 승인됨 "QM_REJECTED" // QM 거절됨 diff --git a/db/schema/vendors.ts b/db/schema/vendors.ts index c194cf61..79ac994b 100644 --- a/db/schema/vendors.ts +++ b/db/schema/vendors.ts @@ -64,6 +64,9 @@ export const vendors = pgTable("vendors", { creditRating: varchar("credit_rating", { length: 50 }), cashFlowRating: varchar("cash_flow_rating", { length: 50 }), + // 안전적격성 평가 통과 여부 (null: 해당없음, true: 승인, false: 거절) + safetyQualificationPassed: boolean("safety_qualification_passed"), + businessSize: varchar("business_size", { length: 255 }), // 기업규모 // 성조회 가입여부: 공제회 가입여부이며, 구매에서 직접 입력하겠다는 값임. 'E'=해당없음, 'Y'=가입, 'N'=미가입, null='-' @@ -540,6 +543,9 @@ export const vendorsWithTypesAndMaterialsView = pgView("vendors_with_types_and_m END `.as("vendor_category"), + // 안전적격성 평가 통과 여부 + safetyQualificationPassed: sql<boolean | null>`${vendors.safetyQualificationPassed}`.as("safety_qualification_passed"), + // 업체대표품목 1, 2, 3 - isConfirmed = true를 우선적으로, 그 다음 false 순으로 정렬 primaryMaterial1: sql<string>` (SELECT CASE diff --git a/lib/mail/templates/safety-pq-approved.hbs b/lib/mail/templates/safety-pq-approved.hbs new file mode 100644 index 00000000..227d4963 --- /dev/null +++ b/lib/mail/templates/safety-pq-approved.hbs @@ -0,0 +1,15 @@ +{{!-- 안전적격성 평가 승인 안내 메일 --}} +<div style="font-family: Arial, sans-serif; font-size: 14px; color: #333;"> + <p>안녕하세요,</p> + <p>안전적격성 평가가 <strong>{{statusText}}</strong>되었습니다.</p> + <ul> + <li>PQ 번호: {{pqNumber}}</li> + <li>업체명: {{vendorName}}</li> + </ul> + <p>자세한 내용은 eVCP 포털에서 확인해주세요.</p> + <p> + <a href="https://{{portalUrl}}" target="_blank" style="color:#0ea5e9;">eVCP 바로가기</a> + </p> + <p>감사합니다.</p> +</div> + diff --git a/lib/mail/templates/safety-pq-rejected.hbs b/lib/mail/templates/safety-pq-rejected.hbs new file mode 100644 index 00000000..d1499c95 --- /dev/null +++ b/lib/mail/templates/safety-pq-rejected.hbs @@ -0,0 +1,18 @@ +{{!-- 안전적격성 평가 거절 안내 메일 --}} +<div style="font-family: Arial, sans-serif; font-size: 14px; color: #333;"> + <p>안녕하세요,</p> + <p>안전적격성 평가가 <strong>{{statusText}}</strong>되었습니다.</p> + <ul> + <li>PQ 번호: {{pqNumber}}</li> + <li>업체명: {{vendorName}}</li> + </ul> + {{#if rejectReason}} + <p>거절 사유: {{rejectReason}}</p> + {{/if}} + <p>자세한 내용은 eVCP 포털에서 확인해주세요.</p> + <p> + <a href="https://{{portalUrl}}" target="_blank" style="color:#ef4444;">eVCP 바로가기</a> + </p> + <p>감사합니다.</p> +</div> + 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 a35884fc..dc2afa66 100644 --- a/lib/pq/pq-review-table-new/vendors-table-columns.tsx +++ b/lib/pq/pq-review-table-new/vendors-table-columns.tsx @@ -295,6 +295,10 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ExtendedC return { status: "PQ_IN_PROGRESS", label: "PQ 진행 중", variant: "secondary" as const };
case "SUBMITTED":
return { status: "PQ_SUBMITTED", label: "PQ 제출됨", variant: "default" as const };
+ case "SAFETY_APPROVED":
+ return { status: "PQ_SAFETY_APPROVED", label: "안전 승인됨", variant: "secondary" as const };
+ case "SAFETY_REJECTED":
+ return { status: "PQ_SAFETY_REJECTED", label: "안전 거절됨", variant: "destructive" as const };
case "APPROVED":
return { status: "PQ_APPROVED", label: "PQ 승인됨", variant: "success" as const };
case "REJECTED":
@@ -554,25 +558,56 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ExtendedC <DataTableColumnHeaderSimple column={column} title="실사품목" />
),
cell: ({ row }) => {
- const pqItems = row.original.pqItems;
+ const pqItems = row.original.pqItems
if (!pqItems) {
- return <span className="text-muted-foreground">-</span>;
+ return <span className="text-muted-foreground">-</span>
}
- // JSON 파싱하여 첫 번째 아이템 표시
- const items = typeof pqItems === 'string' ? JSON.parse(pqItems) : pqItems;
- if (Array.isArray(items) && items.length > 0) {
- const firstItem = items[0];
- return (
- <div className="flex items-center gap-2">
- <span className="text-sm">{firstItem.itemCode} - {firstItem.itemName}</span>
- {items.length > 1 && (
- <span className="text-xs text-muted-foreground">외 {items.length - 1}건</span>
- )}
- </div>
- );
+ // 문자열이면 JSON 파싱을 시도하고, 실패 시 원문 그대로 표시
+ const parsed =
+ typeof pqItems === "string"
+ ? (() => {
+ try {
+ return JSON.parse(pqItems)
+ } catch {
+ return pqItems
+ }
+ })()
+ : pqItems
+
+ if (Array.isArray(parsed)) {
+ if (parsed.length === 0) {
+ return <span className="text-muted-foreground">-</span>
}
+
+ return (
+ <div className="flex flex-wrap gap-1">
+ {parsed.map((item, idx) => {
+ if (!item || typeof item !== "object") return null
+ const itemObj = item as Record<string, any>
+ const displayName =
+ itemObj.materialGroupDescription || itemObj.itemName || ""
+ const displayCode =
+ itemObj.materialGroupCode || itemObj.itemCode || ""
+
+ if (!displayName && !displayCode) return null
+
+ return (
+ <Badge key={`${row.original.id}-pqitem-${idx}`} variant="outline">
+ {displayName || displayCode || "품목"}
+ {displayCode && displayName !== displayCode
+ ? ` (${displayCode})`
+ : ""}
+ </Badge>
+ )
+ })}
+ </div>
+ )
+ }
+
+ // 배열이 아닌 경우 문자열 그대로 표시
+ return <span className="text-sm">{String(parsed) || "-"}</span>
},
meta: {
excelHeader: "실사품목",
diff --git a/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx b/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx index a9d37a4b..b7e54f3d 100644 --- a/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx +++ b/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx @@ -572,6 +572,11 @@ const handleOpenRequestDialog = async () => { row.original.type === "NON_INSPECTION" ) + // 승인되지 않은 PQ가 포함되었는지 확인 + const hasNonApprovedStatus = selectedRows.some(row => + row.original.status !== "APPROVED" + ) + // 실사 방법 라벨 변환 함수 const getInvestigationMethodLabel = (method: string): string => { switch (method) { @@ -676,9 +681,20 @@ const handleOpenRequestDialog = async () => { variant="outline" size="sm" onClick={handleOpenRequestDialog} // 여기를 수정: 새로운 핸들러 함수 사용 - disabled={isLoading || selectedRows.length === 0 || hasNonInspectionPQ} + disabled={ + isLoading || + selectedRows.length === 0 || + hasNonInspectionPQ || + hasNonApprovedStatus + } className="gap-2" - title={hasNonInspectionPQ ? "미실사 PQ는 실사 의뢰할 수 없습니다." : undefined} + title={ + hasNonInspectionPQ + ? "미실사 PQ는 실사 의뢰할 수 없습니다." + : hasNonApprovedStatus + ? "승인된 PQ만 실사 의뢰할 수 있습니다." + : undefined + } > <ClipboardCheck className="size-4" aria-hidden="true" /> <span className="hidden sm:inline">실사 의뢰</span> diff --git a/lib/pq/service.ts b/lib/pq/service.ts index bd83a33c..15e71c4d 100644 --- a/lib/pq/service.ts +++ b/lib/pq/service.ts @@ -551,7 +551,7 @@ export async function submitPQAction({ } // 제출 가능한 상태 확인 - const allowedStatuses = ["REQUESTED", "IN_PROGRESS", "SUBMITTED", "REJECTED"]; + const allowedStatuses = ["REQUESTED", "IN_PROGRESS", "SUBMITTED", "REJECTED", "SAFETY_REJECTED"]; if (existingSubmission) { if (!allowedStatuses.includes(existingSubmission.status)) { @@ -2277,6 +2277,289 @@ export async function updateSHICommentAction({ } } +// 안전 PQ 승인 액션 (HSE 단계) +export async function approveSafetyPQAction({ + pqSubmissionId, + vendorId, +}: { + pqSubmissionId: number; + vendorId: number; +}) { + unstable_noStore(); + + try { + const currentDate = new Date(); + + const pqSubmission = await db + .select({ + id: vendorPQSubmissions.id, + vendorId: vendorPQSubmissions.vendorId, + projectId: vendorPQSubmissions.projectId, + status: vendorPQSubmissions.status, + type: vendorPQSubmissions.type, + pqNumber: vendorPQSubmissions.pqNumber, + requesterId: vendorPQSubmissions.requesterId, + }) + .from(vendorPQSubmissions) + .where( + and( + eq(vendorPQSubmissions.id, pqSubmissionId), + eq(vendorPQSubmissions.vendorId, vendorId) + ) + ) + .then(rows => rows[0]); + + if (!pqSubmission) { + return { ok: false, error: "PQ submission not found" }; + } + + if (pqSubmission.status !== "SUBMITTED") { + return { + ok: false, + error: `Cannot approve safety PQ in current status: ${pqSubmission.status}` + }; + } + + await db + .update(vendorPQSubmissions) + .set({ + status: "SAFETY_APPROVED", + updatedAt: currentDate, + }) + .where(eq(vendorPQSubmissions.id, pqSubmissionId)); + + // 벤더 안전적격성 통과 표시 (해당없음 → 승인) + await db + .update(vendors) + .set({ + safetyQualificationPassed: true, + updatedAt: currentDate, + }) + .where(eq(vendors.id, vendorId)); + + // 메일 발송: PQ 제출자 + 벤더 + const headersList = await headers(); + const host = headersList.get("host") || "localhost:3000"; + const portalUrl = `${host}/partners/pq_new`; + + // 벤더 정보 조회 + const vendorInfo = await db + .select({ + vendorName: vendors.vendorName, + vendorEmail: vendors.email, + }) + .from(vendors) + .where(eq(vendors.id, vendorId)) + .then(rows => rows[0]); + + // PQ 제출자 이메일 조회 + let requesterEmail: string | null = null; + if (pqSubmission.requesterId) { + const requester = await db + .select({ email: users.email }) + .from(users) + .where(eq(users.id, pqSubmission.requesterId)) + .then(rows => rows[0]); + requesterEmail = requester?.email || null; + } + + const emailContext = { + pqNumber: pqSubmission.pqNumber || `PQ-${pqSubmission.id}`, + vendorName: vendorInfo?.vendorName || "", + statusText: "승인", + portalUrl, + }; + + // 벤더 메일 + if (vendorInfo?.vendorEmail) { + try { + await sendEmail({ + to: vendorInfo.vendorEmail, + subject: `[eVCP] 안전적격성 평가 승인 안내 (${emailContext.pqNumber})`, + template: "safety-pq-approved", + context: emailContext, + }); + } catch (emailError) { + console.error("Failed to send safety approve email to vendor:", emailError); + } + } + + // PQ 제출자 메일 + if (requesterEmail) { + try { + await sendEmail({ + to: requesterEmail, + subject: `[eVCP] 안전적격성 평가 승인 안내 (${emailContext.pqNumber})`, + template: "safety-pq-approved", + context: emailContext, + }); + } catch (emailError) { + console.error("Failed to send safety approve email to requester:", emailError); + } + } + + revalidateTag("pq-submissions"); + revalidateTag(`vendor-pq-submissions-${vendorId}`); + revalidateTag("vendors"); + if (pqSubmission.projectId) { + revalidateTag(`project-pq-submissions-${pqSubmission.projectId}`); + } + revalidatePath("/evcp/pq_new"); + + return { ok: true }; + } catch (error) { + console.error("Safety PQ approve error:", error); + return { ok: false, error: getErrorMessage(error) }; + } +} + +// 안전 PQ 거절 액션 (HSE 단계) +export async function rejectSafetyPQAction({ + pqSubmissionId, + vendorId, + rejectReason, +}: { + pqSubmissionId: number; + vendorId: number; + rejectReason: string; +}) { + unstable_noStore(); + + try { + const currentDate = new Date(); + + const pqSubmission = await db + .select({ + id: vendorPQSubmissions.id, + vendorId: vendorPQSubmissions.vendorId, + projectId: vendorPQSubmissions.projectId, + status: vendorPQSubmissions.status, + type: vendorPQSubmissions.type, + pqNumber: vendorPQSubmissions.pqNumber, + requesterId: vendorPQSubmissions.requesterId, + }) + .from(vendorPQSubmissions) + .where( + and( + eq(vendorPQSubmissions.id, pqSubmissionId), + eq(vendorPQSubmissions.vendorId, vendorId) + ) + ) + .then(rows => rows[0]); + + if (!pqSubmission) { + return { ok: false, error: "PQ submission not found" }; + } + + if (pqSubmission.status !== "SUBMITTED") { + return { + ok: false, + error: `Cannot reject safety PQ in current status: ${pqSubmission.status}` + }; + } + + await db + .update(vendorPQSubmissions) + .set({ + status: "SAFETY_REJECTED", + rejectedAt: currentDate, + rejectReason, + updatedAt: currentDate, + }) + .where(eq(vendorPQSubmissions.id, pqSubmissionId)); + + // 벤더 안전적격성 통과 여부를 거절로 기록 + await db + .update(vendors) + .set({ + safetyQualificationPassed: false, + updatedAt: currentDate, + }) + .where(eq(vendors.id, vendorId)); + + // 메일 발송: PQ 제출자 + 벤더 + const headersList = await headers(); + const host = headersList.get("host") || "localhost:3000"; + const portalUrl = `${host}/partners/pq_new`; + + const vendorInfo = await db + .select({ + vendorName: vendors.vendorName, + vendorEmail: vendors.email, + }) + .from(vendors) + .where(eq(vendors.id, vendorId)) + .then(rows => rows[0]); + + let requesterEmail: string | null = null; + if (pqSubmission.requesterId) { + const requester = await db + .select({ email: users.email }) + .from(users) + .where(eq(users.id, pqSubmission.requesterId)) + .then(rows => rows[0]); + requesterEmail = requester?.email || null; + } + + const emailContext = { + pqNumber: pqSubmission.pqNumber || `PQ-${pqSubmission.id}`, + vendorName: vendorInfo?.vendorName || "", + statusText: "거절", + rejectReason: rejectReason || "", + portalUrl, + }; + + if (vendorInfo?.vendorEmail) { + try { + await sendEmail({ + to: vendorInfo.vendorEmail, + subject: `[eVCP] 안전적격성 평가 거절 안내 (${emailContext.pqNumber})`, + template: "safety-pq-rejected", + context: emailContext, + }); + } catch (emailError) { + console.error("Failed to send safety reject email to vendor:", emailError); + } + } + + if (requesterEmail) { + try { + await sendEmail({ + to: requesterEmail, + subject: `[eVCP] 안전적격성 평가 거절 안내 (${emailContext.pqNumber})`, + template: "safety-pq-rejected", + context: emailContext, + }); + } catch (emailError) { + console.error("Failed to send safety reject email to requester:", emailError); + } + } + + if (pqSubmission.type === "GENERAL") { + await db + .update(vendors) + .set({ + status: "PQ_FAILED", + updatedAt: currentDate, + }) + .where(eq(vendors.id, vendorId)); + } + + revalidateTag("pq-submissions"); + revalidateTag(`vendor-pq-submissions-${vendorId}`); + revalidateTag("vendors"); + if (pqSubmission.projectId) { + revalidateTag(`project-pq-submissions-${pqSubmission.projectId}`); + } + revalidatePath("/evcp/pq_new"); + + return { ok: true }; + } catch (error) { + console.error("Safety PQ reject error:", error); + return { ok: false, error: getErrorMessage(error) }; + } +} + // PQ 승인 액션 export async function approvePQAction({ pqSubmissionId, @@ -2314,8 +2597,9 @@ export async function approvePQAction({ return { ok: false, error: "PQ submission not found" }; } - // 2. 상태 확인 (SUBMITTED 상태만 승인 가능) - if (pqSubmission.status !== "SUBMITTED") { + // 2. 상태 확인 (안전 승인 이후 승인 가능, 기존 SUBMITTED는 호환용) + const allowedStatuses = ["SAFETY_APPROVED", "SUBMITTED"]; + if (!allowedStatuses.includes(pqSubmission.status)) { return { ok: false, error: `Cannot approve PQ in current status: ${pqSubmission.status}` @@ -2363,6 +2647,27 @@ export async function approvePQAction({ }) .where(eq(vendorPQSubmissions.id, pqSubmissionId)); + // 5-1. 미실사 PQ라면 구매자체평가 실사 레코드를 자동 생성 + if (pqSubmission.type === "NON_INSPECTION") { + const existingInvestigation = await db + .select({ id: vendorInvestigations.id }) + .from(vendorInvestigations) + .where(eq(vendorInvestigations.pqSubmissionId, pqSubmissionId)) + .limit(1) + .then(rows => rows[0]); + + if (!existingInvestigation) { + await db.insert(vendorInvestigations).values({ + vendorId, + pqSubmissionId, + investigationStatus: "IN_PROGRESS", + investigationMethod: "PURCHASE_SELF_EVAL", + requestedAt: currentDate, + updatedAt: currentDate, + }); + } + } + // 6. 일반 PQ인 경우 벤더 상태 업데이트 (선택사항) if (pqSubmission.type === "GENERAL") { await db @@ -2788,8 +3093,9 @@ export async function rejectPQAction({ return { ok: false, error: "PQ submission not found" }; } - // 2. 상태 확인 (SUBMITTED 상태만 거부 가능) - if (pqSubmission.status !== "SUBMITTED") { + // 2. 상태 확인 (안전 승인 이후 거부 가능, 기존 SUBMITTED는 호환용) + const allowedStatuses = ["SAFETY_APPROVED", "SUBMITTED"]; + if (!allowedStatuses.includes(pqSubmission.status)) { return { ok: false, error: `Cannot reject PQ in current status: ${pqSubmission.status}` @@ -4872,6 +5178,7 @@ export async function updateInvestigationDetailsAction(input: { }); revalidateTag("pq-submissions"); + revalidateTag("vendor-regular-registrations"); revalidatePath("/evcp/pq_new"); return { diff --git a/lib/vendors/table/vendors-table-columns.tsx b/lib/vendors/table/vendors-table-columns.tsx index 36809715..b147f1ef 100644 --- a/lib/vendors/table/vendors-table-columns.tsx +++ b/lib/vendors/table/vendors-table-columns.tsx @@ -353,6 +353,27 @@ export function getColumns({ setRowAction, router, userId }: GetColumnsProps): C ); } + // 안전적격성 평가 통과 여부 컬럼 처리 + if (cfg.id === "safetyQualificationPassed") { + const val = row.original.safetyQualificationPassed as boolean | null | undefined; + const getBadge = (value: boolean | null | undefined) => { + if (value === true) { + return { text: "승인", className: "bg-green-100 text-green-800 border-green-300" }; + } + if (value === false) { + return { text: "거절", className: "bg-red-100 text-red-800 border-red-300" }; + } + return { text: "해당없음", className: "bg-gray-100 text-gray-700 border-gray-300" }; + }; + + const badge = getBadge(val); + return ( + <Badge variant="outline" className={badge.className}> + {badge.text} + </Badge> + ); + } + // 업체 유형 컬럼 처리 if (cfg.id === "vendorTypeName") { const typeVal = row.original.vendorTypeName as string | null; |
