diff options
Diffstat (limited to 'lib/vendors')
| -rw-r--r-- | lib/vendors/service.ts | 551 | ||||
| -rw-r--r-- | lib/vendors/table/attachmentButton.tsx | 45 | ||||
| -rw-r--r-- | lib/vendors/table/request-additional-Info-dialog.tsx | 152 | ||||
| -rw-r--r-- | lib/vendors/table/request-project-pq-dialog.tsx | 242 | ||||
| -rw-r--r-- | lib/vendors/table/request-vendor-investigate-dialog.tsx | 152 | ||||
| -rw-r--r-- | lib/vendors/table/send-vendor-dialog.tsx | 4 | ||||
| -rw-r--r-- | lib/vendors/table/vendors-table-columns.tsx | 158 | ||||
| -rw-r--r-- | lib/vendors/table/vendors-table-toolbar-actions.tsx | 86 | ||||
| -rw-r--r-- | lib/vendors/table/vendors-table.tsx | 5 | ||||
| -rw-r--r-- | lib/vendors/utils.ts | 48 | ||||
| -rw-r--r-- | lib/vendors/validations.ts | 103 |
11 files changed, 1327 insertions, 219 deletions
diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts index 2da16888..8f095c0e 100644 --- a/lib/vendors/service.ts +++ b/lib/vendors/service.ts @@ -2,7 +2,7 @@ import { revalidateTag, unstable_noStore } from "next/cache"; import db from "@/db/db"; -import { vendorAttachments, VendorContact, vendorContacts, vendorItemsView, vendorPossibleItems, vendors, type Vendor } from "@/db/schema/vendors"; +import { vendorAttachments, VendorContact, vendorContacts, vendorDetailView, vendorInvestigations, vendorInvestigationsView, vendorItemsView, vendorPossibleItems, vendors, type Vendor } from "@/db/schema/vendors"; import logger from '@/lib/logger'; import { filterColumns } from "@/lib/filter-columns"; @@ -38,7 +38,7 @@ import type { GetRfqHistorySchema, } from "./validations"; -import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull } from "drizzle-orm"; +import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull, count } from "drizzle-orm"; import { rfqItems, rfqs, vendorRfqView } from "@/db/schema/rfq"; import path from "path"; import fs from "fs/promises"; @@ -48,8 +48,10 @@ import { promises as fsPromises } from 'fs'; import { sendEmail } from "../mail/sendEmail"; import { PgTransaction } from "drizzle-orm/pg-core"; import { items } from "@/db/schema/items"; -import { id_ID } from "@faker-js/faker"; import { users } from "@/db/schema/users"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { projects, vendorProjectPQs } from "@/db/schema"; /* ----------------------------------------------------- @@ -178,7 +180,9 @@ export async function getVendorStatusCounts() { "REJECTED": 0, "IN_PQ": 0, "PQ_FAILED": 0, + "PQ_APPROVED": 0, "APPROVED": 0, + "READY_TO_SEND": 0, "PQ_SUBMITTED": 0 }; @@ -275,7 +279,7 @@ export async function createVendor(params: { vendorData: CreateVendorData // 기존의 일반 첨부파일 files?: File[] - + // 신용평가 / 현금흐름 등급 첨부 creditRatingFiles?: File[] cashFlowRatingFiles?: File[] @@ -288,25 +292,25 @@ export async function createVendor(params: { }[] }) { unstable_noStore() // Next.js 서버 액션 캐싱 방지 - + try { const { vendorData, files = [], creditRatingFiles = [], cashFlowRatingFiles = [], contacts } = params - + // 이메일 중복 검사 - 이미 users 테이블에 존재하는지 확인 const existingUser = await db .select({ id: users.id }) .from(users) .where(eq(users.email, vendorData.email)) .limit(1); - + // 이미 사용자가 존재하면 에러 반환 if (existingUser.length > 0) { - return { - data: null, - error: `이미 등록된 이메일입니다. 다른 이메일을 사용해주세요. (Email ${vendorData.email} already exists in the system)` + return { + data: null, + error: `이미 등록된 이메일입니다. 다른 이메일을 사용해주세요. (Email ${vendorData.email} already exists in the system)` }; } - + await db.transaction(async (tx) => { // 1) Insert the vendor (확장 필드도 함께) const [newVendor] = await insertVendor(tx, { @@ -319,36 +323,36 @@ export async function createVendor(params: { website: vendorData.website || null, status: vendorData.status ?? "PENDING_REVIEW", taxId: vendorData.taxId, - + // 대표자 정보 representativeName: vendorData.representativeName || null, representativeBirth: vendorData.representativeBirth || null, representativeEmail: vendorData.representativeEmail || null, representativePhone: vendorData.representativePhone || null, corporateRegistrationNumber: vendorData.corporateRegistrationNumber || null, - + // 신용/현금흐름 creditAgency: vendorData.creditAgency || null, creditRating: vendorData.creditRating || null, cashFlowRating: vendorData.cashFlowRating || null, }) - + // 2) If there are attached files, store them // (2-1) 일반 첨부 if (files.length > 0) { await storeVendorFiles(tx, newVendor.id, files, "GENERAL") } - + // (2-2) 신용평가 파일 if (creditRatingFiles.length > 0) { await storeVendorFiles(tx, newVendor.id, creditRatingFiles, "CREDIT_RATING") } - + // (2-3) 현금흐름 파일 if (cashFlowRatingFiles.length > 0) { await storeVendorFiles(tx, newVendor.id, cashFlowRatingFiles, "CASH_FLOW_RATING") } - + for (const contact of contacts) { await tx.insert(vendorContacts).values({ vendorId: newVendor.id, @@ -360,7 +364,7 @@ export async function createVendor(params: { }) } }) - + revalidateTag("vendors") return { data: null, error: null } } catch (error) { @@ -665,21 +669,21 @@ export async function getItemsForVendor(vendorId: number) { // 해당 vendorId가 이미 가지고 있는 itemCode 목록을 서브쿼리로 구함 // 그 아이템코드를 제외(notIn)하여 모든 items 테이블에서 조회 const itemsData = await db - .select({ - itemCode: items.itemCode, - itemName: items.itemName, - description: items.description, - }) - .from(items) - .leftJoin( - vendorPossibleItems, - eq(items.itemCode, vendorPossibleItems.itemCode) - ) - // vendorPossibleItems.vendorId가 이 vendorId인 행이 없는(즉 아직 등록되지 않은) 아이템만 - .where( - isNull(vendorPossibleItems.id) // 또는 isNull(vendorPossibleItems.itemCode) - ) - .orderBy(asc(items.itemName)) + .select({ + itemCode: items.itemCode, + itemName: items.itemName, + description: items.description, + }) + .from(items) + .leftJoin( + vendorPossibleItems, + eq(items.itemCode, vendorPossibleItems.itemCode) + ) + // vendorPossibleItems.vendorId가 이 vendorId인 행이 없는(즉 아직 등록되지 않은) 아이템만 + .where( + isNull(vendorPossibleItems.id) // 또는 isNull(vendorPossibleItems.itemCode) + ) + .orderBy(asc(items.itemName)) return { data: itemsData.map((item) => ({ @@ -843,14 +847,15 @@ export async function getRfqHistory(input: GetRfqHistorySchema, vendorId: number export async function checkJoinPortal(taxID: string) { try { // 이미 등록된 회사가 있는지 검색 - const result = await db.select().from(vendors).where(eq(vendors.taxId, taxID)).limit(1) + const result = await db.query.vendors.findFirst({ + where: eq(vendors.taxId, taxID) + }); - if (result.length > 0) { + if (result) { // 이미 가입되어 있음 - // data에 예시로 vendorName이나 다른 정보를 담아 반환 return { success: false, - data: result[0].vendorName ?? "Already joined", + data: result.vendorName ?? "Already joined", } } @@ -888,11 +893,9 @@ interface CreateCompanyInput { export async function downloadVendorAttachments(vendorId: number, fileId?: number) { try { // 벤더 정보 조회 - const vendor = await db.select() - .from(vendors) - .where(eq(vendors.id, vendorId)) - .limit(1) - .then(rows => rows[0]); + const vendor = await db.query.vendors.findFirst({ + where: eq(vendors.id, vendorId) + }); if (!vendor) { throw new Error(`벤더 정보를 찾을 수 없습니다. (ID: ${vendorId})`); @@ -1007,6 +1010,7 @@ export async function cleanupTempFiles(fileName: string) { interface ApproveVendorsInput { ids: number[]; + projectId?: number | null } /** @@ -1014,7 +1018,7 @@ interface ApproveVendorsInput { */ export async function approveVendors(input: ApproveVendorsInput) { unstable_noStore(); - + try { // 트랜잭션 내에서 벤더 상태 업데이트, 유저 생성 및 이메일 발송 const result = await db.transaction(async (tx) => { @@ -1027,7 +1031,7 @@ export async function approveVendors(input: ApproveVendorsInput) { }) .where(inArray(vendors.id, input.ids)) .returning(); - + // 2. 업데이트된 벤더 정보 조회 const updatedVendors = await tx .select({ @@ -1037,21 +1041,22 @@ export async function approveVendors(input: ApproveVendorsInput) { }) .from(vendors) .where(inArray(vendors.id, input.ids)); - + // 3. 각 벤더에 대한 유저 계정 생성 await Promise.all( updatedVendors.map(async (vendor) => { if (!vendor.email) return; // 이메일이 없으면 스킵 - + // 이미 존재하는 유저인지 확인 - const existingUser = await tx - .select({ id: users.id }) - .from(users) - .where(eq(users.email, vendor.email)) - .limit(1); - + const existingUser = await db.query.users.findFirst({ + where: eq(users.email, vendor.email), + columns: { + id: true + } + }); + // 유저가 존재하지 않는 경우에만 생성 - if (existingUser.length === 0) { + if (!existingUser) { await tx.insert(users).values({ name: vendor.vendorName, email: vendor.email, @@ -1061,20 +1066,20 @@ export async function approveVendors(input: ApproveVendorsInput) { } }) ); - + // 4. 각 벤더에게 이메일 발송 await Promise.all( updatedVendors.map(async (vendor) => { if (!vendor.email) return; // 이메일이 없으면 스킵 - + try { const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기 - - const subject = + + const subject = "[eVCP] Admin Account Created"; - + const loginUrl = "http://3.36.56.124:3000/en/login"; - + await sendEmail({ to: vendor.email, subject, @@ -1091,25 +1096,44 @@ export async function approveVendors(input: ApproveVendorsInput) { } }) ); - + return updated; }); - + // 캐시 무효화 revalidateTag("vendors"); revalidateTag("vendor-status-counts"); revalidateTag("users"); // 유저 캐시도 무효화 - + return { data: result, error: null }; } catch (err) { console.error("Error approving vendors:", err); return { data: null, error: getErrorMessage(err) }; } } + export async function requestPQVendors(input: ApproveVendorsInput) { unstable_noStore(); - + try { + // 프로젝트 정보 가져오기 (projectId가 있는 경우) + let projectInfo = null; + if (input.projectId) { + const project = await db + .select({ + id: projects.id, + projectCode: projects.code, + projectName: projects.name, + }) + .from(projects) + .where(eq(projects.id, input.projectId)) + .limit(1); + + if (project.length > 0) { + projectInfo = project[0]; + } + } + // 트랜잭션 내에서 벤더 상태 업데이트 및 이메일 발송 const result = await db.transaction(async (tx) => { // 1. 벤더 상태 업데이트 @@ -1121,7 +1145,7 @@ export async function requestPQVendors(input: ApproveVendorsInput) { }) .where(inArray(vendors.id, input.ids)) .returning(); - + // 2. 업데이트된 벤더 정보 조회 const updatedVendors = await tx .select({ @@ -1131,28 +1155,51 @@ export async function requestPQVendors(input: ApproveVendorsInput) { }) .from(vendors) .where(inArray(vendors.id, input.ids)); - - // 3. 각 벤더에게 이메일 발송 + + // 3. 프로젝트 PQ인 경우, vendorProjectPQs 테이블에 레코드 추가 + if (input.projectId && projectInfo) { + // 각 벤더에 대해 프로젝트 PQ 연결 생성 + const vendorProjectPQsData = input.ids.map(vendorId => ({ + vendorId, + projectId: input.projectId!, + status: "REQUESTED", + createdAt: new Date(), + updatedAt: new Date(), + })); + + await tx.insert(vendorProjectPQs).values(vendorProjectPQsData); + } + + // 4. 각 벤더에게 이메일 발송 await Promise.all( updatedVendors.map(async (vendor) => { if (!vendor.email) return; // 이메일이 없으면 스킵 - + try { const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기 - - const subject = - "[eVCP] You are invited to submit PQ"; - - const loginUrl = "http://3.36.56.124:3000/en/login"; - + + // 프로젝트 PQ인지 일반 PQ인지에 따라 제목 변경 + const subject = input.projectId + ? `[eVCP] You are invited to submit Project PQ for ${projectInfo?.projectCode || 'a project'}` + : "[eVCP] You are invited to submit PQ"; + + // 로그인 URL에 프로젝트 ID 추가 (프로젝트 PQ인 경우) + const baseLoginUrl = "http://3.36.56.124:3000/en/login"; + const loginUrl = input.projectId + ? `${baseLoginUrl}?projectId=${input.projectId}` + : baseLoginUrl; + await sendEmail({ to: vendor.email, subject, - template: "pq", // 이메일 템플릿 이름 + template: input.projectId ? "project-pq" : "pq", // 프로젝트별 템플릿 사용 context: { vendorName: vendor.vendorName, loginUrl, language: userLang, + projectCode: projectInfo?.projectCode || '', + projectName: projectInfo?.projectName || '', + hasProject: !!input.projectId, }, }); } catch (emailError) { @@ -1161,17 +1208,20 @@ export async function requestPQVendors(input: ApproveVendorsInput) { } }) ); - + return updated; }); - + // 캐시 무효화 revalidateTag("vendors"); revalidateTag("vendor-status-counts"); - + if (input.projectId) { + revalidateTag(`project-${input.projectId}`); + } + return { data: result, error: null }; } catch (err) { - console.error("Error approving vendors:", err); + console.error("Error requesting PQ from vendors:", err); return { data: null, error: getErrorMessage(err) }; } } @@ -1190,46 +1240,40 @@ export async function sendVendors(input: SendVendorsInput) { // 트랜잭션 내에서 진행 const result = await db.transaction(async (tx) => { // 1. 선택된 벤더 중 APPROVED 상태인 벤더만 필터링 - const approvedVendors = await tx - .select() - .from(vendors) - .where( - and( - inArray(vendors.id, input.ids), - eq(vendors.status, "APPROVED") - ) - ); + const approvedVendors = await db.query.vendors.findMany({ + where: and( + inArray(vendors.id, input.ids), + eq(vendors.status, "APPROVED") + ) + }); if (!approvedVendors.length) { throw new Error("No approved vendors found in the selection"); } - // 벤더별 처리 결과를 저장할 배열 const results = []; // 2. 각 벤더에 대해 처리 for (const vendor of approvedVendors) { // 2-1. 벤더 연락처 정보 조회 - const contacts = await tx - .select() - .from(vendorContacts) - .where(eq(vendorContacts.vendorId, vendor.id)); + const contacts = await db.query.vendorContacts.findMany({ + where: eq(vendorContacts.vendorId, vendor.id) + }); // 2-2. 벤더 가능 아이템 조회 - const possibleItems = await tx - .select() - .from(vendorPossibleItems) - .where(eq(vendorPossibleItems.vendorId, vendor.id)); - + const possibleItems = await db.query.vendorPossibleItems.findMany({ + where: eq(vendorPossibleItems.vendorId, vendor.id) + }); // 2-3. 벤더 첨부파일 조회 - const attachments = await tx - .select({ - id: vendorAttachments.id, - fileName: vendorAttachments.fileName, - filePath: vendorAttachments.filePath, - }) - .from(vendorAttachments) - .where(eq(vendorAttachments.vendorId, vendor.id)); + const attachments = await db.query.vendorAttachments.findMany({ + where: eq(vendorAttachments.vendorId, vendor.id), + columns: { + id: true, + fileName: true, + filePath: true + } + }); + // 2-4. 벤더 정보를 기간계 시스템에 전송 (NextJS API 라우트 사용) const vendorData = { @@ -1287,7 +1331,7 @@ export async function sendVendors(input: SendVendorsInput) { const subject = "[eVCP] Vendor Registration Completed"; - const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000' + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000' const portalUrl = `${baseUrl}/en/partners`; @@ -1343,3 +1387,298 @@ export async function sendVendors(input: SendVendorsInput) { } } + +interface RequestInfoProps { + ids: number[]; +} + +export async function requestInfo({ ids }: RequestInfoProps) { + try { + // 1. 벤더 정보 가져오기 + const vendorList = await db.query.vendors.findMany({ + where: inArray(vendors.id, ids), + }); + + if (!vendorList.length) { + return { error: "벤더 정보를 찾을 수 없습니다." }; + } + + // 2. 각 벤더에게 이메일 보내기 + for (const vendor of vendorList) { + // 이메일이 없는 경우 스킵 + if (!vendor.email) continue; + + // 벤더 정보 페이지 URL 생성 + const vendorInfoUrl = `${process.env.NEXT_PUBLIC_APP_URL}/partners/info?vendorId=${vendor.id}`; + + // 벤더에게 이메일 보내기 + await sendEmail({ + to: vendor.email, + subject: "[EVCP] 추가 정보 요청 / Additional Information Request", + template: "vendor-additional-info", + context: { + vendorName: vendor.vendorName, + vendorInfoUrl: vendorInfoUrl, + language: "ko", // 기본 언어 설정, 벤더의 선호 언어가 있다면 그것을 사용할 수 있음 + }, + }); + } + + // 3. 성공적으로 처리됨 + return { success: true }; + } catch (error) { + console.error("벤더 정보 요청 중 오류 발생:", error); + return { error: "벤더 정보 요청 중 오류가 발생했습니다. 다시 시도해 주세요." }; + } +} + + +export async function getVendorDetailById(id: number) { + try { + // View를 통해 벤더 정보 조회 + const vendor = await db + .select() + .from(vendorDetailView) + .where(eq(vendorDetailView.id, id)) + .limit(1) + .then(rows => rows[0] || null); + + if (!vendor) { + return null; + } + + // JSON 문자열로 반환된 contacts와 attachments를 JavaScript 객체로 파싱 + const contacts = typeof vendor.contacts === 'string' + ? JSON.parse(vendor.contacts) + : vendor.contacts; + + const attachments = typeof vendor.attachments === 'string' + ? JSON.parse(vendor.attachments) + : vendor.attachments; + + // 파싱된 데이터로 반환 + return { + ...vendor, + contacts, + attachments + }; + } catch (error) { + console.error("Error fetching vendor detail:", error); + throw new Error("Failed to fetch vendor detail"); + } +} + +export type UpdateVendorInfoData = { + id: number + vendorName: string + website?: string + address?: string + email: string + phone?: string + country?: string + representativeName?: string + representativeBirth?: string + representativeEmail?: string + representativePhone?: string + corporateRegistrationNumber?: string + creditAgency?: string + creditRating?: string + cashFlowRating?: string +} + +export type ContactInfo = { + id?: number + contactName: string + contactPosition?: string + contactEmail: string + contactPhone?: string + isPrimary?: boolean +} + +/** + * 벤더 정보를 업데이트하는 함수 + */ +export async function updateVendorInfo(params: { + vendorData: UpdateVendorInfoData + files?: File[] + creditRatingFiles?: File[] + cashFlowRatingFiles?: File[] + contacts: ContactInfo[] + filesToDelete?: number[] // 삭제할 파일 ID 목록 +}) { + try { + const { + vendorData, + files = [], + creditRatingFiles = [], + cashFlowRatingFiles = [], + contacts, + filesToDelete = [] + } = params + + // 세션 및 권한 확인 + const session = await getServerSession(authOptions) + if (!session?.user || !session.user.companyId) { + return { data: null, error: "권한이 없습니다. 로그인이 필요합니다." }; + } + + const companyId = Number(session.user.companyId); + + // 자신의 회사 정보만 수정 가능 (관리자는 모든 회사 정보 수정 가능) + if ( + // !session.user.isAdmin && + vendorData.id !== companyId) { + return { data: null, error: "자신의 회사 정보만 수정할 수 있습니다." }; + } + + // 트랜잭션으로 업데이트 수행 + await db.transaction(async (tx) => { + // 1. 벤더 정보 업데이트 + await tx.update(vendors).set({ + vendorName: vendorData.vendorName, + address: vendorData.address || null, + email: vendorData.email, + phone: vendorData.phone || null, + website: vendorData.website || null, + country: vendorData.country || null, + representativeName: vendorData.representativeName || null, + representativeBirth: vendorData.representativeBirth || null, + representativeEmail: vendorData.representativeEmail || null, + representativePhone: vendorData.representativePhone || null, + corporateRegistrationNumber: vendorData.corporateRegistrationNumber || null, + creditAgency: vendorData.creditAgency || null, + creditRating: vendorData.creditRating || null, + cashFlowRating: vendorData.cashFlowRating || null, + updatedAt: new Date(), + }).where(eq(vendors.id, vendorData.id)) + + // 2. 연락처 정보 관리 + // 2-1. 기존 연락처 가져오기 + const existingContacts = await tx + .select() + .from(vendorContacts) + .where(eq(vendorContacts.vendorId, vendorData.id)) + + // 2-2. 기존 연락처 ID 목록 + const existingContactIds = existingContacts.map(c => c.id) + + // 2-3. 업데이트할 연락처와 새로 추가할 연락처 분류 + const contactsToUpdate = contacts.filter(c => c.id && existingContactIds.includes(c.id)) + const contactsToAdd = contacts.filter(c => !c.id) + + // 2-4. 삭제할 연락처 (기존에 있지만 새 목록에 없는 것) + const contactIdsToKeep = contactsToUpdate.map(c => c.id) + .filter((id): id is number => id !== undefined) + const contactIdsToDelete = existingContactIds.filter(id => !contactIdsToKeep.includes(id)) + + // 2-5. 연락처 삭제 + if (contactIdsToDelete.length > 0) { + await tx + .delete(vendorContacts) + .where(and( + eq(vendorContacts.vendorId, vendorData.id), + inArray(vendorContacts.id, contactIdsToDelete) + )) + } + + // 2-6. 연락처 업데이트 + for (const contact of contactsToUpdate) { + if (contact.id !== undefined) { + await tx + .update(vendorContacts) + .set({ + contactName: contact.contactName, + contactPosition: contact.contactPosition || null, + contactEmail: contact.contactEmail, + contactPhone: contact.contactPhone || null, + isPrimary: contact.isPrimary || false, + updatedAt: new Date(), + }) + .where(and( + eq(vendorContacts.id, contact.id), + eq(vendorContacts.vendorId, vendorData.id) + )) + } + } + + // 2-7. 연락처 추가 + for (const contact of contactsToAdd) { + await tx + .insert(vendorContacts) + .values({ + vendorId: vendorData.id, + contactName: contact.contactName, + contactPosition: contact.contactPosition || null, + contactEmail: contact.contactEmail, + contactPhone: contact.contactPhone || null, + isPrimary: contact.isPrimary || false, + }) + } + + // 3. 파일 삭제 처리 + if (filesToDelete.length > 0) { + // 3-1. 삭제할 파일 정보 가져오기 + const attachmentsToDelete = await tx + .select() + .from(vendorAttachments) + .where(and( + eq(vendorAttachments.vendorId, vendorData.id), + inArray(vendorAttachments.id, filesToDelete) + )) + + // 3-2. 파일 시스템에서 파일 삭제 + for (const attachment of attachmentsToDelete) { + try { + // 파일 경로는 /public 기준이므로 process.cwd()/public을 앞에 붙임 + const filePath = path.join(process.cwd(), 'public', attachment.filePath.replace(/^\//, '')) + await fs.access(filePath, fs.constants.F_OK) // 파일 존재 확인 + await fs.unlink(filePath) // 파일 삭제 + } catch (error) { + console.warn(`Failed to delete file for attachment ${attachment.id}:`, error) + // 파일 삭제 실패해도 DB에서는 삭제 진행 + } + } + + // 3-3. DB에서 파일 기록 삭제 + await tx + .delete(vendorAttachments) + .where(and( + eq(vendorAttachments.vendorId, vendorData.id), + inArray(vendorAttachments.id, filesToDelete) + )) + } + + // 4. 새 파일 저장 (제공된 storeVendorFiles 함수 활용) + // 4-1. 일반 파일 저장 + if (files.length > 0) { + await storeVendorFiles(tx, vendorData.id, files, "GENERAL"); + } + + // 4-2. 신용평가 파일 저장 + if (creditRatingFiles.length > 0) { + await storeVendorFiles(tx, vendorData.id, creditRatingFiles, "CREDIT_RATING"); + } + + // 4-3. 현금흐름 파일 저장 + if (cashFlowRatingFiles.length > 0) { + await storeVendorFiles(tx, vendorData.id, cashFlowRatingFiles, "CASH_FLOW_RATING"); + } + }) + + // 캐시 무효화 + revalidateTag("vendors") + revalidateTag(`vendor-${vendorData.id}`) + + return { + data: { + success: true, + message: '벤더 정보가 성공적으로 업데이트되었습니다.', + vendorId: vendorData.id + }, + error: null + } + } catch (error) { + console.error("Vendor info update error:", error); + return { data: null, error: getErrorMessage(error) } + } +}
\ No newline at end of file diff --git a/lib/vendors/table/attachmentButton.tsx b/lib/vendors/table/attachmentButton.tsx index a82f59e1..3ffa9c5f 100644 --- a/lib/vendors/table/attachmentButton.tsx +++ b/lib/vendors/table/attachmentButton.tsx @@ -16,25 +16,25 @@ interface AttachmentsButtonProps { export function AttachmentsButton({ vendorId, hasAttachments, attachmentsList = [] }: AttachmentsButtonProps) { if (!hasAttachments) return null; - + const handleDownload = async () => { try { toast.loading('첨부파일을 준비하는 중...'); - + // 서버 액션 호출 const result = await downloadVendorAttachments(vendorId); - + // 로딩 토스트 닫기 toast.dismiss(); - + if (!result || !result.url) { toast.error('다운로드 준비 중 오류가 발생했습니다.'); return; } - + // 파일 다운로드 트리거 toast.success('첨부파일 다운로드가 시작되었습니다.'); - + // 다운로드 링크 열기 const a = document.createElement('a'); a.href = result.url; @@ -43,27 +43,34 @@ export function AttachmentsButton({ vendorId, hasAttachments, attachmentsList = document.body.appendChild(a); a.click(); document.body.removeChild(a); - + } catch (error) { toast.dismiss(); toast.error('첨부파일 다운로드에 실패했습니다.'); console.error('첨부파일 다운로드 오류:', error); } }; - + return ( - <Button - variant="ghost" - size="icon" - onClick={handleDownload} - title={`${attachmentsList.length}개 파일 다운로드`} - > - <PaperclipIcon className="h-4 w-4" /> - {attachmentsList.length > 1 && ( - <Badge variant="outline" className="ml-1 h-5 min-w-5 px-1"> + <> + {attachmentsList && attachmentsList.length > 0 && + <Button + variant="ghost" + size="icon" + onClick={handleDownload} + title={`${attachmentsList.length}개 파일 다운로드`} + > + <PaperclipIcon className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + {/* {attachmentsList.length > 1 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.425rem] leading-none flex items-center justify-center" + > {attachmentsList.length} </Badge> - )} - </Button> + )} */} + </Button> + } + </> ); } diff --git a/lib/vendors/table/request-additional-Info-dialog.tsx b/lib/vendors/table/request-additional-Info-dialog.tsx new file mode 100644 index 00000000..872162dd --- /dev/null +++ b/lib/vendors/table/request-additional-Info-dialog.tsx @@ -0,0 +1,152 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Send } 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 { Vendor } from "@/db/schema/vendors" +import { requestInfo } from "../service" + +interface RequestInfoDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + vendors: Row<Vendor>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function RequestInfoDialog({ + vendors, + showTrigger = true, + onSuccess, + ...props +}: RequestInfoDialogProps) { + const [isRequestPending, startRequestTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onApprove() { + startRequestTransition(async () => { + const { error, success } = await requestInfo({ + ids: vendors.map((vendor) => vendor.id), + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("추가 정보 요청이 성공적으로 벤더에게 발송되었습니다.") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <Send className="size-4" aria-hidden="true" /> + 추가 정보 요청 ({vendors.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>벤더 추가 정보 요청 확인</DialogTitle> + <DialogDescription> + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 추가 정보를 요청하시겠습니까? + <br /><br /> + 요청시 벤더에게 이메일이 발송되며, 벤더는 별도 페이지에서 신용 평가 및 현금 흐름 정보와 같은 + 추가 정보를 입력하게 됩니다. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">취소</Button> + </DialogClose> + <Button + aria-label="Send request to selected vendors" + variant="default" + onClick={onApprove} + disabled={isRequestPending} + > + {isRequestPending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 요청 발송 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <Send className="size-4" aria-hidden="true" /> + 추가 정보 요청 ({vendors.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>벤더 추가 정보 요청 확인</DrawerTitle> + <DrawerDescription> + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 추가 정보를 요청하시겠습니까? + <br /><br /> + 요청시 벤더에게 이메일이 발송되며, 벤더는 별도 페이지에서 신용 평가 및 현금 흐름 정보와 같은 + 추가 정보를 입력하게 됩니다. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">취소</Button> + </DrawerClose> + <Button + aria-label="Send request to selected vendors" + variant="default" + onClick={onApprove} + disabled={isRequestPending} + > + {isRequestPending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + 요청 발송 + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/vendors/table/request-project-pq-dialog.tsx b/lib/vendors/table/request-project-pq-dialog.tsx new file mode 100644 index 00000000..c590d7ec --- /dev/null +++ b/lib/vendors/table/request-project-pq-dialog.tsx @@ -0,0 +1,242 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, ChevronDown, BuildingIcon } 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 { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Label } from "@/components/ui/label" +import { Vendor } from "@/db/schema/vendors" +import { requestPQVendors } from "../service" +import { getProjects, type Project } from "@/lib/rfqs/service" + +interface RequestProjectPQDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + vendors: Row<Vendor>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function RequestProjectPQDialog({ + vendors, + showTrigger = true, + onSuccess, + ...props +}: RequestProjectPQDialogProps) { + const [isApprovePending, startApproveTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + const [projects, setProjects] = React.useState<Project[]>([]) + const [selectedProjectId, setSelectedProjectId] = React.useState<number | null>(null) + const [isLoadingProjects, setIsLoadingProjects] = React.useState(false) + + // 프로젝트 목록 로드 + React.useEffect(() => { + async function loadProjects() { + setIsLoadingProjects(true) + try { + const projectsList = await getProjects() + setProjects(projectsList) + } catch (error) { + console.error("프로젝트 목록 로드 오류:", error) + toast.error("프로젝트 목록을 불러오는 중 오류가 발생했습니다.") + } finally { + setIsLoadingProjects(false) + } + } + + loadProjects() + }, []) + + // 다이얼로그가 닫힐 때 선택된 프로젝트 초기화 + React.useEffect(() => { + if (!props.open) { + setSelectedProjectId(null) + } + }, [props.open]) + + // 프로젝트 선택 처리 + const handleProjectChange = (value: string) => { + setSelectedProjectId(Number(value)) + } + + function onApprove() { + if (!selectedProjectId) { + toast.error("프로젝트를 선택해주세요.") + return + } + + startApproveTransition(async () => { + const { error } = await requestPQVendors({ + ids: vendors.map((vendor) => vendor.id), + projectId: selectedProjectId, + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + + toast.success(`벤더에게 프로젝트 PQ가 성공적으로 요청되었습니다.`) + onSuccess?.() + }) + } + + const dialogContent = ( + <> + <div className="space-y-4 py-2"> + <div className="space-y-2"> + <Label htmlFor="project-selection">프로젝트 선택</Label> + <Select + onValueChange={handleProjectChange} + disabled={isLoadingProjects || isApprovePending} + > + <SelectTrigger id="project-selection" className="w-full"> + <SelectValue placeholder="프로젝트를 선택하세요" /> + </SelectTrigger> + <SelectContent> + {isLoadingProjects ? ( + <SelectItem value="loading" disabled>프로젝트 로딩 중...</SelectItem> + ) : projects.length === 0 ? ( + <SelectItem value="empty" disabled>등록된 프로젝트가 없습니다</SelectItem> + ) : ( + projects.map((project) => ( + <SelectItem key={project.id} value={project.id.toString()}> + {project.projectCode} - {project.projectName} + </SelectItem> + )) + )} + </SelectContent> + </Select> + </div> + </div> + </> + ) + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <BuildingIcon className="size-4" aria-hidden="true" /> + 프로젝트 PQ 요청 ({vendors.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>프로젝트 PQ 요청 확인</DialogTitle> + <DialogDescription> + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 프로젝트 PQ 제출을 요청하시겠습니까? + 요청을 보내면 벤더에게 알림이 발송되고 프로젝트 PQ 정보를 입력할 수 있게 됩니다. + </DialogDescription> + </DialogHeader> + + {dialogContent} + + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">취소</Button> + </DialogClose> + <Button + aria-label="선택한 벤더에게 요청하기" + variant="default" + onClick={onApprove} + disabled={isApprovePending || !selectedProjectId} + > + {isApprovePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 요청하기 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <BuildingIcon className="size-4" aria-hidden="true" /> + 프로젝트 PQ 요청 ({vendors.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>프로젝트 PQ 요청 확인</DrawerTitle> + <DrawerDescription> + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 프로젝트 PQ 제출을 요청하시겠습니까? + 요청을 보내면 벤더에게 알림이 발송되고 프로젝트 PQ 정보를 입력할 수 있게 됩니다. + </DrawerDescription> + </DrawerHeader> + + <div className="px-4"> + {dialogContent} + </div> + + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">취소</Button> + </DrawerClose> + <Button + aria-label="선택한 벤더에게 요청하기" + variant="default" + onClick={onApprove} + disabled={isApprovePending || !selectedProjectId} + > + {isApprovePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + 요청하기 + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/vendors/table/request-vendor-investigate-dialog.tsx b/lib/vendors/table/request-vendor-investigate-dialog.tsx new file mode 100644 index 00000000..0309ee4a --- /dev/null +++ b/lib/vendors/table/request-vendor-investigate-dialog.tsx @@ -0,0 +1,152 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Check, 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 { Vendor } from "@/db/schema/vendors" +import { requestInvestigateVendors } from "@/lib/vendor-investigation/service" + +interface ApprovalVendorDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + vendors: Row<Vendor>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function RequestVendorsInvestigateDialog({ + vendors, + showTrigger = true, + onSuccess, + ...props +}: ApprovalVendorDialogProps) { + + console.log(vendors) + const [isApprovePending, startApproveTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onApprove() { + startApproveTransition(async () => { + const { error } = await requestInvestigateVendors({ + ids: vendors.map((vendor) => vendor.id), + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("Vendor Investigation successfully sent to 벤더실사담당자") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <SendHorizonal className="size-4" aria-hidden="true" /> + Vendor Investigation Request ({vendors.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Confirm Vendor Investigation Requst</DialogTitle> + <DialogDescription> + Are you sure you want to request{" "} + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? " vendor" : " vendors"}? + After sent, 벤더실사담당자 will be notified and can manage it. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Request selected vendors" + variant="default" + onClick={onApprove} + disabled={isApprovePending} + > + {isApprovePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Request + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <Check className="size-4" aria-hidden="true" /> + Investigation Request ({vendors.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Confirm Vendor Investigation</DrawerTitle> + <DrawerDescription> + Are you sure you want to request{" "} + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? " vendor" : " vendors"}? + After sent, 벤더실사담당자 will be notified and can manage it. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Request selected vendors" + variant="default" + onClick={onApprove} + disabled={isApprovePending} + > + {isApprovePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Request + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/vendors/table/send-vendor-dialog.tsx b/lib/vendors/table/send-vendor-dialog.tsx index a34abb77..1f93bd7f 100644 --- a/lib/vendors/table/send-vendor-dialog.tsx +++ b/lib/vendors/table/send-vendor-dialog.tsx @@ -28,7 +28,7 @@ import { DrawerTrigger, } from "@/components/ui/drawer" import { Vendor } from "@/db/schema/vendors" -import { requestPQVendors, sendVendors } from "../service" +import { sendVendors } from "../service" interface ApprovalVendorDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { @@ -58,7 +58,7 @@ export function SendVendorsDialog({ } props.onOpenChange?.(false) - toast.success("PQ successfully sent to vendors") + toast.success("Vendor Information successfully sent to MDG") onSuccess?.() }) } diff --git a/lib/vendors/table/vendors-table-columns.tsx b/lib/vendors/table/vendors-table-columns.tsx index c503e369..77750c47 100644 --- a/lib/vendors/table/vendors-table-columns.tsx +++ b/lib/vendors/table/vendors-table-columns.tsx @@ -79,82 +79,96 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef // ---------------------------------------------------------------- // 2) actions 컬럼 (Dropdown 메뉴) // ---------------------------------------------------------------- - const actionsColumn: ColumnDef<Vendor> = { - id: "actions", - enableHiding: false, - cell: function Cell({ row }) { - const [isUpdatePending, startUpdateTransition] = React.useTransition() +// ---------------------------------------------------------------- +// 2) actions 컬럼 (Dropdown 메뉴) +// ---------------------------------------------------------------- +const actionsColumn: ColumnDef<Vendor> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const isApproved = row.original.status === "APPROVED"; - return ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - aria-label="Open menu" - variant="ghost" - className="flex size-8 p-0 data-[state=open]:bg-muted" - > - <Ellipsis className="size-4" aria-hidden="true" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-40"> - <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "update" })} - > - Edit - </DropdownMenuItem> - <DropdownMenuItem - onSelect={() => { - // 1) 만약 rowAction을 열고 싶다면 - // setRowAction({ row, type: "update" }) + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-56"> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + Edit + </DropdownMenuItem> + <DropdownMenuItem + onSelect={() => { + // 1) 만약 rowAction을 열고 싶다면 + // setRowAction({ row, type: "update" }) - // 2) 자세히 보기 페이지로 클라이언트 라우팅 - router.push(`/evcp/vendors/${row.original.id}/info`); - }} + // 2) 자세히 보기 페이지로 클라이언트 라우팅 + router.push(`/evcp/vendors/${row.original.id}/info`); + }} + > + Details + </DropdownMenuItem> + + {/* APPROVED 상태일 때만 추가 정보 기입 메뉴 표시 */} + {isApproved && ( + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "requestInfo" })} + className="text-blue-600 font-medium" > - Details + 추가 정보 기입 </DropdownMenuItem> - <Separator /> - <DropdownMenuSub> - <DropdownMenuSubTrigger>Status</DropdownMenuSubTrigger> - <DropdownMenuSubContent> - <DropdownMenuRadioGroup - value={row.original.status} - onValueChange={(value) => { - startUpdateTransition(() => { - toast.promise( - modifyVendor({ - id: String(row.original.id), - status: value as Vendor["status"], - }), - { - loading: "Updating...", - success: "Label updated", - error: (err) => getErrorMessage(err), - } - ) - }) - }} - > - {vendors.status.enumValues.map((status) => ( - <DropdownMenuRadioItem - key={status} - value={status} - className="capitalize" - disabled={isUpdatePending} - > - {status} - </DropdownMenuRadioItem> - ))} - </DropdownMenuRadioGroup> - </DropdownMenuSubContent> - </DropdownMenuSub> - - </DropdownMenuContent> - </DropdownMenu> - ) - }, - size: 40, - } + )} + + <Separator /> + <DropdownMenuSub> + <DropdownMenuSubTrigger>Status</DropdownMenuSubTrigger> + <DropdownMenuSubContent> + <DropdownMenuRadioGroup + value={row.original.status} + onValueChange={(value) => { + startUpdateTransition(() => { + toast.promise( + modifyVendor({ + id: String(row.original.id), + status: value as Vendor["status"], + }), + { + loading: "Updating...", + success: "Label updated", + error: (err) => getErrorMessage(err), + } + ) + }) + }} + > + {vendors.status.enumValues.map((status) => ( + <DropdownMenuRadioItem + key={status} + value={status} + className="capitalize" + disabled={isUpdatePending} + > + {status} + </DropdownMenuRadioItem> + ))} + </DropdownMenuRadioGroup> + </DropdownMenuSubContent> + </DropdownMenuSub> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, +} // ---------------------------------------------------------------- // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 diff --git a/lib/vendors/table/vendors-table-toolbar-actions.tsx b/lib/vendors/table/vendors-table-toolbar-actions.tsx index c0605191..3cb2c552 100644 --- a/lib/vendors/table/vendors-table-toolbar-actions.tsx +++ b/lib/vendors/table/vendors-table-toolbar-actions.tsx @@ -2,15 +2,24 @@ import * as React from "react" import { type Table } from "@tanstack/react-table" -import { Download, Upload, Check } from "lucide-react" +import { Download, Upload, Check, BuildingIcon } from "lucide-react" import { toast } from "sonner" import { exportTableToExcel } from "@/lib/export" import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" import { Vendor } from "@/db/schema/vendors" import { ApproveVendorsDialog } from "./approve-vendor-dialog" import { RequestPQVendorsDialog } from "./request-vendor-pg-dialog" +import { RequestProjectPQDialog } from "./request-project-pq-dialog" import { SendVendorsDialog } from "./send-vendor-dialog" +import { RequestVendorsInvestigateDialog } from "./request-vendor-investigate-dialog" +import { RequestInfoDialog } from "./request-additional-Info-dialog" interface VendorsTableToolbarActionsProps { table: Table<Vendor> @@ -19,7 +28,7 @@ interface VendorsTableToolbarActionsProps { export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActionsProps) { // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 const fileInputRef = React.useRef<HTMLInputElement>(null) - + // 선택된 벤더 중 PENDING_REVIEW 상태인 벤더만 필터링 const pendingReviewVendors = React.useMemo(() => { return table @@ -28,9 +37,8 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions .map(row => row.original) .filter(vendor => vendor.status === "PENDING_REVIEW"); }, [table.getFilteredSelectedRowModel().rows]); - - - // 선택된 벤더 중 PENDING_REVIEW 상태인 벤더만 필터링 + + // 선택된 벤더 중 IN_REVIEW 상태인 벤더만 필터링 const inReviewVendors = React.useMemo(() => { return table .getFilteredSelectedRowModel() @@ -38,7 +46,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions .map(row => row.original) .filter(vendor => vendor.status === "IN_REVIEW"); }, [table.getFilteredSelectedRowModel().rows]); - + const approvedVendors = React.useMemo(() => { return table .getFilteredSelectedRowModel() @@ -46,14 +54,36 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions .map(row => row.original) .filter(vendor => vendor.status === "APPROVED"); }, [table.getFilteredSelectedRowModel().rows]); - - - + + const sendVendors = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(vendor => vendor.status === "READY_TO_SEND"); + }, [table.getFilteredSelectedRowModel().rows]); + + const pqApprovedVendors = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(vendor => vendor.status === "PQ_APPROVED"); + }, [table.getFilteredSelectedRowModel().rows]); + + // 프로젝트 PQ를 보낼 수 있는 벤더 상태 필터링 + const projectPQEligibleVendors = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(vendor => + ["PENDING_REVIEW", "IN_REVIEW", "IN_PQ", "PQ_APPROVED", "APPROVED", "READY_TO_SEND", "ACTIVE"].includes(vendor.status) + ); + }, [table.getFilteredSelectedRowModel().rows]); + return ( <div className="flex items-center gap-2"> - - - {/* 승인 다이얼로그: PENDING_REVIEW 상태인 벤더가 있을 때만 표시 */} {pendingReviewVendors.length > 0 && ( <ApproveVendorsDialog @@ -61,22 +91,44 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions onSuccess={() => table.toggleAllRowsSelected(false)} /> )} - + + {/* 일반 PQ 요청: IN_REVIEW 상태인 벤더가 있을 때만 표시 */} {inReviewVendors.length > 0 && ( <RequestPQVendorsDialog vendors={inReviewVendors} onSuccess={() => table.toggleAllRowsSelected(false)} /> )} - + + {/* 프로젝트 PQ 요청: 적격 상태의 벤더가 있을 때만 표시 */} + {projectPQEligibleVendors.length > 0 && ( + <RequestProjectPQDialog + vendors={projectPQEligibleVendors} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + )} + {approvedVendors.length > 0 && ( - <SendVendorsDialog + <RequestInfoDialog vendors={approvedVendors} onSuccess={() => table.toggleAllRowsSelected(false)} /> )} - - + + {sendVendors.length > 0 && ( + <RequestInfoDialog + vendors={sendVendors} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + )} + + {pqApprovedVendors.length > 0 && ( + <RequestVendorsInvestigateDialog + vendors={pqApprovedVendors} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + )} + {/** 4) Export 버튼 */} <Button variant="outline" diff --git a/lib/vendors/table/vendors-table.tsx b/lib/vendors/table/vendors-table.tsx index c04d57a9..36fd45bd 100644 --- a/lib/vendors/table/vendors-table.tsx +++ b/lib/vendors/table/vendors-table.tsx @@ -20,6 +20,7 @@ import { VendorsTableToolbarActions } from "./vendors-table-toolbar-actions" import { VendorsTableFloatingBar } from "./vendors-table-floating-bar" import { UpdateTaskSheet } from "@/lib/tasks/table/update-task-sheet" import { UpdateVendorSheet } from "./update-vendor-sheet" +import { getVendorStatusIcon } from "@/lib/vendors/utils" interface VendorsTableProps { promises: Promise< @@ -72,9 +73,11 @@ export function VendorsTable({ promises }: VendorsTableProps) { label: "Status", type: "multi-select", options: vendors.status.enumValues.map((status) => ({ - label: toSentenceCase(status), + label: (status), value: status, count: statusCounts[status], + icon: getVendorStatusIcon(status), + })), }, { id: "createdAt", label: "Created at", type: "date" }, diff --git a/lib/vendors/utils.ts b/lib/vendors/utils.ts new file mode 100644 index 00000000..305d772d --- /dev/null +++ b/lib/vendors/utils.ts @@ -0,0 +1,48 @@ +import { + Activity, + AlertCircle, + AlertTriangle, + ArrowDownIcon, + ArrowRightIcon, + ArrowUpIcon, + AwardIcon, + BadgeCheck, + CheckCircle2, + CircleHelp, + CircleIcon, + CircleX, + ClipboardCheck, + ClipboardList, + FileCheck2, + FilePenLine, + FileX2, + MailCheck, + PencilIcon, + SearchIcon, + SendIcon, + Timer, + Trash2, + XCircle, +} from "lucide-react" + +import { Vendor } from "@/db/schema/vendors" + +export function getVendorStatusIcon(status: Vendor["status"]) { + const statusIcons = { + PENDING_REVIEW: ClipboardList, // 가입 신청 중 (초기 신청) + IN_REVIEW: FilePenLine, // 심사 중 + REJECTED: XCircle, // 심사 거부됨 + IN_PQ: ClipboardCheck, // PQ 진행 중 + PQ_SUBMITTED: FileCheck2, // PQ 제출 + PQ_FAILED: FileX2, // PQ 실패 + PQ_APPROVED: BadgeCheck, // PQ 통과, 승인됨 + APPROVED: CheckCircle2, // PQ 통과, 승인됨 + READY_TO_SEND: CheckCircle2, // PQ 통과, 승인됨 + ACTIVE: Activity, // 활성 상태 (실제 거래 중) + INACTIVE: AlertCircle, // 비활성 상태 (일시적) + BLACKLISTED: AlertTriangle, // 거래 금지 상태 + } + + return statusIcons[status] || CircleIcon +} + diff --git a/lib/vendors/validations.ts b/lib/vendors/validations.ts index 14efc8dc..1c08f8ff 100644 --- a/lib/vendors/validations.ts +++ b/lib/vendors/validations.ts @@ -1,4 +1,3 @@ -import { tasks, type Task } from "@/db/schema/tasks"; import { createSearchParamsCache, parseAsArrayOf, @@ -9,7 +8,7 @@ import { import * as z from "zod" import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" -import { Vendor, VendorContact, VendorItemsView, vendors } from "@/db/schema/vendors"; +import { Vendor, VendorContact, vendorInvestigationsView, VendorItemsView, vendors } from "@/db/schema/vendors"; import { rfqs } from "@/db/schema/rfq" @@ -339,3 +338,103 @@ export type UpdateVendorContactSchema = z.infer<typeof updateVendorContactSchema export type CreateVendorItemSchema = z.infer<typeof createVendorItemSchema> export type UpdateVendorItemSchema = z.infer<typeof updateVendorItemSchema> export type GetRfqHistorySchema = Awaited<ReturnType<typeof searchParamsRfqHistoryCache.parse>> + + + +export const updateVendorInfoSchema = z.object({ + vendorName: z.string().min(1, "업체명은 필수 입력사항입니다."), + taxId: z.string(), + address: z.string().optional(), + country: z.string().min(1, "국가를 선택해 주세요."), + phone: z.string().optional(), + email: z.string().email("유효한 이메일을 입력해 주세요."), + website: z.string().optional(), + + // 한국 사업자 정보 (KR일 경우 필수 항목들) + representativeName: z.string().optional(), + representativeBirth: z.string().optional(), + representativeEmail: z.string().optional(), + representativePhone: z.string().optional(), + corporateRegistrationNumber: z.string().optional(), + + // 신용평가 정보 + creditAgency: z.string().optional(), + creditRating: z.string().optional(), + cashFlowRating: z.string().optional(), + + // 첨부파일 + attachedFiles: z.any().optional(), + creditRatingAttachment: z.any().optional(), + cashFlowRatingAttachment: z.any().optional(), + + // 연락처 정보 + contacts: z.array(contactSchema).min(1, "최소 1명의 담당자가 필요합니다."), +}) + +export const updateVendorSchemaWithConditions = updateVendorInfoSchema.superRefine( + (data, ctx) => { + // 국가가 한국(KR)인 경우, 한국 사업자 정보 필수 + if (data.country === "KR") { + if (!data.representativeName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "대표자 이름은 필수 입력사항입니다.", + path: ["representativeName"], + }) + } + + if (!data.representativeBirth) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "대표자 생년월일은 필수 입력사항입니다.", + path: ["representativeBirth"], + }) + } + + if (!data.representativeEmail) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "대표자 이메일은 필수 입력사항입니다.", + path: ["representativeEmail"], + }) + } + + if (!data.representativePhone) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "대표자 전화번호는 필수 입력사항입니다.", + path: ["representativePhone"], + }) + } + + if (!data.corporateRegistrationNumber) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "법인등록번호는 필수 입력사항입니다.", + path: ["corporateRegistrationNumber"], + }) + } + + // 신용평가사가 선택된 경우, 등급 정보 필수 + if (data.creditAgency) { + if (!data.creditRating) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "신용평가등급은 필수 입력사항입니다.", + path: ["creditRating"], + }) + } + + if (!data.cashFlowRating) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "현금흐름등급은 필수 입력사항입니다.", + path: ["cashFlowRating"], + }) + } + } + } + } +) + +export type UpdateVendorInfoSchema = z.infer<typeof updateVendorInfoSchema>
\ No newline at end of file |
