From 4dc27e9495b005b29b4d7a2ad404c8c0644769eb Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 4 Aug 2025 09:41:06 +0000 Subject: (최겸) 실사 컬럼 수정 및 업데이트 변경, 협력업체 PQ 초대 로직 변경 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table/investigation-table-columns.tsx | 12 +- .../table/investigation-table.tsx | 6 +- .../table/update-investigation-sheet.tsx | 10 +- lib/vendor-investigation/validations.ts | 12 +- lib/vendors/service.ts | 173 +++++++------ lib/vendors/table/request-pq-dialog.tsx | 286 +++++++++++++++++++++ .../table/vendors-table-toolbar-actions.tsx | 17 +- 7 files changed, 405 insertions(+), 111 deletions(-) create mode 100644 lib/vendors/table/request-pq-dialog.tsx (limited to 'lib') diff --git a/lib/vendor-investigation/table/investigation-table-columns.tsx b/lib/vendor-investigation/table/investigation-table-columns.tsx index 6146d940..88b6644f 100644 --- a/lib/vendor-investigation/table/investigation-table-columns.tsx +++ b/lib/vendor-investigation/table/investigation-table-columns.tsx @@ -295,10 +295,14 @@ function formatStatus(status: string): string { function formatEnumValue(value: string): string { switch (value) { // Evaluation types - case "SITE_AUDIT": - return "실사의뢰평가" - case "QM_SELF_AUDIT": - return "QM자체평가" + case "PURCHASE_SELF_EVAL": + return "구매자체평가" + case "DOCUMENT_EVAL": + return "서류평가" + case "PRODUCT_INSPECTION": + return "제품검사평가" + case "SITE_VISIT_EVAL": + return "방문실사평가" // Evaluation results case "APPROVED": diff --git a/lib/vendor-investigation/table/investigation-table.tsx b/lib/vendor-investigation/table/investigation-table.tsx index d5dc05ac..660a8507 100644 --- a/lib/vendor-investigation/table/investigation-table.tsx +++ b/lib/vendor-investigation/table/investigation-table.tsx @@ -102,8 +102,10 @@ export function VendorsInvestigationTable({ promises }: VendorsTableProps) { label: "평가 유형", type: "select", options: [ - { label: "실사의뢰평가", value: "SITE_AUDIT" }, - { label: "QM자체평가", value: "QM_SELF_AUDIT" }, + { label: "구매자체평가", value: "PURCHASE_SELF_EVAL" }, + { label: "서류평가", value: "DOCUMENT_EVAL" }, + { label: "제품검사평가", value: "PRODUCT_INSPECTION" }, + { label: "방문실사평가", value: "SITE_VISIT_EVAL" }, ] }, { diff --git a/lib/vendor-investigation/table/update-investigation-sheet.tsx b/lib/vendor-investigation/table/update-investigation-sheet.tsx index 29f0fa92..fbaf000e 100644 --- a/lib/vendor-investigation/table/update-investigation-sheet.tsx +++ b/lib/vendor-investigation/table/update-investigation-sheet.tsx @@ -125,7 +125,7 @@ export function UpdateVendorInvestigationSheet({ investigationStatus: investigation?.investigationStatus ?? "PLANNED", evaluationType: investigation?.evaluationType ?? undefined, investigationAddress: investigation?.investigationAddress ?? "", - investigationMethod: investigation?.investigationMethod ?? "", + investigationMethod: investigation?.investigationMethod ?? undefined, forecastedAt: investigation?.forecastedAt ?? undefined, requestedAt: investigation?.requestedAt ?? undefined, confirmedAt: investigation?.confirmedAt ?? undefined, @@ -145,7 +145,7 @@ export function UpdateVendorInvestigationSheet({ investigationStatus: investigation.investigationStatus || "PLANNED", evaluationType: investigation.evaluationType ?? undefined, investigationAddress: investigation.investigationAddress ?? "", - investigationMethod: investigation.investigationMethod ?? "", + investigationMethod: investigation.investigationMethod ?? undefined, forecastedAt: investigation.forecastedAt ?? undefined, requestedAt: investigation.requestedAt ?? undefined, confirmedAt: investigation.confirmedAt ?? undefined, @@ -573,8 +573,10 @@ export function UpdateVendorInvestigationSheet({ - 실사의뢰평가 - QM자체평가 + 구매자체평가 + 서류평가 + 제품검사평가 + 방문실사평가 diff --git a/lib/vendor-investigation/validations.ts b/lib/vendor-investigation/validations.ts index d04f100f..e4ec2b52 100644 --- a/lib/vendor-investigation/validations.ts +++ b/lib/vendor-investigation/validations.ts @@ -33,8 +33,8 @@ export const searchParamsInvestigationCache = createSearchParamsCache({ // Fields specific to vendor investigations // ----------------------------------------------------------------- - // investigationStatus: PLANNED, IN_PROGRESS, COMPLETED, CANCELED - investigationStatus: parseAsStringEnum(["PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELED"]), + // investigationStatus: PLANNED, IN_PROGRESS, COMPLETED, CANCELED, RESULT_SENT + investigationStatus: parseAsStringEnum(["PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELED", "RESULT_SENT"]), // In case you also want to filter by vendorName, vendorCode, etc. vendorName: parseAsString.withDefault(""), @@ -64,12 +64,12 @@ export const updateVendorInvestigationSchema = z.object({ investigationId: z.number({ required_error: "Investigation ID is required", }), - investigationStatus: z.enum(["PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELED"], { + investigationStatus: z.enum(["PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELED", "RESULT_SENT"], { required_error: "실사 상태를 선택해주세요.", }), - evaluationType: z.enum(["SITE_AUDIT", "QM_SELF_AUDIT"]).optional(), + evaluationType: z.enum(["PURCHASE_SELF_EVAL", "DOCUMENT_EVAL", "PRODUCT_INSPECTION", "SITE_VISIT_EVAL"]).optional(), investigationAddress: z.string().optional(), - investigationMethod: z.string().max(100, "실사 방법은 100자 이내로 입력해주세요.").optional(), + investigationMethod: z.enum(["PURCHASE_SELF_EVAL", "DOCUMENT_EVAL", "PRODUCT_INSPECTION", "SITE_VISIT_EVAL"]).optional(), // 날짜 필드들을 string에서 Date로 변환하도록 수정 forecastedAt: z.union([ @@ -97,7 +97,7 @@ export const updateVendorInvestigationSchema = z.object({ .min(0, "평가 점수는 0점 이상이어야 합니다.") .max(100, "평가 점수는 100점 이하여야 합니다.") .optional(), - evaluationResult: z.enum(["APPROVED", "SUPPLEMENT", "REJECTED"]).optional(), + evaluationResult: z.enum(["APPROVED", "SUPPLEMENT", "REJECTED", "RESULT_SENT"]).optional(), investigationNotes: z.string().max(1000, "QM 의견은 1000자 이내로 입력해주세요.").optional(), attachments: z.any().optional(), // File 업로드를 위한 필드 }) diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts index 853b3701..e3309786 100644 --- a/lib/vendors/service.ts +++ b/lib/vendors/service.ts @@ -1654,14 +1654,20 @@ export async function generatePQNumber(isProject: boolean = false) { } } -export async function requestPQVendors(input: ApproveVendorsInput & { userId: number }) { +export async function requestPQVendors(input: ApproveVendorsInput & { + userId: number, + agreements?: Record, + dueDate?: string | null, + type?: "GENERAL" | "PROJECT" | "NON_INSPECTION", + extraNote?: string, + pqItems?: string +}) { unstable_noStore(); - + const session = await getServerSession(authOptions); const requesterId = session?.user?.id ? Number(session.user.id) : null; try { - // 프로젝트 정보 가져오기 (projectId가 있는 경우) let projectInfo = null; if (input.projectId) { const project = await db @@ -1673,95 +1679,72 @@ export async function requestPQVendors(input: ApproveVendorsInput & { userId: nu .from(projects) .where(eq(projects.id, input.projectId)) .limit(1); - + if (project.length > 0) { projectInfo = project[0]; } } - - // 트랜잭션 내에서 협력업체 상태 업데이트 및 이메일 발송 + const result = await db.transaction(async (tx) => { - // 0. 업데이트 전 협력업체 상태 조회 const vendorsBeforeUpdate = await tx - .select({ - id: vendors.id, - status: vendors.status, - }) + .select({ id: vendors.id, status: vendors.status }) .from(vendors) .where(inArray(vendors.id, input.ids)); - - // 1. 협력업체 상태 업데이트 + const [updated] = await tx .update(vendors) - .set({ - status: "IN_PQ", - updatedAt: new Date() - }) + .set({ status: "IN_PQ", updatedAt: new Date() }) .where(inArray(vendors.id, input.ids)) .returning(); - - // 2. 업데이트된 협력업체 정보 조회 + const updatedVendors = await tx - .select({ - id: vendors.id, - vendorName: vendors.vendorName, - email: vendors.email, - }) + .select({ id: vendors.id, vendorName: vendors.vendorName, email: vendors.email }) .from(vendors) .where(inArray(vendors.id, input.ids)); - - // 3. vendorPQSubmissions 테이블에 레코드 추가 (프로젝트 PQ와 일반 PQ 모두) - const pqType = input.projectId ? "PROJECT" : "GENERAL"; + + const pqType = input.type; const currentDate = new Date(); - - // 기존 PQ 요청이 있는지 확인 (중복 방지) + const existingSubmissions = await tx - .select({ - vendorId: vendorPQSubmissions.vendorId, - projectId: vendorPQSubmissions.projectId, - type: vendorPQSubmissions.type - }) + .select({ vendorId: vendorPQSubmissions.vendorId }) .from(vendorPQSubmissions) .where( and( inArray(vendorPQSubmissions.vendorId, input.ids), - eq(vendorPQSubmissions.type, pqType), - input.projectId + pqType ? eq(vendorPQSubmissions.type, pqType) : undefined, + input.projectId ? eq(vendorPQSubmissions.projectId, input.projectId) : isNull(vendorPQSubmissions.projectId) ) ); - - // 중복되지 않는 벤더에 대해서만 새 PQ 요청 생성 - const existingVendorIds = new Set(existingSubmissions.map(s => s.vendorId)); - const newVendorIds = input.ids.filter(id => !existingVendorIds.has(id)); - + + const existingVendorIds = new Set(existingSubmissions.map((s) => s.vendorId)); + const newVendorIds = input.ids.filter((id) => !existingVendorIds.has(id)); + if (newVendorIds.length > 0) { - // 각 벤더별로 유니크한 PQ 번호 생성 및 저장 const vendorPQDataPromises = newVendorIds.map(async (vendorId) => { - // PQ 번호 생성 (프로젝트 PQ인지 여부 전달) const pqNumber = await generatePQNumber(pqType === "PROJECT"); - - return { - vendorId, - pqNumber, // 생성된 PQ 번호 저장 - projectId: input.projectId || null, - type: pqType, - status: "REQUESTED", - requesterId: input.userId || requesterId, // 요청자 ID 저장 - 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, + }; }); - - // 모든 PQ 번호 생성 완료 대기 + const vendorPQData = await Promise.all(vendorPQDataPromises); - - // 트랜잭션 내에서 데이터 삽입 + await tx.insert(vendorPQSubmissions).values(vendorPQData); } - // 4. 로그 기록 await Promise.all( vendorsBeforeUpdate.map(async (vendorBefore) => { await tx.insert(vendorsLogs).values({ @@ -1770,25 +1753,23 @@ export async function requestPQVendors(input: ApproveVendorsInput & { userId: nu action: "status_change", oldStatus: vendorBefore.status, newStatus: "IN_PQ", - comment: input.projectId - ? `Project PQ requested (Project: ${projectInfo?.projectCode || input.projectId})` + comment: input.projectId + ? `Project PQ requested (Project: ${projectInfo?.projectCode || input.projectId})` : "General PQ requested", }); }) ); const headersList = await headers(); - const host = headersList.get('host') || 'localhost:3000'; + const host = headersList.get("host") || "localhost:3000"; - // 5. 각 벤더에게 이메일 발송 await Promise.all( updatedVendors.map(async (vendor) => { - if (!vendor.email) return; // 이메일이 없으면 스킵 - + if (!vendor.email) return; + try { - const userLang = "en"; // 기본값, 필요시 협력업체 언어 설정에서 가져오기 - - // PQ 번호 조회 (이메일에 포함하기 위해) + const userLang = "en"; + const vendorPQ = await tx .select({ pqNumber: vendorPQSubmissions.pqNumber }) .from(vendorPQSubmissions) @@ -1796,59 +1777,76 @@ export async function requestPQVendors(input: ApproveVendorsInput & { userId: nu and( eq(vendorPQSubmissions.vendorId, vendor.id), eq(vendorPQSubmissions.type, pqType), - input.projectId + input.projectId ? eq(vendorPQSubmissions.projectId, input.projectId) : isNull(vendorPQSubmissions.projectId) ) ) .limit(1) - .then(rows => rows[0]); - - // 프로젝트 PQ인지 일반 PQ인지에 따라 제목 변경 + .then((rows) => rows[0]); + const subject = input.projectId - ? `[eVCP] You are invited to submit Project PQ ${vendorPQ?.pqNumber || ''} for ${projectInfo?.projectCode || 'a project'}` - : `[eVCP] You are invited to submit PQ ${vendorPQ?.pqNumber || ''}`; - - // 로그인 URL에 프로젝트 ID 추가 (프로젝트 PQ인 경우) + ? `[eVCP] You are invited to submit Project PQ ${vendorPQ?.pqNumber || ""} for ${projectInfo?.projectCode || "a project"}` + : input.type === "NON_INSPECTION" + ? `[eVCP] You are invited to submit Non-Inspection PQ ${vendorPQ?.pqNumber || ""}` + : `[eVCP] You are invited to submit PQ ${vendorPQ?.pqNumber || ""}`; + const baseLoginUrl = `${host}/partners/pq`; const loginUrl = input.projectId ? `${baseLoginUrl}?projectId=${input.projectId}` : baseLoginUrl; - + + // 체크된 계약 항목 배열 생성 + const contracts = input.agreements + ? Object.entries(input.agreements) + .filter(([_, checked]) => checked) + .map(([name, _]) => name) + : []; + + // PQ 대상 품목 + const pqItems = input.pqItems || " - "; + await sendEmail({ to: vendor.email, subject, - template: input.projectId ? "project-pq" : "pq", // 프로젝트별 템플릿 사용 + template: input.projectId ? "project-pq" : input.type === "NON_INSPECTION" ? "non-inspection-pq" : "pq", context: { vendorName: vendor.vendorName, + vendorContact: "", // 담당자 정보가 없으므로 빈 문자열 + pqNumber: vendorPQ?.pqNumber || "", + senderName: session?.user?.name || "eVCP", + senderEmail: session?.user?.email || "noreply@evcp.com", + dueDate: input.dueDate ? new Date(input.dueDate).toLocaleDateString('ko-KR') : "", + pqItems, + contracts, + extraNote: input.extraNote || "", + currentYear: new Date().getFullYear().toString(), loginUrl, language: userLang, - projectCode: projectInfo?.projectCode || '', - projectName: projectInfo?.projectName || '', + projectCode: projectInfo?.projectCode || "", + projectName: projectInfo?.projectName || "", hasProject: !!input.projectId, - pqNumber: vendorPQ?.pqNumber || '', // PQ 번호 추가 + pqType: input.type || "GENERAL", }, }); } catch (emailError) { console.error(`Failed to send email to vendor ${vendor.id}:`, emailError); - // 이메일 전송 실패는 전체 트랜잭션을 실패시키지 않음 } }) ); - + return updated; }); - - // 캐시 무효화 + revalidateTag("vendors"); revalidateTag("vendor-status-counts"); revalidateTag("vendor-pq-submissions"); - + if (input.projectId) { revalidateTag(`project-${input.projectId}`); revalidateTag(`project-pq-submissions-${input.projectId}`); } - + return { data: result, error: null }; } catch (err) { console.error("Error requesting PQ from vendors:", err); @@ -1856,6 +1854,7 @@ export async function requestPQVendors(input: ApproveVendorsInput & { userId: nu } } + interface SendVendorsInput { ids: number[]; } diff --git a/lib/vendors/table/request-pq-dialog.tsx b/lib/vendors/table/request-pq-dialog.tsx new file mode 100644 index 00000000..6d477d9f --- /dev/null +++ b/lib/vendors/table/request-pq-dialog.tsx @@ -0,0 +1,286 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, SendHorizonal } from "lucide-react" +import { toast } from "sonner" +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Checkbox } from "@/components/ui/checkbox" +import { Label } from "@/components/ui/label" +import { Vendor } from "@/db/schema/vendors" +import { requestPQVendors } from "../service" +import { getProjectsWithPQList } from "@/lib/pq/service" +import type { Project } from "@/lib/pq/service" +import { useSession } from "next-auth/react" +import { DatePicker } from "@/components/ui/date-picker" + +interface RequestPQDialogProps extends React.ComponentPropsWithoutRef { + vendors: Row["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +const AGREEMENT_LIST = [ + "준법서약", + "표준하도급계약", + "안전보건관리계약", + "윤리규범 준수 서약", + "동반성장협약", + "내국신용장 미개설 합의", + "기술자료 제출 기본 동의", + "GTC 합의", +] + +export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...props }: RequestPQDialogProps) { + const [isApprovePending, startApproveTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + const { data: session } = useSession() + + const [type, setType] = React.useState<"GENERAL" | "PROJECT" | "NON_INSPECTION" | null>(null) + const [dueDate, setDueDate] = React.useState(null) + const [projects, setProjects] = React.useState([]) + const [selectedProjectId, setSelectedProjectId] = React.useState(null) + const [agreements, setAgreements] = React.useState>({}) + const [extraNote, setExtraNote] = React.useState("") + const [pqItems, setPqItems] = React.useState("") + const [isLoadingProjects, setIsLoadingProjects] = React.useState(false) + + React.useEffect(() => { + if (type === "PROJECT") { + setIsLoadingProjects(true) + getProjectsWithPQList().then(setProjects).catch(() => toast.error("프로젝트 로딩 실패")) + .finally(() => setIsLoadingProjects(false)) + } + }, [type]) + + React.useEffect(() => { + if (!props.open) { + setType(null) + setSelectedProjectId(null) + setAgreements({}) + setDueDate(null) + setPqItems("") + setExtraNote("") + } + }, [props.open]) + + const onApprove = () => { + if (!type) return toast.error("PQ 유형을 선택하세요.") + if (type === "PROJECT" && !selectedProjectId) return toast.error("프로젝트를 선택하세요.") + if (!dueDate) return toast.error("마감일을 선택하세요.") + if (!session?.user?.id) return toast.error("인증 실패") + + startApproveTransition(async () => { + const { error } = await requestPQVendors({ + ids: vendors.map((v) => v.id), + userId: Number(session.user.id), + agreements, + dueDate, + projectId: type === "PROJECT" ? selectedProjectId : null, + type: type || "GENERAL", + extraNote, + pqItems, + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("PQ가 성공적으로 요청되었습니다") + onSuccess?.() + }) + } + + const dialogContent = ( +
+ {/* 선택된 협력업체 정보 */} +
+ +
+ {vendors.map((vendor) => ( +
+
+
{vendor.vendorName}
+
+ {vendor.vendorCode} • {vendor.email || "이메일 없음"} +
+
+
+ ))} +
+
+ +
+ + +
+ + {type === "PROJECT" && ( +
+ + +
+ )} + + {/* 마감일 입력 */} +
+ + setDueDate(date ? date.toISOString().slice(0, 10) : "")} + placeholder="마감일 선택" + /> +
+ + {/* PQ 대상품목 */} +
+ +