diff options
Diffstat (limited to 'lib/vendors')
| -rw-r--r-- | lib/vendors/repository.ts | 34 | ||||
| -rw-r--r-- | lib/vendors/service.ts | 731 | ||||
| -rw-r--r-- | lib/vendors/table/approve-vendor-dialog.tsx | 11 | ||||
| -rw-r--r-- | lib/vendors/table/request-additional-Info-dialog.tsx | 22 | ||||
| -rw-r--r-- | lib/vendors/table/request-basicContract-dialog.tsx | 548 | ||||
| -rw-r--r-- | lib/vendors/table/request-project-pq-dialog.tsx | 24 | ||||
| -rw-r--r-- | lib/vendors/table/request-vendor-investigate-dialog.tsx | 243 | ||||
| -rw-r--r-- | lib/vendors/table/request-vendor-pg-dialog.tsx | 11 | ||||
| -rw-r--r-- | lib/vendors/table/update-vendor-sheet.tsx | 710 | ||||
| -rw-r--r-- | lib/vendors/table/vendor-all-export.ts | 486 | ||||
| -rw-r--r-- | lib/vendors/table/vendors-table-columns.tsx | 393 | ||||
| -rw-r--r-- | lib/vendors/table/vendors-table-toolbar-actions.tsx | 154 | ||||
| -rw-r--r-- | lib/vendors/table/vendors-table.tsx | 99 | ||||
| -rw-r--r-- | lib/vendors/table/view-vendors_logs-dialog.tsx | 244 | ||||
| -rw-r--r-- | lib/vendors/validations.ts | 79 |
15 files changed, 3163 insertions, 626 deletions
diff --git a/lib/vendors/repository.ts b/lib/vendors/repository.ts index ff195932..1f59aac0 100644 --- a/lib/vendors/repository.ts +++ b/lib/vendors/repository.ts @@ -2,7 +2,7 @@ import { and, eq, inArray, count, gt, AnyColumn, SQLWrapper, SQL} from "drizzle-orm"; import { PgTransaction } from "drizzle-orm/pg-core"; -import { VendorContact, vendorContacts, vendorItemsView, vendorPossibleItems, vendors, type Vendor } from "@/db/schema/vendors"; +import { VendorContact, vendorContacts, vendorItemsView, vendorPossibleItems, vendors, vendorsWithTypesView, type Vendor } from "@/db/schema/vendors"; import db from '@/db/db'; import { items } from "@/db/schema/items"; import { rfqs,rfqItems, rfqEvaluations, vendorResponses } from "@/db/schema/rfq"; @@ -47,8 +47,33 @@ export async function countVendors( } + export async function selectVendorsWithTypes ( + tx: PgTransaction<any, any, any>, + { where, orderBy, offset, limit }: SelectVendorsOptions +) { + return tx + .select() + .from(vendorsWithTypesView) + .where(where ?? undefined) + .orderBy(...(orderBy ?? [])) + .offset(offset ?? 0) + .limit(limit ?? 20); +} + /** - * 3) INSERT (단일 벤더 생성) + * 2) COUNT + */ +export async function countVendorsWithTypes( + tx: PgTransaction<any, any, any>, + where?: any + ) { + const res = await tx.select({ count: count() }).from(vendorsWithTypesView).where(where); + return res[0]?.count ?? 0; + } + + +/** + * 3) INSERT (단일 협력업체 생성) * - id/createdAt/updatedAt은 DB default 사용 * - 반환값은 "생성된 레코드" 배열 ([newVendor]) */ @@ -60,7 +85,7 @@ export async function insertVendor( } /** - * 4) UPDATE (단일 벤더) + * 4) UPDATE (단일 협력업체) */ export async function updateVendor( tx: PgTransaction<any, any, any>, @@ -75,7 +100,7 @@ export async function updateVendor( } /** - * 5) UPDATE (복수 벤더) + * 5) UPDATE (복수 협력업체) * - 여러 개의 id를 받아 일괄 업데이트 */ export async function updateVendors( @@ -280,3 +305,4 @@ export async function countRfqHistory( return count; } + diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts index 8f095c0e..87a8336d 100644 --- a/lib/vendors/service.ts +++ b/lib/vendors/service.ts @@ -2,12 +2,13 @@ import { revalidateTag, unstable_noStore } from "next/cache"; import db from "@/db/db"; -import { vendorAttachments, VendorContact, vendorContacts, vendorDetailView, vendorInvestigations, vendorInvestigationsView, vendorItemsView, vendorPossibleItems, vendors, type Vendor } from "@/db/schema/vendors"; +import { vendorAttachments, VendorContact, vendorContacts, vendorDetailView, vendorInvestigations, vendorInvestigationsView, vendorItemsView, vendorPossibleItems, vendors, vendorsWithTypesView, vendorTypes, type Vendor } from "@/db/schema/vendors"; import logger from '@/lib/logger'; import { filterColumns } from "@/lib/filter-columns"; import { unstable_cache } from "@/lib/unstable-cache"; import { getErrorMessage } from "@/lib/handle-error"; +import { headers } from 'next/headers'; import { selectVendors, @@ -24,7 +25,10 @@ import { countVendorItems, insertVendorItem, countRfqHistory, - selectRfqHistory + selectRfqHistory, + selectVendorsWithTypes, + countVendorsWithTypes, + } from "./repository"; import type { @@ -51,7 +55,8 @@ import { items } from "@/db/schema/items"; 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"; +import { contracts, contractsDetailView, projects, vendorProjectPQs, vendorsLogs } from "@/db/schema"; +import { Hospital } from "lucide-react"; /* ----------------------------------------------------- @@ -68,61 +73,63 @@ export async function getVendors(input: GetVendorsSchema) { async () => { try { const offset = (input.page - 1) * input.perPage; - - // 1) 고급 필터 + + // 1) 고급 필터 - vendors 대신 vendorsWithTypesView 사용 const advancedWhere = filterColumns({ - table: vendors, + table: vendorsWithTypesView, filters: input.filters, joinOperator: input.joinOperator, }); - + // 2) 글로벌 검색 let globalWhere; if (input.search) { const s = `%${input.search}%`; globalWhere = or( - ilike(vendors.vendorName, s), - ilike(vendors.vendorCode, s), - ilike(vendors.email, s), - ilike(vendors.status, s) + ilike(vendorsWithTypesView.vendorName, s), + ilike(vendorsWithTypesView.vendorCode, s), + ilike(vendorsWithTypesView.email, s), + ilike(vendorsWithTypesView.status, s), + // 추가: 업체 유형 검색 + ilike(vendorsWithTypesView.vendorTypeName, s) ); } - + // 최종 where 결합 const finalWhere = and(advancedWhere, globalWhere); - + // 간단 검색 (advancedTable=false) 시 예시 const simpleWhere = and( input.vendorName - ? ilike(vendors.vendorName, `%${input.vendorName}%`) + ? ilike(vendorsWithTypesView.vendorName, `%${input.vendorName}%`) : undefined, - input.status ? ilike(vendors.status, input.status) : undefined, + input.status ? ilike(vendorsWithTypesView.status, input.status) : undefined, input.country - ? ilike(vendors.country, `%${input.country}%`) + ? ilike(vendorsWithTypesView.country, `%${input.country}%`) : undefined ); - + // 실제 사용될 where const where = finalWhere; - + // 정렬 const orderBy = input.sort.length > 0 ? input.sort.map((item) => - item.desc ? desc(vendors[item.id]) : asc(vendors[item.id]) + item.desc ? desc(vendorsWithTypesView[item.id]) : asc(vendorsWithTypesView[item.id]) ) - : [asc(vendors.createdAt)]; - + : [asc(vendorsWithTypesView.createdAt)]; + // 트랜잭션 내에서 데이터 조회 const { data, total } = await db.transaction(async (tx) => { - // 1) vendor 목록 조회 - const vendorsData = await selectVendors(tx, { + // 1) vendor 목록 조회 (view 사용) + const vendorsData = await selectVendorsWithTypes(tx, { where, orderBy, offset, limit: input.perPage, }); - + // 2) 각 vendor의 attachments 조회 const vendorsWithAttachments = await Promise.all( vendorsData.map(async (vendor) => { @@ -134,7 +141,7 @@ export async function getVendors(input: GetVendorsSchema) { }) .from(vendorAttachments) .where(eq(vendorAttachments.vendorId, vendor.id)); - + return { ...vendor, hasAttachments: attachments.length > 0, @@ -142,17 +149,18 @@ export async function getVendors(input: GetVendorsSchema) { }; }) ); - + // 3) 전체 개수 - const total = await countVendors(tx, where); + const total = await countVendorsWithTypes(tx, where); return { data: vendorsWithAttachments, total }; }); - + // 페이지 수 const pageCount = Math.ceil(total / input.perPage); - + return { data, pageCount }; } catch (err) { + console.error("Error fetching vendors:", err); // 에러 발생 시 return { data: [], pageCount: 0 }; } @@ -165,7 +173,6 @@ export async function getVendors(input: GetVendorsSchema) { )(); } - export async function getVendorStatusCounts() { return unstable_cache( async () => { @@ -252,34 +259,58 @@ async function storeVendorFiles( } } + +export async function getVendorTypes() { + unstable_noStore(); // Next.js server action caching prevention + + try { + const types = await db + .select({ + id: vendorTypes.id, + code: vendorTypes.code, + nameKo: vendorTypes.nameKo, + nameEn: vendorTypes.nameEn, + }) + .from(vendorTypes) + .orderBy(vendorTypes.nameKo); + + return { data: types, error: null }; + } catch (error) { + return { data: null, error: getErrorMessage(error) }; + } +} + export type CreateVendorData = { vendorName: string + vendorTypeId: number vendorCode?: string + items?: string website?: string taxId: string address?: string email: string phone?: string - + representativeName?: string representativeBirth?: string representativeEmail?: string representativePhone?: string - + creditAgency?: string creditRating?: string cashFlowRating?: string corporateRegistrationNumber?: string - + country?: string status?: "PENDING_REVIEW" | "IN_REVIEW" | "IN_PQ" | "PQ_FAILED" | "APPROVED" | "ACTIVE" | "INACTIVE" | "BLACKLISTED" | "PQ_SUBMITTED" } +// Updated createVendor function with taxId duplicate check export async function createVendor(params: { vendorData: CreateVendorData // 기존의 일반 첨부파일 files?: File[] - + // 신용평가 / 현금흐름 등급 첨부 creditRatingFiles?: File[] cashFlowRatingFiles?: File[] @@ -292,17 +323,17 @@ 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 { @@ -310,7 +341,22 @@ export async function createVendor(params: { error: `이미 등록된 이메일입니다. 다른 이메일을 사용해주세요. (Email ${vendorData.email} already exists in the system)` }; } - + + // taxId 중복 검사 추가 + const existingVendor = await db + .select({ id: vendors.id }) + .from(vendors) + .where(eq(vendors.taxId, vendorData.taxId)) + .limit(1); + + // 이미 동일한 taxId를 가진 업체가 존재하면 에러 반환 + if (existingVendor.length > 0) { + return { + data: null, + error: `이미 등록된 사업자등록번호입니다. (Tax ID ${vendorData.taxId} already exists in the system)` + }; + } + await db.transaction(async (tx) => { // 1) Insert the vendor (확장 필드도 함께) const [newVendor] = await insertVendor(tx, { @@ -323,36 +369,38 @@ export async function createVendor(params: { website: vendorData.website || null, status: vendorData.status ?? "PENDING_REVIEW", taxId: vendorData.taxId, - + vendorTypeId: vendorData.vendorTypeId, + items: vendorData.items || 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, }) - + // 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, @@ -364,7 +412,7 @@ export async function createVendor(params: { }) } }) - + revalidateTag("vendors") return { data: null, error: null } } catch (error) { @@ -377,12 +425,26 @@ export async function createVendor(params: { /** 단건 업데이트 */ export async function modifyVendor( - input: UpdateVendorSchema & { id: string } + input: UpdateVendorSchema & { id: string; userId: number; comment:string; } // userId 추가 ) { unstable_noStore(); try { const updated = await db.transaction(async (tx) => { - // 특정 ID 벤더를 업데이트 + // 1. 업데이트 전에 기존 벤더 정보를 가져옴 + const existingVendor = await tx.query.vendors.findFirst({ + where: eq(vendors.id, parseInt(input.id)), + columns: { + status: true, // 상태 변경 로깅에 필요한 현재 상태만 가져옴 + }, + }); + + if (!existingVendor) { + throw new Error(`Vendor with ID ${input.id} not found`); + } + + const oldStatus = existingVendor.status; + + // 2. 벤더 정보 업데이트 const [res] = await updateVendor(tx, input.id, { vendorName: input.vendorName, vendorCode: input.vendorCode, @@ -391,8 +453,32 @@ export async function modifyVendor( phone: input.phone, email: input.email, website: input.website, + creditAgency: input.creditAgency, + creditRating: input.creditRating, + cashFlowRating: input.cashFlowRating, status: input.status, }); + + // 3. 상태가 변경되었다면 로그 기록 + if (oldStatus !== input.status) { + await tx.insert(vendorsLogs).values({ + vendorId: parseInt(input.id), + userId: input.userId, + action: "status_change", + oldStatus, + newStatus: input.status, + comment: input.comment || `Status changed from ${oldStatus} to ${input.status}`, + }); + } else if (input.comment) { + // 상태 변경이 없더라도 코멘트가 있으면 로그 기록 + await tx.insert(vendorsLogs).values({ + vendorId: parseInt(input.id), + userId: input.userId, + action: "vendor_updated", + comment: input.comment, + }); + } + return res; }); @@ -414,7 +500,7 @@ export async function modifyVendors(input: { unstable_noStore(); try { const data = await db.transaction(async (tx) => { - // 여러 벤더 일괄 업데이트 + // 여러 협력업체 일괄 업데이트 const [updated] = await updateVendors(tx, input.ids, { // 예: 상태만 일괄 변경 status: input.status, @@ -560,7 +646,7 @@ export async function createVendorContact(input: CreateVendorContactSchema) { return newContact; }); - // 캐시 무효화 (벤더 연락처 목록 등) + // 캐시 무효화 (협력업체 연락처 목록 등) revalidateTag(`vendor-contacts-${input.vendorId}`); return { data: null, error: null }; @@ -723,7 +809,7 @@ export async function createVendorItem(input: CreateVendorItemSchema) { return newContact; }); - // 캐시 무효화 (벤더 연락처 목록 등) + // 캐시 무효화 (협력업체 연락처 목록 등) revalidateTag(`vendor-items-${input.vendorId}`); return { data: null, error: null }; @@ -885,98 +971,55 @@ interface CreateCompanyInput { /** - * 벤더 첨부파일 다운로드를 위한 서버 액션 - * @param vendorId 벤더 ID + * 협력업체 첨부파일 다운로드를 위한 서버 액션 + * @param vendorId 협력업체 ID * @param fileId 특정 파일 ID (단일 파일 다운로드시) * @returns 다운로드할 수 있는 임시 URL */ -export async function downloadVendorAttachments(vendorId: number, fileId?: number) { +export async function downloadVendorAttachments(vendorId:number, fileId?:number) { try { - // 벤더 정보 조회 - const vendor = await db.query.vendors.findFirst({ - where: eq(vendors.id, vendorId) + // API 경로 생성 (단일 파일 또는 모든 파일) + const url = fileId + ? `/api/vendors/attachments/download?id=${fileId}&vendorId=${vendorId}` + : `/api/vendors/attachments/download-all?vendorId=${vendorId}`; + + // fetch 요청 (기본적으로 Blob으로 응답 받기) + const response = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, }); - - if (!vendor) { - throw new Error(`벤더 정보를 찾을 수 없습니다. (ID: ${vendorId})`); - } - - // 첨부파일 조회 (특정 파일 또는 모든 파일) - const attachments = fileId - ? await db.select() - .from(vendorAttachments) - .where(eq(vendorAttachments.id, fileId)) - : await db.select() - .from(vendorAttachments) - .where(eq(vendorAttachments.vendorId, vendorId)); - - if (!attachments.length) { - throw new Error('다운로드할 첨부파일이 없습니다.'); + + if (!response.ok) { + throw new Error(`Server responded with ${response.status}: ${response.statusText}`); } - - // 업로드 기본 경로 - const basePath = process.env.UPLOAD_DIR || path.join(process.cwd(), 'uploads'); - - // 단일 파일인 경우 직접 URL 반환 - if (attachments.length === 1) { - const attachment = attachments[0]; - const filePath = `/api/vendors/attachments/download?id=${attachment.id}`; - return { url: filePath, fileName: attachment.fileName }; + + // 파일명 가져오기 (Content-Disposition 헤더에서) + const contentDisposition = response.headers.get('content-disposition'); + let fileName = fileId ? `file-${fileId}.zip` : `vendor-${vendorId}-files.zip`; + + if (contentDisposition) { + const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(contentDisposition); + if (matches && matches[1]) { + fileName = matches[1].replace(/['"]/g, ''); + } } - - // 다중 파일: 임시 ZIP 생성 후 URL 반환 - // 임시 디렉토리 생성 - const tempDir = path.join(process.cwd(), 'tmp'); - await fsPromises.mkdir(tempDir, { recursive: true }); - - // 고유 ID로 임시 ZIP 파일명 생성 - const tempId = randomUUID(); - const zipFileName = `${vendor.vendorName || `vendor-${vendorId}`}-attachments-${tempId}.zip`; - const zipFilePath = path.join(tempDir, zipFileName); - - // JSZip을 사용하여 ZIP 파일 생성 - const zip = new JSZip(); - - // 파일 읽기 및 추가 작업을 병렬로 처리 - await Promise.all( - attachments.map(async (attachment) => { - const filePath = path.join(basePath, attachment.filePath); - - try { - // 파일 존재 확인 (fsPromises.access 사용) - try { - await fsPromises.access(filePath, fs.constants.F_OK); - } catch (e) { - console.warn(`파일이 존재하지 않습니다: ${filePath}`); - return; // 파일이 없으면 건너뜀 - } - - // 파일 읽기 (fsPromises.readFile 사용) - const fileData = await fsPromises.readFile(filePath); - - // ZIP에 파일 추가 - zip.file(attachment.fileName, fileData); - } catch (error) { - console.warn(`파일을 처리할 수 없습니다: ${filePath}`, error); - // 오류가 있더라도 계속 진행 - } - }) - ); - - // ZIP 생성 및 저장 - const zipContent = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', compressionOptions: { level: 9 } }); - await fsPromises.writeFile(zipFilePath, zipContent); - - // 임시 ZIP 파일에 접근할 수 있는 URL 생성 - const downloadUrl = `/api/vendors/attachments/download-temp?file=${encodeURIComponent(zipFileName)}`; - - return { - url: downloadUrl, - fileName: `${vendor.vendorName || `vendor-${vendorId}`}-attachments.zip` + + // Blob으로 응답 변환 + const blob = await response.blob(); + + // Blob URL 생성 + const blobUrl = window.URL.createObjectURL(blob); + + return { + url: blobUrl, + fileName, + blob }; } catch (error) { - console.error('첨부파일 다운로드 서버 액션 오류:', error); - throw new Error('첨부파일 다운로드 준비 중 오류가 발생했습니다.'); + console.error('Download API error:', error); + throw error; } } @@ -1016,13 +1059,22 @@ interface ApproveVendorsInput { /** * 선택된 벤더의 상태를 IN_REVIEW로 변경하고 이메일 알림을 발송하는 서버 액션 */ -export async function approveVendors(input: ApproveVendorsInput) { +export async function approveVendors(input: ApproveVendorsInput & { userId: number }) { unstable_noStore(); try { - // 트랜잭션 내에서 벤더 상태 업데이트, 유저 생성 및 이메일 발송 + // 트랜잭션 내에서 협력업체 상태 업데이트, 유저 생성 및 이메일 발송 const result = await db.transaction(async (tx) => { - // 1. 벤더 상태 업데이트 + // 0. 업데이트 전 협력업체 상태 조회 + const vendorsBeforeUpdate = await tx + .select({ + id: vendors.id, + status: vendors.status, + }) + .from(vendors) + .where(inArray(vendors.id, input.ids)); + + // 1. 협력업체 상태 업데이트 const [updated] = await tx .update(vendors) .set({ @@ -1032,7 +1084,7 @@ export async function approveVendors(input: ApproveVendorsInput) { .where(inArray(vendors.id, input.ids)) .returning(); - // 2. 업데이트된 벤더 정보 조회 + // 2. 업데이트된 협력업체 정보 조회 const updatedVendors = await tx .select({ id: vendors.id, @@ -1067,18 +1119,35 @@ export async function approveVendors(input: ApproveVendorsInput) { }) ); - // 4. 각 벤더에게 이메일 발송 + // 4. 로그 기록 + await Promise.all( + vendorsBeforeUpdate.map(async (vendorBefore) => { + await tx.insert(vendorsLogs).values({ + vendorId: vendorBefore.id, + userId: input.userId, + action: "status_change", + oldStatus: vendorBefore.status, + newStatus: "IN_REVIEW", + comment: "Vendor approved for review", + }); + }) + ); + + // 5. 각 벤더에게 이메일 발송 await Promise.all( updatedVendors.map(async (vendor) => { if (!vendor.email) return; // 이메일이 없으면 스킵 try { - const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기 + const userLang = "en"; // 기본값, 필요시 협력업체 언어 설정에서 가져오기 const subject = "[eVCP] Admin Account Created"; - const loginUrl = "http://3.36.56.124:3000/en/login"; + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + const baseUrl = `http://${host}` + const loginUrl = `${baseUrl}/en/login`; await sendEmail({ to: vendor.email, @@ -1112,7 +1181,7 @@ export async function approveVendors(input: ApproveVendorsInput) { } } -export async function requestPQVendors(input: ApproveVendorsInput) { +export async function requestPQVendors(input: ApproveVendorsInput & { userId: number }) { unstable_noStore(); try { @@ -1134,9 +1203,18 @@ export async function requestPQVendors(input: ApproveVendorsInput) { } } - // 트랜잭션 내에서 벤더 상태 업데이트 및 이메일 발송 + // 트랜잭션 내에서 협력업체 상태 업데이트 및 이메일 발송 const result = await db.transaction(async (tx) => { - // 1. 벤더 상태 업데이트 + // 0. 업데이트 전 협력업체 상태 조회 + const vendorsBeforeUpdate = await tx + .select({ + id: vendors.id, + status: vendors.status, + }) + .from(vendors) + .where(inArray(vendors.id, input.ids)); + + // 1. 협력업체 상태 업데이트 const [updated] = await tx .update(vendors) .set({ @@ -1146,7 +1224,7 @@ export async function requestPQVendors(input: ApproveVendorsInput) { .where(inArray(vendors.id, input.ids)) .returning(); - // 2. 업데이트된 벤더 정보 조회 + // 2. 업데이트된 협력업체 정보 조회 const updatedVendors = await tx .select({ id: vendors.id, @@ -1169,14 +1247,33 @@ export async function requestPQVendors(input: ApproveVendorsInput) { await tx.insert(vendorProjectPQs).values(vendorProjectPQsData); } - - // 4. 각 벤더에게 이메일 발송 + + // 4. 로그 기록 + await Promise.all( + vendorsBeforeUpdate.map(async (vendorBefore) => { + await tx.insert(vendorsLogs).values({ + vendorId: vendorBefore.id, + userId: input.userId, + action: "status_change", + oldStatus: vendorBefore.status, + newStatus: "IN_PQ", + comment: input.projectId + ? `Project PQ requested (Project: ${projectInfo?.projectCode || input.projectId})` + : "PQ requested", + }); + }) + ); + + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + + // 5. 각 벤더에게 이메일 발송 await Promise.all( updatedVendors.map(async (vendor) => { if (!vendor.email) return; // 이메일이 없으면 스킵 try { - const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기 + const userLang = "en"; // 기본값, 필요시 협력업체 언어 설정에서 가져오기 // 프로젝트 PQ인지 일반 PQ인지에 따라 제목 변경 const subject = input.projectId @@ -1184,7 +1281,7 @@ export async function requestPQVendors(input: ApproveVendorsInput) { : "[eVCP] You are invited to submit PQ"; // 로그인 URL에 프로젝트 ID 추가 (프로젝트 PQ인 경우) - const baseLoginUrl = "http://3.36.56.124:3000/en/login"; + const baseLoginUrl = `${host}/partners/pq`; const loginUrl = input.projectId ? `${baseLoginUrl}?projectId=${input.projectId}` : baseLoginUrl; @@ -1192,7 +1289,8 @@ export async function requestPQVendors(input: ApproveVendorsInput) { await sendEmail({ to: vendor.email, subject, - template: input.projectId ? "project-pq" : "pq", // 프로젝트별 템플릿 사용 + template:input.projectId ? "project-pq" : "pq", // 프로젝트별 템플릿 사용 + // template: "vendor-pq-status", // 프로젝트별 템플릿 사용 context: { vendorName: vendor.vendorName, loginUrl, @@ -1225,21 +1323,20 @@ export async function requestPQVendors(input: ApproveVendorsInput) { return { data: null, error: getErrorMessage(err) }; } } - interface SendVendorsInput { ids: number[]; } /** - * APPROVED 상태인 벤더 정보를 기간계 시스템에 전송하고 벤더 코드를 업데이트하는 서버 액션 + * APPROVED 상태인 협력업체 정보를 기간계 시스템에 전송하고 협력업체 코드를 업데이트하는 서버 액션 */ -export async function sendVendors(input: SendVendorsInput) { +export async function sendVendors(input: SendVendorsInput & { userId: number }) { unstable_noStore(); try { // 트랜잭션 내에서 진행 const result = await db.transaction(async (tx) => { - // 1. 선택된 벤더 중 APPROVED 상태인 벤더만 필터링 + // 1. 선택된 협력업체 중 APPROVED 상태인 벤더만 필터링 const approvedVendors = await db.query.vendors.findMany({ where: and( inArray(vendors.id, input.ids), @@ -1255,16 +1352,16 @@ export async function sendVendors(input: SendVendorsInput) { // 2. 각 벤더에 대해 처리 for (const vendor of approvedVendors) { - // 2-1. 벤더 연락처 정보 조회 + // 2-1. 협력업체 연락처 정보 조회 const contacts = await db.query.vendorContacts.findMany({ where: eq(vendorContacts.vendorId, vendor.id) }); - // 2-2. 벤더 가능 아이템 조회 + // 2-2. 협력업체 가능 아이템 조회 const possibleItems = await db.query.vendorPossibleItems.findMany({ where: eq(vendorPossibleItems.vendorId, vendor.id) }); - // 2-3. 벤더 첨부파일 조회 + // 2-3. 협력업체 첨부파일 조회 const attachments = await db.query.vendorAttachments.findMany({ where: eq(vendorAttachments.vendorId, vendor.id), columns: { @@ -1274,8 +1371,7 @@ export async function sendVendors(input: SendVendorsInput) { } }); - - // 2-4. 벤더 정보를 기간계 시스템에 전송 (NextJS API 라우트 사용) + // 2-4. 협력업체 정보를 기간계 시스템에 전송 (NextJS API 라우트 사용) const vendorData = { id: vendor.id, vendorName: vendor.vendorName, @@ -1311,7 +1407,7 @@ export async function sendVendors(input: SendVendorsInput) { throw new Error(`Invalid response from ERP system for vendor ${vendor.id}`); } - // 2-5. 벤더 코드 및 상태 업데이트 + // 2-5. 협력업체 코드 및 상태 업데이트 const vendorCode = responseData.vendorCode; const [updated] = await tx @@ -1324,16 +1420,27 @@ export async function sendVendors(input: SendVendorsInput) { .where(eq(vendors.id, vendor.id)) .returning(); - // 2-6. 벤더에게 알림 이메일 발송 + // 2-6. 로그 기록 + await tx.insert(vendorsLogs).values({ + vendorId: vendor.id, + userId: input.userId, + action: "status_change", + oldStatus: "APPROVED", + newStatus: "ACTIVE", + comment: `Sent to ERP system. Vendor code assigned: ${vendorCode}`, + }); + + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + + // 2-7. 벤더에게 알림 이메일 발송 if (vendor.email) { - const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기 + const userLang = "en"; // 기본값, 필요시 협력업체 언어 설정에서 가져오기 const subject = "[eVCP] Vendor Registration Completed"; - const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000' - - const portalUrl = `${baseUrl}/en/partners`; + const portalUrl = `http://${host}/en/partners`; await sendEmail({ to: vendor.email, @@ -1355,12 +1462,20 @@ export async function sendVendors(input: SendVendorsInput) { message: "Successfully sent to ERP system", }); } catch (vendorError) { - // 개별 벤더 처리 오류 기록 + // 개별 협력업체 처리 오류 기록 results.push({ id: vendor.id, success: false, error: getErrorMessage(vendorError), }); + + // 에러가 발생해도 로그는 기록 + await tx.insert(vendorsLogs).values({ + vendorId: vendor.id, + userId: input.userId, + action: "erp_send_failed", + comment: `Failed to send to ERP: ${getErrorMessage(vendorError)}`, + }); } } @@ -1387,55 +1502,68 @@ export async function sendVendors(input: SendVendorsInput) { } } - interface RequestInfoProps { ids: number[]; + userId: number; // 추가: 어떤 사용자가 요청했는지 로깅하기 위함 } -export async function requestInfo({ ids }: RequestInfoProps) { +export async function requestInfo({ ids, userId }: RequestInfoProps) { try { - // 1. 벤더 정보 가져오기 - const vendorList = await db.query.vendors.findMany({ - where: inArray(vendors.id, ids), - }); - - if (!vendorList.length) { - return { error: "벤더 정보를 찾을 수 없습니다." }; - } + return await db.transaction(async (tx) => { + // 1. 협력업체 정보 가져오기 + const vendorList = await tx.query.vendors.findMany({ + where: inArray(vendors.id, ids), + }); - // 2. 각 벤더에게 이메일 보내기 - for (const vendor of vendorList) { - // 이메일이 없는 경우 스킵 - if (!vendor.email) continue; + if (!vendorList.length) { + return { error: "협력업체 정보를 찾을 수 없습니다." }; + } - // 벤더 정보 페이지 URL 생성 - const vendorInfoUrl = `${process.env.NEXT_PUBLIC_APP_URL}/partners/info?vendorId=${vendor.id}`; + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + + // 2. 각 벤더에 대한 로그 기록 및 이메일 발송 + for (const vendor of vendorList) { + // 로그 기록 + await tx.insert(vendorsLogs).values({ + vendorId: vendor.id, + userId: userId, + action: "info_requested", + comment: "추가 정보 요청됨", + }); - // 벤더에게 이메일 보내기 - await sendEmail({ - to: vendor.email, - subject: "[EVCP] 추가 정보 요청 / Additional Information Request", - template: "vendor-additional-info", - context: { - vendorName: vendor.vendorName, - vendorInfoUrl: vendorInfoUrl, - language: "ko", // 기본 언어 설정, 벤더의 선호 언어가 있다면 그것을 사용할 수 있음 - }, - }); - } + // 이메일이 없는 경우 스킵 + if (!vendor.email) continue; + + // 협력업체 정보 페이지 URL 생성 + const vendorInfoUrl = `http://${host}/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 }; + // 3. 성공적으로 처리됨 + return { success: true }; + }); } catch (error) { - console.error("벤더 정보 요청 중 오류 발생:", error); - return { error: "벤더 정보 요청 중 오류가 발생했습니다. 다시 시도해 주세요." }; + console.error("협력업체 정보 요청 중 오류 발생:", error); + return { error: "협력업체 정보 요청 중 오류가 발생했습니다. 다시 시도해 주세요." }; } } export async function getVendorDetailById(id: number) { try { - // View를 통해 벤더 정보 조회 + // View를 통해 협력업체 정보 조회 const vendor = await db .select() .from(vendorDetailView) @@ -1496,7 +1624,7 @@ export type ContactInfo = { } /** - * 벤더 정보를 업데이트하는 함수 + * 협력업체 정보를 업데이트하는 함수 */ export async function updateVendorInfo(params: { vendorData: UpdateVendorInfoData @@ -1533,7 +1661,7 @@ export async function updateVendorInfo(params: { // 트랜잭션으로 업데이트 수행 await db.transaction(async (tx) => { - // 1. 벤더 정보 업데이트 + // 1. 협력업체 정보 업데이트 await tx.update(vendors).set({ vendorName: vendorData.vendorName, address: vendorData.address || null, @@ -1672,7 +1800,7 @@ export async function updateVendorInfo(params: { return { data: { success: true, - message: '벤더 정보가 성공적으로 업데이트되었습니다.', + message: '협력업체 정보가 성공적으로 업데이트되었습니다.', vendorId: vendorData.id }, error: null @@ -1681,4 +1809,205 @@ export async function updateVendorInfo(params: { console.error("Vendor info update error:", error); return { data: null, error: getErrorMessage(error) } } +} + + + +export interface VendorsLogWithUser { + id: number + vendorCandidateId: number + userId: number + userName: string | null + userEmail: string | null + action: string + oldStatus: string | null + newStatus: string | null + comment: string | null + createdAt: Date +} + +export async function getVendorLogs(vendorId: number): Promise<VendorsLogWithUser[]> { + try { + const logs = await db + .select({ + id: vendorsLogs.id, + vendorCandidateId: vendorsLogs.vendorId, + userId: vendorsLogs.userId, + action: vendorsLogs.action, + oldStatus: vendorsLogs.oldStatus, + newStatus: vendorsLogs.newStatus, + comment: vendorsLogs.comment, + createdAt: vendorsLogs.createdAt, + + // 조인한 users 테이블 필드 + userName: users.name, + userEmail: users.email, + }) + .from(vendorsLogs) + .leftJoin(users, eq(vendorsLogs.userId, users.id)) + .where(eq(vendorsLogs.vendorId, vendorId)) + .orderBy(desc(vendorsLogs.createdAt)) + + return logs + } catch (error) { + console.error("Failed to fetch candidate logs with user info:", error) + throw error + } +} + + + +/** + * 엑셀 내보내기용 벤더 연락처 목록 조회 + * - 페이지네이션 없이 모든 연락처 반환 + */ +export async function exportVendorContacts(vendorId: number) { + try { + const contacts = await db + .select() + .from(vendorContacts) + .where(eq(vendorContacts.vendorId, vendorId)) + .orderBy(vendorContacts.isPrimary, vendorContacts.contactName); + + return contacts; + } catch (error) { + console.error("Failed to export vendor contacts:", error); + return []; + } +} + +/** + * 엑셀 내보내기용 벤더 아이템 목록 조회 + * - 페이지네이션 없이 모든 아이템 정보 반환 + */ +export async function exportVendorItems(vendorId: number) { + try { + const vendorItems = await db + .select({ + id: vendorItemsView.vendorItemId, + vendorId: vendorItemsView.vendorId, + itemName: vendorItemsView.itemName, + itemCode: vendorItemsView.itemCode, + description: vendorItemsView.description, + createdAt: vendorItemsView.createdAt, + updatedAt: vendorItemsView.updatedAt, + }) + .from(vendorItemsView) + .where(eq(vendorItemsView.vendorId, vendorId)) + .orderBy(vendorItemsView.itemName); + + return vendorItems; + } catch (error) { + console.error("Failed to export vendor items:", error); + return []; + } +} + +/** + * 엑셀 내보내기용 벤더 RFQ 목록 조회 + * - 페이지네이션 없이 모든 RFQ 정보 반환 + */ +export async function exportVendorRFQs(vendorId: number) { + try { + const rfqs = await db + .select() + .from(vendorRfqView) + .where(eq(vendorRfqView.vendorId, vendorId)) + .orderBy(vendorRfqView.rfqVendorUpdated); + + return rfqs; + } catch (error) { + console.error("Failed to export vendor RFQs:", error); + return []; + } +} + +/** + * 엑셀 내보내기용 벤더 계약 목록 조회 + * - 페이지네이션 없이 모든 계약 정보 반환 + */ +export async function exportVendorContracts(vendorId: number) { + try { + const contracts = await db + .select() + .from(contractsDetailView) + .where(eq(contractsDetailView.vendorId, vendorId)) + .orderBy(contractsDetailView.createdAt); + + return contracts; + } catch (error) { + console.error("Failed to export vendor contracts:", error); + return []; + } +} + +/** + * 엑셀 내보내기용 벤더 정보 조회 + * - 페이지네이션 없이 모든 벤더 정보 반환 + */ +export async function exportVendorDetails(vendorIds: number[]) { + try { + if (!vendorIds.length) return []; + + // 벤더 기본 정보 조회 + const vendorsData = await db + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + taxId: vendors.taxId, + address: vendors.address, + country: vendors.country, + phone: vendors.phone, + email: vendors.email, + website: vendors.website, + status: vendors.status, + representativeName: vendors.representativeName, + representativeBirth: vendors.representativeBirth, + representativeEmail: vendors.representativeEmail, + representativePhone: vendors.representativePhone, + corporateRegistrationNumber: vendors.corporateRegistrationNumber, + creditAgency: vendors.creditAgency, + creditRating: vendors.creditRating, + cashFlowRating: vendors.cashFlowRating, + createdAt: vendors.createdAt, + updatedAt: vendors.updatedAt, + }) + .from(vendors) + .where( + vendorIds.length === 1 + ? eq(vendors.id, vendorIds[0]) + : inArray(vendors.id, vendorIds) + ); + + // 벤더별 상세 정보를 포함하여 반환 + const vendorsWithDetails = await Promise.all( + vendorsData.map(async (vendor) => { + // 연락처 조회 + const contacts = await exportVendorContacts(vendor.id); + + // 아이템 조회 + const items = await exportVendorItems(vendor.id); + + // RFQ 조회 + const rfqs = await exportVendorRFQs(vendor.id); + + // 계약 조회 + const contracts = await exportVendorContracts(vendor.id); + + return { + ...vendor, + vendorContacts: contacts, + vendorItems: items, + vendorRfqs: rfqs, + vendorContracts: contracts, + }; + }) + ); + + return vendorsWithDetails; + } catch (error) { + console.error("Failed to export vendor details:", error); + return []; + } }
\ No newline at end of file diff --git a/lib/vendors/table/approve-vendor-dialog.tsx b/lib/vendors/table/approve-vendor-dialog.tsx index 253c2830..9c175dc5 100644 --- a/lib/vendors/table/approve-vendor-dialog.tsx +++ b/lib/vendors/table/approve-vendor-dialog.tsx @@ -29,6 +29,7 @@ import { } from "@/components/ui/drawer" import { Vendor } from "@/db/schema/vendors" import { approveVendors } from "../service" +import { useSession } from "next-auth/react" interface ApprovalVendorDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { @@ -45,11 +46,19 @@ export function ApproveVendorsDialog({ }: ApprovalVendorDialogProps) { const [isApprovePending, startApproveTransition] = React.useTransition() const isDesktop = useMediaQuery("(min-width: 640px)") + const { data: session } = useSession() function onApprove() { + if (!session?.user?.id) { + toast.error("사용자 인증 정보를 찾을 수 없습니다.") + return + } + startApproveTransition(async () => { const { error } = await approveVendors({ ids: vendors.map((vendor) => vendor.id), + userId: Number(session.user.id) + }) if (error) { @@ -70,7 +79,7 @@ export function ApproveVendorsDialog({ <DialogTrigger asChild> <Button variant="outline" size="sm" className="gap-2"> <Check className="size-4" aria-hidden="true" /> - Approve ({vendors.length}) + 가입 Approve ({vendors.length}) </Button> </DialogTrigger> ) : null} diff --git a/lib/vendors/table/request-additional-Info-dialog.tsx b/lib/vendors/table/request-additional-Info-dialog.tsx index 872162dd..2e39a527 100644 --- a/lib/vendors/table/request-additional-Info-dialog.tsx +++ b/lib/vendors/table/request-additional-Info-dialog.tsx @@ -29,6 +29,7 @@ import { } from "@/components/ui/drawer" import { Vendor } from "@/db/schema/vendors" import { requestInfo } from "../service" +import { useSession } from "next-auth/react" interface RequestInfoDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { @@ -45,11 +46,18 @@ export function RequestInfoDialog({ }: RequestInfoDialogProps) { const [isRequestPending, startRequestTransition] = React.useTransition() const isDesktop = useMediaQuery("(min-width: 640px)") + const { data: session } = useSession() function onApprove() { + if (!session?.user?.id) { + toast.error("사용자 인증 정보를 찾을 수 없습니다.") + return + } startRequestTransition(async () => { const { error, success } = await requestInfo({ ids: vendors.map((vendor) => vendor.id), + userId: Number(session.user.id) + }) if (error) { @@ -58,7 +66,7 @@ export function RequestInfoDialog({ } props.onOpenChange?.(false) - toast.success("추가 정보 요청이 성공적으로 벤더에게 발송되었습니다.") + toast.success("추가 정보 요청이 성공적으로 협력업체에게 발송되었습니다.") onSuccess?.() }) } @@ -76,12 +84,12 @@ export function RequestInfoDialog({ ) : null} <DialogContent> <DialogHeader> - <DialogTitle>벤더 추가 정보 요청 확인</DialogTitle> + <DialogTitle>협력업체 추가 정보 요청 확인</DialogTitle> <DialogDescription> <span className="font-medium">{vendors.length}</span> - {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 추가 정보를 요청하시겠습니까? + {vendors.length === 1 ? "개의 협력업체" : "개의 협력업체들"}에게 추가 정보를 요청하시겠습니까? <br /><br /> - 요청시 벤더에게 이메일이 발송되며, 벤더는 별도 페이지에서 신용 평가 및 현금 흐름 정보와 같은 + 요청시 협력업체에게 이메일이 발송되며, 협력업체는 별도 페이지에서 신용 평가 및 현금 흐름 정보와 같은 추가 정보를 입력하게 됩니다. </DialogDescription> </DialogHeader> @@ -121,12 +129,12 @@ export function RequestInfoDialog({ ) : null} <DrawerContent> <DrawerHeader> - <DrawerTitle>벤더 추가 정보 요청 확인</DrawerTitle> + <DrawerTitle>협력업체 추가 정보 요청 확인</DrawerTitle> <DrawerDescription> <span className="font-medium">{vendors.length}</span> - {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 추가 정보를 요청하시겠습니까? + {vendors.length === 1 ? "개의 협력업체" : "개의 협력업체들"}에게 추가 정보를 요청하시겠습니까? <br /><br /> - 요청시 벤더에게 이메일이 발송되며, 벤더는 별도 페이지에서 신용 평가 및 현금 흐름 정보와 같은 + 요청시 협력업체에게 이메일이 발송되며, 협력업체는 별도 페이지에서 신용 평가 및 현금 흐름 정보와 같은 추가 정보를 입력하게 됩니다. </DrawerDescription> </DrawerHeader> diff --git a/lib/vendors/table/request-basicContract-dialog.tsx b/lib/vendors/table/request-basicContract-dialog.tsx new file mode 100644 index 00000000..8d05fbbe --- /dev/null +++ b/lib/vendors/table/request-basicContract-dialog.tsx @@ -0,0 +1,548 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Send, AlertCircle, Clock, RefreshCw } 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 { Checkbox } from "@/components/ui/checkbox" +import { Badge } from "@/components/ui/badge" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import { Vendor } from "@/db/schema/vendors" +import { useSession } from "next-auth/react" +import { getAllTemplates } from "@/lib/basic-contract/service" +import { useState, useEffect } from "react" +import { requestBasicContractInfo } from "@/lib/basic-contract/service" +import { checkContractRequestStatus } from "@/lib/basic-contract/service" +import { BasicContractTemplate } from "@/db/schema" + +interface RequestInfoDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + vendors: Row<Vendor>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +// 계약 요청 상태 인터페이스 +interface VendorTemplateStatus { + vendorId: number; + vendorName: string; + templateId: number; + templateName: string; + status: string; + createdAt: Date; + completedAt?: Date; // 계약 체결 날짜 추가 + isExpired: boolean; // 요청 만료 (30일) + isUpdated: boolean; // 템플릿 업데이트 여부 + isContractExpired: boolean; // 계약 유효기간 만료 여부 (1년) 추가 +} +export function RequestContractDialog({ + vendors, + showTrigger = true, + onSuccess, + ...props +}: RequestInfoDialogProps) { + const [isRequestPending, startRequestTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + const { data: session } = useSession() + const [templates, setTemplates] = useState<BasicContractTemplate[]>([]) + const [selectedTemplateIds, setSelectedTemplateIds] = useState<number[]>([]) + const [isLoading, setIsLoading] = useState(false) + const [statusLoading, setStatusLoading] = useState(false) + const [statusData, setStatusData] = useState<VendorTemplateStatus[]>([]) + const [forceResend, setForceResend] = useState<Set<string>>(new Set()) + + // 템플릿 및 상태 로드 + useEffect(() => { + loadTemplatesAndStatus(); + }, [vendors]); + + // 템플릿과 현재 요청 상태를 로드하는 함수 + const loadTemplatesAndStatus = async () => { + console.log("loadTemplatesAndStatus") + setIsLoading(true); + setStatusLoading(true); + + try { + // 1. 템플릿 로드 + const allTemplates = await getAllTemplates(); + const activeTemplates = allTemplates.filter(t => t.status === 'ACTIVE'); + setTemplates(activeTemplates); + + // 기본 템플릿 선택 설정 + const allActiveTemplateIds = activeTemplates.map(t => t.id); + setSelectedTemplateIds(allActiveTemplateIds); + + // 2. 현재 계약 요청 상태 확인 + if (vendors.length > 0 && allActiveTemplateIds.length > 0) { + const vendorIds = vendors.map(v => v.id); + const { data } = await checkContractRequestStatus(vendorIds, allActiveTemplateIds); + setStatusData(data || []); + } + } catch (error) { + console.error("데이터 로딩 오류:", error); + toast.error("템플릿 또는 상태 정보를 불러오는데 실패했습니다."); + } finally { + setIsLoading(false); + setStatusLoading(false); + } + }; + + // 체크박스 상태 변경 핸들러 + const handleTemplateToggle = (templateId: number, checked: boolean) => { + if (checked) { + setSelectedTemplateIds(prev => [...prev, templateId]); + } else { + setSelectedTemplateIds(prev => prev.filter(id => id !== templateId)); + } + }; + + // 강제 재전송 토글 + const toggleForceResend = (vendorId: number, templateId: number) => { + const key = `${vendorId}-${templateId}`; + const newForceResend = new Set(forceResend); + + if (newForceResend.has(key)) { + newForceResend.delete(key); + } else { + newForceResend.add(key); + } + + setForceResend(newForceResend); + }; + + const renderStatusBadge = (vendorId: number, templateId: number) => { + const status = statusData.find( + s => s.vendorId === vendorId && s.templateId === templateId + ); + + if (!status || status.status === "NONE") return null; + + // 상태에 따른 배지 스타일 설정 + let badgeVariant = "outline"; + let badgeLabel = ""; + let icon = null; + let tooltip = ""; + + switch (status.status) { + case "PENDING": + badgeVariant = "secondary"; + badgeLabel = "대기중"; + + if (status.isExpired) { + icon = <Clock className="h-3 w-3 mr-1" />; + tooltip = "요청이 만료되었습니다. 재전송이 필요합니다."; + } else if (status.isUpdated) { + icon = <RefreshCw className="h-3 w-3 mr-1" />; + tooltip = "템플릿이 업데이트되었습니다. 재전송이 필요합니다."; + } else { + tooltip = "서명 요청이 진행 중입니다."; + } + break; + + case "COMPLETED": + // 계약 유효기간 만료 확인 + if (status.isContractExpired) { + badgeVariant = "warning"; // 경고 스타일 적용 + badgeLabel = "재계약 필요"; + icon = <Clock className="h-3 w-3 mr-1" />; + tooltip = "계약 유효기간이 만료되었습니다. 재계약이 필요합니다."; + } else { + badgeVariant = "success"; + badgeLabel = "완료됨"; + tooltip = "이미 서명이 완료되었습니다."; + } + break; + + case "REJECTED": + badgeVariant = "destructive"; + badgeLabel = "거부됨"; + tooltip = "협력업체가 서명을 거부했습니다."; + break; + } + + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Badge variant={badgeVariant as any} className="ml-2 text-xs"> + {icon} + {badgeLabel} + </Badge> + </TooltipTrigger> + <TooltipContent> + <p>{tooltip}</p> + + {/* 재전송 조건에 계약 유효기간 만료 추가 */} + {(status.isExpired || status.isUpdated || status.status === "REJECTED" || + (status.status === "COMPLETED" && status.isContractExpired)) && ( + <p className="text-xs mt-1"> + <Button + variant="link" + size="sm" + className="h-4 p-0" + onClick={() => toggleForceResend(vendorId, templateId)} + > + {forceResend.has(`${vendorId}-${templateId}`) ? "재전송 취소" : "재전송 하기"} + </Button> + </p> + )} + </TooltipContent> + </Tooltip> + </TooltipProvider> + ); + }; + + // 유효한 요청인지 확인 함수 개선 + const isValidRequest = (vendorId: number, templateId: number) => { + const status = statusData.find( + s => s.vendorId === vendorId && s.templateId === templateId + ); + + if (!status || status.status === "NONE") return true; + + // 만료되었거나 템플릿이 업데이트되었거나 거부된 경우 재전송 가능 + // 계약 유효기간 만료도 조건에 추가 + if (status.isExpired || + status.isUpdated || + status.status === "REJECTED" || + (status.status === "COMPLETED" && status.isContractExpired)) { + return forceResend.has(`${vendorId}-${templateId}`); + } + + // PENDING(비만료) 또는 COMPLETED(유효기간 내)는 재전송 불가 + return false; + }; + + + // 요청 발송 처리 + function onApprove() { + if (!session?.user?.id) { + toast.error("사용자 인증 정보를 찾을 수 없습니다.") + return + } + + if (selectedTemplateIds.length === 0) { + toast.error("최소 하나 이상의 계약서 템플릿을 선택해주세요.") + return + } + + // 모든 협력업체-템플릿 조합 생성 + const validRequests: { vendorId: number, templateId: number }[] = []; + const skippedRequests: { vendorId: number, templateId: number, reason: string }[] = []; + + vendors.forEach(vendor => { + selectedTemplateIds.forEach(templateId => { + if (isValidRequest(vendor.id, templateId)) { + validRequests.push({ + vendorId: vendor.id, + templateId + }); + } else { + // 유효하지 않은 요청은 건너뜀 + const status = statusData.find( + s => s.vendorId === vendor.id && s.templateId === templateId + ); + + let reason = "알 수 없음"; + if (status) { + if (status.status === "PENDING") reason = "이미 대기 중"; + if (status.status === "COMPLETED") reason = "이미 완료됨"; + } + + skippedRequests.push({ + vendorId: vendor.id, + templateId, + reason + }); + } + }); + }); + + if (validRequests.length === 0) { + toast.error("전송 가능한 요청이 없습니다. 재전송이 필요한 항목을 '재전송 하기' 버튼으로 활성화하세요."); + return; + } + + startRequestTransition(async () => { + // 유효한 요청만 처리 + const requests = validRequests.map(req => + requestBasicContractInfo({ + vendorIds: [req.vendorId], + requestedBy: Number(session.user.id), + templateId: req.templateId + }) + ); + + try { + const results = await Promise.all(requests); + + // 오류 확인 + const errors = results.filter(r => r.error); + if (errors.length > 0) { + toast.error(`${errors.length}개의 요청에서 오류가 발생했습니다.`); + return; + } + + // 상태 메시지 생성 + let successMessage = "기본계약서 서명 요청이 성공적으로 발송되었습니다."; + if (skippedRequests.length > 0) { + successMessage += ` (${skippedRequests.length}개 요청 건너뜀)`; + } + + props.onOpenChange?.(false); + toast.success(successMessage); + onSuccess?.(); + } catch (error) { + console.error("요청 처리 중 오류:", error); + toast.error("서명 요청 처리 중 오류가 발생했습니다."); + } + }); + } + + // 선택된 템플릿 수 표시 + const selectedCount = selectedTemplateIds.length; + const totalCount = templates.length; + + // UI 렌더링 + const renderTemplateList = () => ( + <div className="space-y-3"> + {templates.map((template) => ( + <div key={template.id} className="pb-2 border-b last:border-b-0"> + <div className="flex items-center justify-between"> + <div className="flex items-center space-x-2"> + <Checkbox + id={`template-${template.id}`} + checked={selectedTemplateIds.includes(template.id)} + onCheckedChange={(checked) => handleTemplateToggle(template.id, checked as boolean)} + /> + <label + htmlFor={`template-${template.id}`} + className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer" + > + {template.templateName} + </label> + + {/* 상태 배지를 템플릿 이름 옆에 나란히 배치 */} + {vendors.length === 1 && renderStatusBadge(vendors[0].id, template.id)} + </div> + + + {vendors.length === 1 && (() => { + const status = statusData.find( + s => s.vendorId === vendors[0].id && s.templateId === template.id + ); + + // 계약 유효기간 만료 조건 추가 + if (status && (status.isExpired || + status.isUpdated || + status.status === "REJECTED" || + (status.status === "COMPLETED" && status.isContractExpired))) { + const key = `${vendors[0].id}-${template.id}`; + + // 계약 유효기간 만료인 경우 다른 텍스트 표시 + const buttonText = status.status === "COMPLETED" && status.isContractExpired + ? (forceResend.has(key) ? "재계약 취소" : "재계약하기") + : (forceResend.has(key) ? "재전송 취소" : "재전송하기"); + + return ( + <Button + variant="ghost" + size="sm" + className="h-7 px-2 text-xs" + onClick={() => toggleForceResend(vendors[0].id, template.id)} + > + {buttonText} + </Button> + ); + } + return null; + })()} + + </div> + + {/* 추가 정보 표시 (파일명 등) */} + <div className="mt-1 pl-6 text-xs text-muted-foreground"> + {template.fileName} + </div> + </div> + ))} + </div> + ); + + // 내용 영역 렌더링 + const renderContentArea = () => ( + <div className="space-y-4"> + <div className="space-y-2"> + <div className="flex justify-between items-center"> + <h3 className="text-sm font-medium">계약서 템플릿 선택</h3> + <span className="text-xs text-muted-foreground"> + {selectedCount}/{totalCount} 선택됨 + </span> + </div> + + {isLoading ? ( + <div className="flex items-center justify-center py-4"> + <Loader className="size-4 animate-spin mr-2" /> + <span>템플릿 로딩 중...</span> + </div> + ) : templates.length === 0 ? ( + <div className="text-sm text-muted-foreground p-2 border rounded-md"> + 활성 상태의 템플릿이 없습니다. 템플릿을 먼저 등록해주세요. + </div> + ) : ( + // ScrollArea 대신 네이티브 스크롤 사용 + <div className="border rounded-md p-3 overflow-y-auto h-[200px]"> + {renderTemplateList()} + </div> + )} + </div> + + {statusLoading && ( + <div className="flex items-center text-sm text-muted-foreground"> + <Loader className="size-3 animate-spin mr-2" /> + <span>계약 상태 확인 중...</span> + </div> + )} + + {/* 선택된 템플릿 정보 (ScrollArea 대신 네이티브 스크롤 사용) */} + {selectedTemplateIds.length > 0 && ( + <div className="space-y-2 text-sm"> + <h3 className="font-medium">선택된 템플릿 정보</h3> + <div className="overflow-y-auto max-h-[150px] border rounded-md p-2"> + <div className="space-y-2"> + {selectedTemplateIds.map(id => { + const template = templates.find(t => t.id === id); + if (!template) return null; + + return ( + <div key={id} className="p-2 border rounded-md bg-muted/50"> + <p><span className="font-medium">이름:</span> {template.templateName}</p> + <p><span className="font-medium">파일:</span> {template.fileName}</p> + </div> + ); + })} + </div> + </div> + </div> + )} + + <div className="text-sm text-muted-foreground mt-4"> + 요청시 협력업체에게 이메일이 발송되며, 협력업체는 별도 페이지에서 기본계약서와 기타 관련 서류들에 대해서 서명을 하게 됩니다. + </div> + </div> + ); + + 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 className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle>협력업체 기본계약서 요청 확인</DialogTitle> + <DialogDescription> + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? "개의 협력업체" : "개의 협력업체들"}에게 기본계약서 서명을 요청하시겠습니까? + </DialogDescription> + </DialogHeader> + + <div className="py-4"> + {renderContentArea()} + </div> + + <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 || isLoading || selectedTemplateIds.length === 0} + > + {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 ? "개의 협력업체" : "개의 협력업체들"}에게 기본계약서 서명을 요청하시겠습니까? + </DrawerDescription> + </DrawerHeader> + + <div className="px-4"> + {renderContentArea()} + </div> + + <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 || isLoading || selectedTemplateIds.length === 0} + > + {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 index c590d7ec..a9fe0e1a 100644 --- a/lib/vendors/table/request-project-pq-dialog.tsx +++ b/lib/vendors/table/request-project-pq-dialog.tsx @@ -44,6 +44,7 @@ import { Label } from "@/components/ui/label" import { Vendor } from "@/db/schema/vendors" import { requestPQVendors } from "../service" import { getProjects, type Project } from "@/lib/rfqs/service" +import { useSession } from "next-auth/react" interface RequestProjectPQDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { @@ -63,6 +64,7 @@ export function RequestProjectPQDialog({ const [projects, setProjects] = React.useState<Project[]>([]) const [selectedProjectId, setSelectedProjectId] = React.useState<number | null>(null) const [isLoadingProjects, setIsLoadingProjects] = React.useState(false) + const { data: session } = useSession() // 프로젝트 목록 로드 React.useEffect(() => { @@ -95,15 +97,23 @@ export function RequestProjectPQDialog({ } function onApprove() { + if (!selectedProjectId) { toast.error("프로젝트를 선택해주세요.") return } + if (!session?.user?.id) { + toast.error("사용자 인증 정보를 찾을 수 없습니다.") + return + } + startApproveTransition(async () => { const { error } = await requestPQVendors({ ids: vendors.map((vendor) => vendor.id), projectId: selectedProjectId, + userId: Number(session.user.id) + }) if (error) { @@ -113,7 +123,7 @@ export function RequestProjectPQDialog({ props.onOpenChange?.(false) - toast.success(`벤더에게 프로젝트 PQ가 성공적으로 요청되었습니다.`) + toast.success(`협력업체에게 프로젝트 PQ가 성공적으로 요청되었습니다.`) onSuccess?.() }) } @@ -165,8 +175,8 @@ export function RequestProjectPQDialog({ <DialogTitle>프로젝트 PQ 요청 확인</DialogTitle> <DialogDescription> <span className="font-medium">{vendors.length}</span> - {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 프로젝트 PQ 제출을 요청하시겠습니까? - 요청을 보내면 벤더에게 알림이 발송되고 프로젝트 PQ 정보를 입력할 수 있게 됩니다. + {vendors.length === 1 ? "개의 협력업체" : "개의 협력업체들"}에게 프로젝트 PQ 제출을 요청하시겠습니까? + 요청을 보내면 협력업체에게 알림이 발송되고 프로젝트 PQ 정보를 입력할 수 있게 됩니다. </DialogDescription> </DialogHeader> @@ -177,7 +187,7 @@ export function RequestProjectPQDialog({ <Button variant="outline">취소</Button> </DialogClose> <Button - aria-label="선택한 벤더에게 요청하기" + aria-label="선택한 협력업체에게 요청하기" variant="default" onClick={onApprove} disabled={isApprovePending || !selectedProjectId} @@ -211,8 +221,8 @@ export function RequestProjectPQDialog({ <DrawerTitle>프로젝트 PQ 요청 확인</DrawerTitle> <DrawerDescription> <span className="font-medium">{vendors.length}</span> - {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 프로젝트 PQ 제출을 요청하시겠습니까? - 요청을 보내면 벤더에게 알림이 발송되고 프로젝트 PQ 정보를 입력할 수 있게 됩니다. + {vendors.length === 1 ? "개의 협력업체" : "개의 협력업체들"}에게 프로젝트 PQ 제출을 요청하시겠습니까? + 요청을 보내면 협력업체에게 알림이 발송되고 프로젝트 PQ 정보를 입력할 수 있게 됩니다. </DrawerDescription> </DrawerHeader> @@ -225,7 +235,7 @@ export function RequestProjectPQDialog({ <Button variant="outline">취소</Button> </DrawerClose> <Button - aria-label="선택한 벤더에게 요청하기" + aria-label="선택한 협력업체에게 요청하기" variant="default" onClick={onApprove} disabled={isApprovePending || !selectedProjectId} diff --git a/lib/vendors/table/request-vendor-investigate-dialog.tsx b/lib/vendors/table/request-vendor-investigate-dialog.tsx index 0309ee4a..b3deafce 100644 --- a/lib/vendors/table/request-vendor-investigate-dialog.tsx +++ b/lib/vendors/table/request-vendor-investigate-dialog.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { type Row } from "@tanstack/react-table" -import { Loader, Check, SendHorizonal } from "lucide-react" +import { Loader, Check, SendHorizonal, AlertCircle, AlertTriangle } from "lucide-react" import { toast } from "sonner" import { useMediaQuery } from "@/hooks/use-media-query" @@ -27,8 +27,29 @@ import { DrawerTitle, DrawerTrigger, } from "@/components/ui/drawer" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Badge } from "@/components/ui/badge" +import { Separator } from "@/components/ui/separator" + import { Vendor } from "@/db/schema/vendors" -import { requestInvestigateVendors } from "@/lib/vendor-investigation/service" +import { requestInvestigateVendors, getExistingInvestigationsForVendors } from "@/lib/vendor-investigation/service" +import { useSession } from "next-auth/react" +import { formatDate } from "@/lib/utils" interface ApprovalVendorDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { @@ -37,21 +58,98 @@ interface ApprovalVendorDialogProps onSuccess?: () => void } +// Helper function to get status badge variant and text +function getStatusBadge(status: string) { + switch (status) { + case "REQUESTED": + return { variant: "secondary", text: "Requested" } + case "SCHEDULED": + return { variant: "warning", text: "Scheduled" } + case "IN_PROGRESS": + return { variant: "default", text: "In Progress" } + case "COMPLETED": + return { variant: "success", text: "Completed" } + case "CANCELLED": + return { variant: "destructive", text: "Cancelled" } + default: + return { variant: "outline", text: status } + } +} + export function RequestVendorsInvestigateDialog({ vendors, showTrigger = true, onSuccess, ...props }: ApprovalVendorDialogProps) { - - console.log(vendors) const [isApprovePending, startApproveTransition] = React.useTransition() + const [isLoading, setIsLoading] = React.useState(true) + const [existingInvestigations, setExistingInvestigations] = React.useState<any[]>([]) const isDesktop = useMediaQuery("(min-width: 640px)") + const { data: session } = useSession() + + // Fetch existing investigations when dialog opens + React.useEffect(() => { + if (vendors.length > 0) { + setIsLoading(true) + const fetchExistingInvestigations = async () => { + try { + const vendorIds = vendors.map(vendor => vendor.id) + const result = await getExistingInvestigationsForVendors(vendorIds) + setExistingInvestigations(result) + } catch (error) { + console.error("Failed to fetch existing investigations:", error) + toast.error("Failed to fetch existing investigations") + } finally { + setIsLoading(false) + } + } + + fetchExistingInvestigations() + } + }, [vendors]) + + // Group vendors by investigation status + const vendorsWithInvestigations = React.useMemo(() => { + if (!existingInvestigations.length) return { withInvestigations: [], withoutInvestigations: vendors } + + const vendorMap = new Map(vendors.map(v => [v.id, v])) + const withInvestigations: Array<{ vendor: typeof vendors[0], investigation: any }> = [] + + // Find vendors with existing investigations + existingInvestigations.forEach(inv => { + const vendor = vendorMap.get(inv.vendorId) + if (vendor) { + withInvestigations.push({ vendor, investigation: inv }) + vendorMap.delete(inv.vendorId) + } + }) + + // Remaining vendors don't have investigations + const withoutInvestigations = Array.from(vendorMap.values()) + + return { withInvestigations, withoutInvestigations } + }, [vendors, existingInvestigations]) function onApprove() { + if (!session?.user?.id) { + toast.error("사용자 인증 정보를 찾을 수 없습니다.") + return + } + + // Only request investigations for vendors without existing ones + const vendorsToRequest = vendorsWithInvestigations.withoutInvestigations + + if (vendorsToRequest.length === 0) { + toast.info("모든 선택된 업체에 이미 실사 요청이 있습니다.") + props.onOpenChange?.(false) + return + } + startApproveTransition(async () => { const { error } = await requestInvestigateVendors({ - ids: vendors.map((vendor) => vendor.id), + ids: vendorsToRequest.map((vendor) => vendor.id), + userId: Number(session.user.id) }) if (error) { @@ -60,11 +158,102 @@ export function RequestVendorsInvestigateDialog({ } props.onOpenChange?.(false) - toast.success("Vendor Investigation successfully sent to 벤더실사담당자") + toast.success(`${vendorsToRequest.length}개 업체에 대한 실사 요청을 보냈습니다.`) onSuccess?.() }) } + const renderContent = () => { + return ( + <> + <div className="space-y-4"> + {isLoading ? ( + <div className="flex items-center justify-center py-4"> + <Loader className="size-6 animate-spin text-muted-foreground" /> + </div> + ) : ( + <> + {vendorsWithInvestigations.withInvestigations.length > 0 && ( + <Alert> + <AlertTriangle className="h-4 w-4" /> + <AlertTitle>기존 실사 요청 정보가 있습니다</AlertTitle> + <AlertDescription> + 선택한 {vendors.length}개 업체 중 {vendorsWithInvestigations.withInvestigations.length}개 업체에 대한 + 기존 실사 요청이 있습니다. 새로운 요청은 기존 데이터가 없는 업체에만 적용됩니다. + </AlertDescription> + </Alert> + )} + + {vendorsWithInvestigations.withInvestigations.length > 0 && ( + <Accordion type="single" collapsible className="w-full"> + <AccordionItem value="existing-investigations"> + <AccordionTrigger className="font-medium"> + 기존 실사 요청 ({vendorsWithInvestigations.withInvestigations.length}) + </AccordionTrigger> + <AccordionContent> + <ScrollArea className="max-h-[200px]"> + <Table> + <TableHeader> + <TableRow> + <TableHead>업체명</TableHead> + <TableHead>상태</TableHead> + <TableHead>요청일</TableHead> + <TableHead>예정 일정</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {vendorsWithInvestigations.withInvestigations.map(({ vendor, investigation }) => { + const status = getStatusBadge(investigation.investigationStatus) + return ( + <TableRow key={investigation.investigationId}> + <TableCell className="font-medium">{vendor.vendorName}</TableCell> + <TableCell> + <Badge variant={status.variant as any}>{status.text}</Badge> + </TableCell> + <TableCell>{formatDate(investigation.createdAt)}</TableCell> + <TableCell> + {investigation.scheduledStartAt + ? formatDate(investigation.scheduledStartAt) + : "미정"} + </TableCell> + </TableRow> + ) + })} + </TableBody> + </Table> + </ScrollArea> + </AccordionContent> + </AccordionItem> + </Accordion> + )} + + <div> + <h3 className="text-sm font-medium mb-2"> + 새로운 실사가 요청될 업체 ({vendorsWithInvestigations.withoutInvestigations.length}) + </h3> + {vendorsWithInvestigations.withoutInvestigations.length > 0 ? ( + <ScrollArea className="max-h-[200px]"> + <ul className="space-y-1"> + {vendorsWithInvestigations.withoutInvestigations.map((vendor) => ( + <li key={vendor.id} className="text-sm py-1 px-2 border-b"> + {vendor.vendorName} ({vendor.vendorCode || "코드 없음"}) + </li> + ))} + </ul> + </ScrollArea> + ) : ( + <p className="text-sm text-muted-foreground py-2"> + 모든 선택된 업체에 이미 실사 요청이 있습니다. + </p> + )} + </div> + </> + )} + </div> + </> + ) + } + if (isDesktop) { return ( <Dialog {...props}> @@ -72,29 +261,30 @@ export function RequestVendorsInvestigateDialog({ <DialogTrigger asChild> <Button variant="outline" size="sm" className="gap-2"> <SendHorizonal className="size-4" aria-hidden="true" /> - Vendor Investigation Request ({vendors.length}) + 실사 요청 ({vendors.length}) </Button> </DialogTrigger> ) : null} - <DialogContent> + <DialogContent className="max-w-md"> <DialogHeader> - <DialogTitle>Confirm Vendor Investigation Requst</DialogTitle> + <DialogTitle>Confirm Vendor Investigation Request</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. + 선택한 {vendors.length}개 업체에 대한 실사 요청을 확인합니다. + 요청 후 협력업체실사담당자에게 알림이 전송됩니다. </DialogDescription> </DialogHeader> - <DialogFooter className="gap-2 sm:space-x-0"> + + {renderContent()} + + <DialogFooter className="gap-2 sm:space-x-0 mt-4"> <DialogClose asChild> - <Button variant="outline">Cancel</Button> + <Button variant="outline">취소</Button> </DialogClose> <Button aria-label="Request selected vendors" variant="default" onClick={onApprove} - disabled={isApprovePending} + disabled={isApprovePending || isLoading || vendorsWithInvestigations.withoutInvestigations.length === 0} > {isApprovePending && ( <Loader @@ -102,7 +292,7 @@ export function RequestVendorsInvestigateDialog({ aria-hidden="true" /> )} - Request + 요청하기 </Button> </DialogFooter> </DialogContent> @@ -124,26 +314,29 @@ export function RequestVendorsInvestigateDialog({ <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. + 선택한 {vendors.length}개 업체에 대한 실사 요청을 확인합니다. + 요청 후 협력업체실사담당자에게 알림이 전송됩니다. </DrawerDescription> </DrawerHeader> - <DrawerFooter className="gap-2 sm:space-x-0"> + + <div className="px-4"> + {renderContent()} + </div> + + <DrawerFooter className="gap-2 sm:space-x-0 mt-4"> <DrawerClose asChild> - <Button variant="outline">Cancel</Button> + <Button variant="outline">취소</Button> </DrawerClose> <Button aria-label="Request selected vendors" variant="default" onClick={onApprove} - disabled={isApprovePending} + disabled={isApprovePending || isLoading || vendorsWithInvestigations.withoutInvestigations.length === 0} > {isApprovePending && ( <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> )} - Request + 요청하기 </Button> </DrawerFooter> </DrawerContent> diff --git a/lib/vendors/table/request-vendor-pg-dialog.tsx b/lib/vendors/table/request-vendor-pg-dialog.tsx index de23ad9b..4bc4e909 100644 --- a/lib/vendors/table/request-vendor-pg-dialog.tsx +++ b/lib/vendors/table/request-vendor-pg-dialog.tsx @@ -29,6 +29,7 @@ import { } from "@/components/ui/drawer" import { Vendor } from "@/db/schema/vendors" import { requestPQVendors } from "../service" +import { useSession } from "next-auth/react" interface ApprovalVendorDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { @@ -45,11 +46,21 @@ export function RequestPQVendorsDialog({ }: ApprovalVendorDialogProps) { const [isApprovePending, startApproveTransition] = React.useTransition() const isDesktop = useMediaQuery("(min-width: 640px)") + const { data: session } = useSession() function onApprove() { + + if (!session?.user?.id) { + toast.error("사용자 인증 정보를 찾을 수 없습니다.") + return + } + + startApproveTransition(async () => { const { error } = await requestPQVendors({ ids: vendors.map((vendor) => vendor.id), + userId: Number(session.user.id) + }) if (error) { diff --git a/lib/vendors/table/update-vendor-sheet.tsx b/lib/vendors/table/update-vendor-sheet.tsx index e65c4b1c..08994b6a 100644 --- a/lib/vendors/table/update-vendor-sheet.tsx +++ b/lib/vendors/table/update-vendor-sheet.tsx @@ -3,7 +3,25 @@ import * as React from "react" import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" -import { Loader } from "lucide-react" +import { + Loader, + Activity, + AlertCircle, + AlertTriangle, + ClipboardList, + FilePenLine, + XCircle, + ClipboardCheck, + FileCheck2, + FileX2, + BadgeCheck, + CheckCircle2, + Circle as CircleIcon, + User, + Building, + AlignLeft, + Calendar +} from "lucide-react" import { toast } from "sonner" import { Button } from "@/components/ui/button" @@ -14,6 +32,7 @@ import { FormItem, FormLabel, FormMessage, + FormDescription } from "@/components/ui/form" import { Input } from "@/components/ui/input" import { @@ -33,27 +52,143 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" +import { useSession } from "next-auth/react" // Import useSession -import { Vendor } from "@/db/schema/vendors" +import { VendorWithType, vendors } from "@/db/schema/vendors" import { updateVendorSchema, type UpdateVendorSchema } from "../validations" import { modifyVendor } from "../service" -// 예: import { modifyVendor } from "@/lib/vendors/service" interface UpdateVendorSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { - vendor: Vendor | null + vendor: VendorWithType | null +} +type StatusType = (typeof vendors.status.enumValues)[number]; + +type StatusConfig = { + Icon: React.ElementType; + className: string; + label: string; +}; + +// 상태 표시 유틸리티 함수 +const getStatusConfig = (status: StatusType): StatusConfig => { + switch(status) { + case "PENDING_REVIEW": + return { + Icon: ClipboardList, + className: "text-yellow-600", + label: "가입 신청 중" + }; + case "IN_REVIEW": + return { + Icon: FilePenLine, + className: "text-blue-600", + label: "심사 중" + }; + case "REJECTED": + return { + Icon: XCircle, + className: "text-red-600", + label: "심사 거부됨" + }; + case "IN_PQ": + return { + Icon: ClipboardCheck, + className: "text-purple-600", + label: "PQ 진행 중" + }; + case "PQ_SUBMITTED": + return { + Icon: FileCheck2, + className: "text-indigo-600", + label: "PQ 제출" + }; + case "PQ_FAILED": + return { + Icon: FileX2, + className: "text-red-600", + label: "PQ 실패" + }; + case "PQ_APPROVED": + return { + Icon: BadgeCheck, + className: "text-green-600", + label: "PQ 통과" + }; + case "APPROVED": + return { + Icon: CheckCircle2, + className: "text-green-600", + label: "승인됨" + }; + case "READY_TO_SEND": + return { + Icon: CheckCircle2, + className: "text-emerald-600", + label: "MDG 송부대기" + }; + case "ACTIVE": + return { + Icon: Activity, + className: "text-emerald-600", + label: "활성 상태" + }; + case "INACTIVE": + return { + Icon: AlertCircle, + className: "text-gray-600", + label: "비활성 상태" + }; + case "BLACKLISTED": + return { + Icon: AlertTriangle, + className: "text-slate-800", + label: "거래 금지" + }; + default: + return { + Icon: CircleIcon, + className: "text-gray-600", + label: status + }; + } +}; + +// 신용평가기관 목록 +const creditAgencies = [ + { value: "NICE", label: "NICE평가정보" }, + { value: "KIS", label: "KIS (한국신용평가)" }, + { value: "KED", label: "KED (한국기업데이터)" }, + { value: "SCI", label: "SCI평가정보" }, +] + +// 신용등급 스케일 +const creditRatingScaleMap: Record<string, string[]> = { + NICE: ["AAA", "AA", "A", "BBB", "BB", "B", "C", "D"], + KIS: ["AAA", "AA+", "AA", "A+", "A", "BBB+", "BBB", "BB", "B", "C"], + KED: ["AAA", "AA", "A", "BBB", "BB", "B", "CCC", "CC", "C", "D"], + SCI: ["AAA", "AA+", "AA", "AA-", "A+", "A", "A-", "BBB+", "BBB-", "B"], +} + +// 현금흐름등급 스케일 +const cashFlowRatingScaleMap: Record<string, string[]> = { + NICE: ["우수", "양호", "보통", "미흡", "불량"], + KIS: ["A+", "A", "B+", "B", "C", "D"], + KED: ["1등급", "2등급", "3등급", "4등급", "5등급"], + SCI: ["Level 1", "Level 2", "Level 3", "Level 4"], } // 폼 컴포넌트 export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) { const [isPending, startTransition] = React.useTransition() + const [selectedAgency, setSelectedAgency] = React.useState<string>(vendor?.creditAgency || "NICE") - console.log(vendor) - - // RHF + Zod + // 폼 정의 - UpdateVendorSchema 타입을 직접 사용 const form = useForm<UpdateVendorSchema>({ resolver: zodResolver(updateVendorSchema), defaultValues: { + // 업체 기본 정보 vendorName: vendor?.vendorName ?? "", vendorCode: vendor?.vendorCode ?? "", address: vendor?.address ?? "", @@ -61,7 +196,18 @@ export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) phone: vendor?.phone ?? "", email: vendor?.email ?? "", website: vendor?.website ?? "", + creditRating: vendor?.creditRating ?? "", + cashFlowRating: vendor?.cashFlowRating ?? "", status: vendor?.status ?? "ACTIVE", + vendorTypeId: vendor?.vendorTypeId ?? undefined, + + // 구매담당자 정보 (기본값은 비어있음) + buyerName: "", + buyerDepartment: "", + contractStartDate: undefined, + contractEndDate: undefined, + internalNotes: "", + // evaluationScore: "", }, }) @@ -75,191 +221,439 @@ export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) phone: vendor?.phone ?? "", email: vendor?.email ?? "", website: vendor?.website ?? "", + creditRating: vendor?.creditRating ?? "", + cashFlowRating: vendor?.cashFlowRating ?? "", status: vendor?.status ?? "ACTIVE", + vendorTypeId: vendor?.vendorTypeId ?? undefined, + + // 구매담당자 필드는 유지 + buyerName: form.getValues("buyerName"), + buyerDepartment: form.getValues("buyerDepartment"), + contractStartDate: form.getValues("contractStartDate"), + contractEndDate: form.getValues("contractEndDate"), + internalNotes: form.getValues("internalNotes"), + // evaluationScore: form.getValues("evaluationScore"), }); } }, [vendor, form]); - console.log(form.getValues()) + // 신용평가기관 변경 시 등급 필드를 초기화하는 효과 + React.useEffect(() => { + // 선택된 평가기관에 따라 현재 선택된 등급이 유효한지 확인 + const currentCreditRating = form.getValues("creditRating"); + const currentCashFlowRating = form.getValues("cashFlowRating"); + + // 선택된 기관에 따른 유효한 등급 목록 + const validCreditRatings = creditRatingScaleMap[selectedAgency] || []; + const validCashFlowRatings = cashFlowRatingScaleMap[selectedAgency] || []; + + // 현재 등급이 유효하지 않으면 초기화 + if (currentCreditRating && !validCreditRatings.includes(currentCreditRating)) { + form.setValue("creditRating", ""); + } + + if (currentCashFlowRating && !validCashFlowRatings.includes(currentCashFlowRating)) { + form.setValue("cashFlowRating", ""); + } + + // 신용평가기관 필드 업데이트 + if(selectedAgency){ + form.setValue("creditAgency", selectedAgency as "NICE" | "KIS" | "KED" | "SCI"); + } + + }, [selectedAgency, form]); + // 제출 핸들러 async function onSubmit(data: UpdateVendorSchema) { if (!vendor) return + const { data: session } = useSession() - startTransition(async () => { - // 서버 액션 or API - // const { error } = await modifyVendor({ id: vendor.id, ...data }) - // 여기선 간단 예시 - try { - // 예시: - const { error } = await modifyVendor({ id: String(vendor.id), ...data }) - if (error) throw new Error(error) - - toast.success("Vendor updated!") - form.reset() - props.onOpenChange?.(false) - } catch (err: any) { - toast.error(String(err)) - } - }) - } + if (!session?.user?.id) { + toast.error("사용자 인증 정보를 찾을 수 없습니다.") + return + } + startTransition(async () => { + try { + // Add status change comment if status has changed + const oldStatus = vendor.status ?? "ACTIVE" // Default to ACTIVE if undefined + const newStatus = data.status ?? "ACTIVE" // Default to ACTIVE if undefined + + const statusComment = + oldStatus !== newStatus + ? `상태 변경: ${getStatusConfig(oldStatus).label} → ${getStatusConfig(newStatus).label}` + : "" // Empty string instead of undefined + + // 업체 정보 업데이트 - userId와 상태 변경 코멘트 추가 + const { error } = await modifyVendor({ + id: String(vendor.id), + userId: Number(session.user.id), // Add user ID from session + comment: statusComment, // Add comment for status changes + ...data // 모든 데이터 전달 - 서비스 함수에서 필요한 필드만 처리 + }) + + if (error) throw new Error(error) + + toast.success("업체 정보가 업데이트되었습니다!") + form.reset() + props.onOpenChange?.(false) + } catch (err: any) { + toast.error(String(err)) + } + }) +} return ( <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetContent className="flex flex-col gap-6 sm:max-w-lg overflow-y-auto"> <SheetHeader className="text-left"> - <SheetTitle>Update Vendor</SheetTitle> + <SheetTitle>업체 정보 수정</SheetTitle> <SheetDescription> - Update the vendor details and save the changes + 업체 세부 정보를 수정하고 변경 사항을 저장하세요 </SheetDescription> </SheetHeader> <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> - {/* vendorName */} - <FormField - control={form.control} - name="vendorName" - render={({ field }) => ( - <FormItem> - <FormLabel>Vendor Name</FormLabel> - <FormControl> - <Input placeholder="Vendor Name" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6"> + {/* 업체 기본 정보 섹션 */} + <div className="space-y-4"> + <div className="flex items-center"> + <Building className="mr-2 h-5 w-5 text-muted-foreground" /> + <h3 className="text-sm font-medium">업체 기본 정보</h3> + </div> + <FormDescription> + 업체가 제공한 기본 정보입니다. 필요시 수정하세요. + </FormDescription> + <div className="grid grid-cols-1 gap-4 md:grid-cols-2"> + {/* vendorName */} + <FormField + control={form.control} + name="vendorName" + render={({ field }) => ( + <FormItem> + <FormLabel>업체명</FormLabel> + <FormControl> + <Input placeholder="업체명 입력" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* vendorCode */} + <FormField + control={form.control} + name="vendorCode" + render={({ field }) => ( + <FormItem> + <FormLabel>업체 코드</FormLabel> + <FormControl> + <Input placeholder="예: ABC123" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* address */} + <FormField + control={form.control} + name="address" + render={({ field }) => ( + <FormItem className="md:col-span-2"> + <FormLabel>주소</FormLabel> + <FormControl> + <Input placeholder="주소 입력" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* country */} + <FormField + control={form.control} + name="country" + render={({ field }) => ( + <FormItem> + <FormLabel>국가</FormLabel> + <FormControl> + <Input placeholder="예: 대한민국" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* phone */} + <FormField + control={form.control} + name="phone" + render={({ field }) => ( + <FormItem> + <FormLabel>전화번호</FormLabel> + <FormControl> + <Input placeholder="예: 010-1234-5678" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* email */} + <FormField + control={form.control} + name="email" + render={({ field }) => ( + <FormItem> + <FormLabel>이메일</FormLabel> + <FormControl> + <Input placeholder="예: info@company.com" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* website */} + <FormField + control={form.control} + name="website" + render={({ field }) => ( + <FormItem> + <FormLabel>웹사이트</FormLabel> + <FormControl> + <Input placeholder="예: https://www.company.com" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> - {/* vendorCode */} - <FormField - control={form.control} - name="vendorCode" - render={({ field }) => ( - <FormItem> - <FormLabel>Vendor Code</FormLabel> - <FormControl> - <Input placeholder="Code123" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + {/* status with icons */} + <FormField + control={form.control} + name="status" + render={({ field }) => { + // 현재 선택된 상태의 구성 정보 가져오기 + const selectedConfig = getStatusConfig(field.value ?? "ACTIVE"); + const SelectedIcon = selectedConfig?.Icon || CircleIcon; - {/* address */} - <FormField - control={form.control} - name="address" - render={({ field }) => ( - <FormItem> - <FormLabel>Address</FormLabel> - <FormControl> - <Input placeholder="123 Main St" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + return ( + <FormItem> + <FormLabel>업체승인상태</FormLabel> + <FormControl> + <Select + value={field.value} + onValueChange={field.onChange} + > + <SelectTrigger className="w-full"> + <SelectValue> + {field.value && ( + <div className="flex items-center"> + <SelectedIcon className={`mr-2 h-4 w-4 ${selectedConfig.className}`} /> + <span>{selectedConfig.label}</span> + </div> + )} + </SelectValue> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {vendors.status.enumValues.map((status) => { + const config = getStatusConfig(status); + const StatusIcon = config.Icon; + return ( + <SelectItem key={status} value={status}> + <div className="flex items-center"> + <StatusIcon className={`mr-2 h-4 w-4 ${config.className}`} /> + <span>{config.label}</span> + </div> + </SelectItem> + ); + })} + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> - {/* country */} - <FormField - control={form.control} - name="country" - render={({ field }) => ( - <FormItem> - <FormLabel>Country</FormLabel> - <FormControl> - <Input placeholder="USA" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + + {/* 신용평가기관 선택 */} + <FormField + control={form.control} + name="creditAgency" + render={({ field }) => ( + <FormItem> + <FormLabel>신용평가기관</FormLabel> + <FormControl> + <Select + value={field.value || ""} + onValueChange={(value) => { + field.onChange(value); + setSelectedAgency(value); + }} + > + <SelectTrigger className="w-full"> + <SelectValue placeholder="평가기관 선택" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {creditAgencies.map((agency) => ( + <SelectItem key={agency.value} value={agency.value}> + {agency.label} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 평가년도 - 나중에 추가 가능 */} + + {/* 신용등급 - 선택된 기관에 따라 옵션 변경 */} + <FormField + control={form.control} + name="creditRating" + render={({ field }) => ( + <FormItem> + <FormLabel>신용등급</FormLabel> + <FormControl> + <Select + value={field.value || ""} + onValueChange={field.onChange} + disabled={!selectedAgency} + > + <SelectTrigger className="w-full"> + <SelectValue placeholder="신용등급 선택" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {(creditRatingScaleMap[selectedAgency] || []).map((rating) => ( + <SelectItem key={rating} value={rating}> + {rating} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 현금흐름등급 - 선택된 기관에 따라 옵션 변경 */} + <FormField + control={form.control} + name="cashFlowRating" + render={({ field }) => ( + <FormItem> + <FormLabel>현금흐름등급</FormLabel> + <FormControl> + <Select + value={field.value || ""} + onValueChange={field.onChange} + disabled={!selectedAgency} + > + <SelectTrigger className="w-full"> + <SelectValue placeholder="현금흐름등급 선택" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {(cashFlowRatingScaleMap[selectedAgency] || []).map((rating) => ( + <SelectItem key={rating} value={rating}> + {rating} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> - {/* phone */} - <FormField - control={form.control} - name="phone" - render={({ field }) => ( - <FormItem> - <FormLabel>Phone</FormLabel> - <FormControl> - <Input placeholder="+1 555-1234" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + </div> + </div> - {/* email */} - <FormField - control={form.control} - name="email" - render={({ field }) => ( - <FormItem> - <FormLabel>Email</FormLabel> - <FormControl> - <Input placeholder="vendor@example.com" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + {/* 구분선 */} + <Separator className="my-2" /> - {/* website */} - <FormField - control={form.control} - name="website" - render={({ field }) => ( - <FormItem> - <FormLabel>Website</FormLabel> - <FormControl> - <Input placeholder="https://www.vendor.com" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + {/* 구매담당자 입력 섹션 */} + <div className="space-y-4 bg-slate-50 p-4 rounded-md border border-slate-200"> + <div className="flex items-center"> + <User className="mr-2 h-5 w-5 text-blue-600" /> + <h3 className="text-sm font-medium text-blue-800">구매담당자 정보</h3> + </div> + <FormDescription> + 구매담당자가 관리하는 추가 정보입니다. 이 정보는 내부용으로만 사용됩니다. + </FormDescription> + + <div className="grid grid-cols-1 gap-4 md:grid-cols-2"> + {/* 여기에 구매담당자 필드 추가 */} + <FormField + control={form.control} + name="buyerName" + render={({ field }) => ( + <FormItem> + <FormLabel>담당자 이름</FormLabel> + <FormControl> + <Input placeholder="담당자 이름" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="buyerDepartment" + render={({ field }) => ( + <FormItem> + <FormLabel>담당 부서</FormLabel> + <FormControl> + <Input placeholder="예: 구매부" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> - {/* status */} - <FormField - control={form.control} - name="status" - render={({ field }) => ( - <FormItem> - <FormLabel>Status</FormLabel> - <FormControl> - <Select - value={field.value} - onValueChange={field.onChange} - > - <SelectTrigger className="capitalize"> - <SelectValue placeholder="Select a status" /> - </SelectTrigger> - <SelectContent> - <SelectGroup> - {/* enum ["ACTIVE","INACTIVE","BLACKLISTED"] */} - <SelectItem value="ACTIVE">ACTIVE</SelectItem> - <SelectItem value="INACTIVE">INACTIVE</SelectItem> - <SelectItem value="BLACKLISTED">BLACKLISTED</SelectItem> - </SelectGroup> - </SelectContent> - </Select> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + + <FormField + control={form.control} + name="internalNotes" + render={({ field }) => ( + <FormItem className="md:col-span-2"> + <FormLabel>내부 메모</FormLabel> + <FormControl> + <Input placeholder="내부 참고사항을 입력하세요" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> <SheetFooter className="gap-2 pt-2 sm:space-x-0"> <SheetClose asChild> <Button type="button" variant="outline"> - Cancel + 취소 </Button> </SheetClose> <Button disabled={isPending}> {isPending && ( <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> )} - Save + 저장 </Button> </SheetFooter> </form> diff --git a/lib/vendors/table/vendor-all-export.ts b/lib/vendors/table/vendor-all-export.ts new file mode 100644 index 00000000..cef801fd --- /dev/null +++ b/lib/vendors/table/vendor-all-export.ts @@ -0,0 +1,486 @@ +// /lib/vendor-export.ts +import ExcelJS from "exceljs" +import { VendorWithType } from "@/db/schema/vendors" +import { exportVendorDetails } from "../service"; + +// 연락처 인터페이스 정의 +interface VendorContact { + contactName: string; + contactPosition?: string | null; + contactEmail: string; + contactPhone?: string | null; + isPrimary: boolean; +} + +// 아이템 인터페이스 정의 +interface VendorItem { + itemCode: string; + itemName: string; + description?: string | null; + createdAt?: Date | string; +} + +// RFQ 인터페이스 정의 +interface VendorRFQ { + rfqNumber: string; + title: string; + status: string; + requestDate?: Date | string | null; + dueDate?: Date | string | null; + description?: string | null; +} + +// 계약 인터페이스 정의 +interface VendorContract { + projectCode: string; + projectName: string; + contractNo: string; + contractName: string; + status: string; + paymentTerms: string; + deliveryTerms: string; + deliveryDate: Date | string; + deliveryLocation: string; + startDate?: Date | string | null; + endDate?: Date | string | null; + currency: string; + totalAmount?: number | null; +} + +// 서비스에서 반환하는 실제 데이터 구조 +interface VendorData { + id: number; + vendorName: string; + vendorCode: string | null; + taxId: string; + address: string | null; + country: string | null; + phone: string | null; + email: string | null; + website: string | null; + status: string; + representativeName: string | null; + representativeBirth: string | null; + representativeEmail: string | null; + representativePhone: string | null; + corporateRegistrationNumber: string | null; + creditAgency: string | null; + creditRating: string | null; + cashFlowRating: string | null; +// items: string | null; + createdAt: Date; + updatedAt: Date; + vendorContacts: VendorContact[]; + vendorItems: VendorItem[]; + vendorRfqs: VendorRFQ[]; + vendorContracts: VendorContract[]; +} + +/** + * 선택된 벤더의 모든 관련 정보를 통합 시트 형식으로 엑셀로 내보내는 함수 + * - 기본정보 시트 + * - 연락처 시트 + * - 아이템 시트 + * - RFQ 시트 + * - 계약 시트 + * 각 시트에는 식별을 위한 벤더 코드, 벤더명, 세금ID가 포함됨 + */ +export async function exportVendorsWithRelatedData( + vendors: VendorWithType[], + filename = "vendors-detailed" +): Promise<void> { + if (!vendors.length) return; + + // 선택된 벤더 ID 목록 + const vendorIds = vendors.map(vendor => vendor.id); + + try { + // 서버로부터 모든 관련 데이터 가져오기 + const vendorsWithDetails = await exportVendorDetails(vendorIds); + + if (!vendorsWithDetails.length) { + throw new Error("내보내기 데이터를 가져오는 중 오류가 발생했습니다."); + } + + // 워크북 생성 + const workbook = new ExcelJS.Workbook(); + + // 데이터 타입 확인 (서비스에서 반환하는 실제 데이터 형태) + const vendorData = vendorsWithDetails as unknown as any[]; + + // ===== 1. 기본 정보 시트 ===== + createBasicInfoSheet(workbook, vendorData); + + // ===== 2. 연락처 시트 ===== + createContactsSheet(workbook, vendorData); + + // ===== 3. 아이템 시트 ===== + createItemsSheet(workbook, vendorData); + + // ===== 4. RFQ 시트 ===== + createRFQsSheet(workbook, vendorData); + + // ===== 5. 계약 시트 ===== + createContractsSheet(workbook, vendorData); + + // 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${filename}-${new Date().toISOString().split("T")[0]}.xlsx`; + link.click(); + URL.revokeObjectURL(url); + + return; + } catch (error) { + console.error("Export error:", error); + throw error; + } +} + +// 기본 정보 시트 생성 함수 +function createBasicInfoSheet( + workbook: ExcelJS.Workbook, + vendors: VendorData[] +): void { + const basicInfoSheet = workbook.addWorksheet("기본정보"); + + // 기본 정보 시트 헤더 설정 + basicInfoSheet.columns = [ + { header: "업체코드", key: "vendorCode", width: 15 }, + { header: "업체명", key: "vendorName", width: 20 }, + { header: "세금ID", key: "taxId", width: 15 }, + { header: "국가", key: "country", width: 10 }, + { header: "상태", key: "status", width: 15 }, + { header: "이메일", key: "email", width: 20 }, + { header: "전화번호", key: "phone", width: 15 }, + { header: "웹사이트", key: "website", width: 20 }, + { header: "주소", key: "address", width: 30 }, + { header: "대표자명", key: "representativeName", width: 15 }, + { header: "신용등급", key: "creditRating", width: 10 }, + { header: "현금흐름등급", key: "cashFlowRating", width: 10 }, + { header: "생성일", key: "createdAt", width: 15 }, + ]; + + // 헤더 스타일 설정 + applyHeaderStyle(basicInfoSheet); + + // 벤더 데이터 추가 + vendors.forEach((vendor: VendorData) => { + basicInfoSheet.addRow({ + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + country: vendor.country, + status: getStatusText(vendor.status), // 상태 코드를 읽기 쉬운 텍스트로 변환 + email: vendor.email, + phone: vendor.phone, + website: vendor.website, + address: vendor.address, + representativeName: vendor.representativeName, + creditRating: vendor.creditRating, + cashFlowRating: vendor.cashFlowRating, + createdAt: vendor.createdAt ? formatDate(vendor.createdAt) : "", + }); + }); +} + +// 연락처 시트 생성 함수 +function createContactsSheet( + workbook: ExcelJS.Workbook, + vendors: VendorData[] +): void { + const contactsSheet = workbook.addWorksheet("연락처"); + + contactsSheet.columns = [ + // 벤더 식별 정보 + { header: "업체코드", key: "vendorCode", width: 15 }, + { header: "업체명", key: "vendorName", width: 20 }, + { header: "세금ID", key: "taxId", width: 15 }, + // 연락처 정보 + { header: "이름", key: "contactName", width: 15 }, + { header: "직책", key: "contactPosition", width: 15 }, + { header: "이메일", key: "contactEmail", width: 25 }, + { header: "전화번호", key: "contactPhone", width: 15 }, + { header: "주요 연락처", key: "isPrimary", width: 10 }, + ]; + + // 헤더 스타일 설정 + applyHeaderStyle(contactsSheet); + + // 벤더별 연락처 데이터 추가 + vendors.forEach((vendor: VendorData) => { + if (vendor.vendorContacts && vendor.vendorContacts.length > 0) { + vendor.vendorContacts.forEach((contact: VendorContact) => { + contactsSheet.addRow({ + // 벤더 식별 정보 + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + // 연락처 정보 + contactName: contact.contactName, + contactPosition: contact.contactPosition || "", + contactEmail: contact.contactEmail, + contactPhone: contact.contactPhone || "", + isPrimary: contact.isPrimary ? "예" : "아니오", + }); + }); + } else { + // 연락처가 없는 경우에도 벤더 정보만 추가 + contactsSheet.addRow({ + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + contactName: "", + contactPosition: "", + contactEmail: "", + contactPhone: "", + isPrimary: "", + }); + } + }); +} + +// 아이템 시트 생성 함수 +function createItemsSheet( + workbook: ExcelJS.Workbook, + vendors: VendorData[] +): void { + const itemsSheet = workbook.addWorksheet("아이템"); + + itemsSheet.columns = [ + // 벤더 식별 정보 + { header: "업체코드", key: "vendorCode", width: 15 }, + { header: "업체명", key: "vendorName", width: 20 }, + { header: "세금ID", key: "taxId", width: 15 }, + // 아이템 정보 + { header: "아이템 코드", key: "itemCode", width: 15 }, + { header: "아이템명", key: "itemName", width: 25 }, + { header: "설명", key: "description", width: 30 }, + { header: "등록일", key: "createdAt", width: 15 }, + ]; + + // 헤더 스타일 설정 + applyHeaderStyle(itemsSheet); + + // 벤더별 아이템 데이터 추가 + vendors.forEach((vendor: VendorData) => { + if (vendor.vendorItems && vendor.vendorItems.length > 0) { + vendor.vendorItems.forEach((item: VendorItem) => { + itemsSheet.addRow({ + // 벤더 식별 정보 + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + // 아이템 정보 + itemCode: item.itemCode, + itemName: item.itemName, + description: item.description || "", + createdAt: item.createdAt ? formatDate(item.createdAt) : "", + }); + }); + } else { + // 아이템이 없는 경우에도 벤더 정보만 추가 + itemsSheet.addRow({ + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + itemCode: "", + itemName: "", + description: "", + createdAt: "", + }); + } + }); +} + +// RFQ 시트 생성 함수 +function createRFQsSheet( + workbook: ExcelJS.Workbook, + vendors: VendorData[] +): void { + const rfqsSheet = workbook.addWorksheet("RFQ"); + + rfqsSheet.columns = [ + // 벤더 식별 정보 + { header: "업체코드", key: "vendorCode", width: 15 }, + { header: "업체명", key: "vendorName", width: 20 }, + { header: "세금ID", key: "taxId", width: 15 }, + // RFQ 정보 + { header: "RFQ 번호", key: "rfqNumber", width: 15 }, + { header: "제목", key: "title", width: 25 }, + { header: "상태", key: "status", width: 15 }, + { header: "요청일", key: "requestDate", width: 15 }, + { header: "마감일", key: "dueDate", width: 15 }, + { header: "설명", key: "description", width: 30 }, + ]; + + // 헤더 스타일 설정 + applyHeaderStyle(rfqsSheet); + + // 벤더별 RFQ 데이터 추가 + vendors.forEach((vendor: VendorData) => { + if (vendor.vendorRfqs && vendor.vendorRfqs.length > 0) { + vendor.vendorRfqs.forEach((rfq: VendorRFQ) => { + rfqsSheet.addRow({ + // 벤더 식별 정보 + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + // RFQ 정보 + rfqNumber: rfq.rfqNumber, + title: rfq.title, + status: rfq.status, + requestDate: rfq.requestDate ? formatDate(rfq.requestDate) : "", + dueDate: rfq.dueDate ? formatDate(rfq.dueDate) : "", + description: rfq.description || "", + }); + }); + } else { + // RFQ가 없는 경우에도 벤더 정보만 추가 + rfqsSheet.addRow({ + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + rfqNumber: "", + title: "", + status: "", + requestDate: "", + dueDate: "", + description: "", + }); + } + }); +} + +// 계약 시트 생성 함수 +function createContractsSheet( + workbook: ExcelJS.Workbook, + vendors: VendorData[] +): void { + const contractsSheet = workbook.addWorksheet("계약"); + + contractsSheet.columns = [ + // 벤더 식별 정보 + { header: "업체코드", key: "vendorCode", width: 15 }, + { header: "업체명", key: "vendorName", width: 20 }, + { header: "세금ID", key: "taxId", width: 15 }, + // 계약 정보 + { header: "프로젝트 코드", key: "projectCode", width: 15 }, + { header: "프로젝트명", key: "projectName", width: 20 }, + { header: "계약 번호", key: "contractNo", width: 15 }, + { header: "계약명", key: "contractName", width: 25 }, + { header: "상태", key: "status", width: 15 }, + { header: "지급 조건", key: "paymentTerms", width: 15 }, + { header: "납품 조건", key: "deliveryTerms", width: 15 }, + { header: "납품 일자", key: "deliveryDate", width: 15 }, + { header: "납품 위치", key: "deliveryLocation", width: 20 }, + { header: "계약 시작일", key: "startDate", width: 15 }, + { header: "계약 종료일", key: "endDate", width: 15 }, + { header: "통화", key: "currency", width: 10 }, + { header: "총액", key: "totalAmount", width: 15 }, + ]; + + // 헤더 스타일 설정 + applyHeaderStyle(contractsSheet); + + // 벤더별 계약 데이터 추가 + vendors.forEach((vendor: VendorData) => { + if (vendor.vendorContracts && vendor.vendorContracts.length > 0) { + vendor.vendorContracts.forEach((contract: VendorContract) => { + contractsSheet.addRow({ + // 벤더 식별 정보 + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + // 계약 정보 + projectCode: contract.projectCode, + projectName: contract.projectName, + contractNo: contract.contractNo, + contractName: contract.contractName, + status: contract.status, + paymentTerms: contract.paymentTerms, + deliveryTerms: contract.deliveryTerms, + deliveryDate: contract.deliveryDate ? formatDate(contract.deliveryDate) : "", + deliveryLocation: contract.deliveryLocation, + startDate: contract.startDate ? formatDate(contract.startDate) : "", + endDate: contract.endDate ? formatDate(contract.endDate) : "", + currency: contract.currency, + totalAmount: contract.totalAmount ? formatAmount(contract.totalAmount) : "", + }); + }); + } else { + // 계약이 없는 경우에도 벤더 정보만 추가 + contractsSheet.addRow({ + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + projectCode: "", + projectName: "", + contractNo: "", + contractName: "", + status: "", + paymentTerms: "", + deliveryTerms: "", + deliveryDate: "", + deliveryLocation: "", + startDate: "", + endDate: "", + currency: "", + totalAmount: "", + }); + } + }); +} + +// 헤더 스타일 적용 함수 +function applyHeaderStyle(sheet: ExcelJS.Worksheet): void { + const headerRow = sheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.alignment = { horizontal: "center" }; + headerRow.eachCell((cell: ExcelJS.Cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + }; + }); +} + +// 날짜 포맷 함수 +function formatDate(date: Date | string): string { + if (!date) return ""; + if (typeof date === 'string') { + date = new Date(date); + } + return date.toISOString().split('T')[0]; +} + +// 금액 포맷 함수 +function formatAmount(amount: number): string { + return amount.toLocaleString(); +} + +// 상태 코드를 읽기 쉬운 텍스트로 변환하는 함수 +function getStatusText(status: string): string { + const statusMap: Record<string, string> = { + "PENDING_REVIEW": "검토 대기중", + "IN_REVIEW": "검토 중", + "REJECTED": "거부됨", + "IN_PQ": "PQ 진행 중", + "PQ_SUBMITTED": "PQ 제출됨", + "PQ_FAILED": "PQ 실패", + "PQ_APPROVED": "PQ 승인됨", + "APPROVED": "승인됨", + "READY_TO_SEND": "발송 준비됨", + "ACTIVE": "활성", + "INACTIVE": "비활성", + "BLACKLISTED": "거래 금지" + }; + + return statusMap[status] || status; +}
\ No newline at end of file diff --git a/lib/vendors/table/vendors-table-columns.tsx b/lib/vendors/table/vendors-table-columns.tsx index 77750c47..c768b587 100644 --- a/lib/vendors/table/vendors-table-columns.tsx +++ b/lib/vendors/table/vendors-table-columns.tsx @@ -27,30 +27,41 @@ import { import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" import { useRouter } from "next/navigation" -import { Vendor, vendors, VendorWithAttachments } from "@/db/schema/vendors" +import { VendorWithType, vendors, VendorWithAttachments } from "@/db/schema/vendors" import { modifyVendor } from "../service" import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" import { vendorColumnsConfig } from "@/config/vendorColumnsConfig" import { Separator } from "@/components/ui/separator" import { AttachmentsButton } from "./attachmentButton" +import { getVendorStatusIcon } from "../utils" +// 타입 정의 추가 +type StatusType = (typeof vendors.status.enumValues)[number]; +type BadgeVariantType = "default" | "secondary" | "destructive" | "outline"; +type StatusConfig = { + variant: BadgeVariantType; + className: string; +}; +type StatusDisplayMap = { + [key in StatusType]: string; +}; type NextRouter = ReturnType<typeof useRouter>; - interface GetColumnsProps { - setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Vendor> | null>>; + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorWithType> | null>>; router: NextRouter; + userId: number; } /** * tanstack table 컬럼 정의 (중첩 헤더 버전) */ -export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<Vendor>[] { +export function getColumns({ setRowAction, router, userId }: GetColumnsProps): ColumnDef<VendorWithType>[] { // ---------------------------------------------------------------- // 1) select 컬럼 (체크박스) // ---------------------------------------------------------------- - const selectColumn: ColumnDef<Vendor> = { + const selectColumn: ColumnDef<VendorWithType> = { id: "select", header: ({ table }) => ( <Checkbox @@ -79,102 +90,103 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef // ---------------------------------------------------------------- // 2) actions 컬럼 (Dropdown 메뉴) // ---------------------------------------------------------------- -// ---------------------------------------------------------------- -// 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-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`); - }} - > - Details - </DropdownMenuItem> - - {/* APPROVED 상태일 때만 추가 정보 기입 메뉴 표시 */} - {isApproved && ( + const actionsColumn: ColumnDef<VendorWithType> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const isApproved = row.original.status === "PQ_APPROVED"; + const afterApproved = row.original.status === "ACTIVE"; + + 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"> + {(isApproved ||afterApproved) && ( <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "requestInfo" })} - className="text-blue-600 font-medium" + onSelect={() => setRowAction({ row, type: "update" })} > - 추가 정보 기입 + 레코드 편집 </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, -} + )} + + <DropdownMenuItem + onSelect={() => { + // 1) 만약 rowAction을 열고 싶다면 + // setRowAction({ row, type: "update" }) + + // 2) 자세히 보기 페이지로 클라이언트 라우팅 + router.push(`/evcp/vendors/${row.original.id}/info`); + }} + > + 상세보기 + </DropdownMenuItem> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "log" })} + > + 감사 로그 보기 + </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 VendorWithType["status"], + userId, + vendorName: row.original.vendorName, // Required field from UpdateVendorSchema + comment: `Status changed to ${value}` + }), + { + 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 생성 // ---------------------------------------------------------------- - // 3-1) groupMap: { [groupName]: ColumnDef<Vendor>[] } - const groupMap: Record<string, ColumnDef<Vendor>[]> = {} + // 3-1) groupMap: { [groupName]: ColumnDef<VendorWithType>[] } + const groupMap: Record<string, ColumnDef<VendorWithType>[]> = {} vendorColumnsConfig.forEach((cfg) => { // 만약 group가 없으면 "_noGroup" 처리 @@ -185,7 +197,7 @@ const actionsColumn: ColumnDef<Vendor> = { } // child column 정의 - const childCol: ColumnDef<Vendor> = { + const childCol: ColumnDef<VendorWithType> = { accessorKey: cfg.id, enableResizing: true, header: ({ column }) => ( @@ -197,20 +209,158 @@ const actionsColumn: ColumnDef<Vendor> = { type: cfg.type, }, cell: ({ row, cell }) => { + // Status 컬럼 렌더링 개선 - 아이콘과 더 선명한 배경색 사용 + if (cfg.id === "status") { + const statusVal = row.original.status as StatusType; + if (!statusVal) return null; + // Status badge variant mapping - 더 뚜렷한 색상으로 변경 + const getStatusConfig = (status: StatusType): StatusConfig & { iconColor: string } => { + switch (status) { + case "PENDING_REVIEW": + return { + variant: "outline", + className: "bg-yellow-100 text-yellow-800 border-yellow-300", + iconColor: "text-yellow-600" + }; + case "IN_REVIEW": + return { + variant: "outline", + className: "bg-blue-100 text-blue-800 border-blue-300", + iconColor: "text-blue-600" + }; + case "REJECTED": + return { + variant: "outline", + className: "bg-red-100 text-red-800 border-red-300", + iconColor: "text-red-600" + }; + case "IN_PQ": + return { + variant: "outline", + className: "bg-purple-100 text-purple-800 border-purple-300", + iconColor: "text-purple-600" + }; + case "PQ_SUBMITTED": + return { + variant: "outline", + className: "bg-indigo-100 text-indigo-800 border-indigo-300", + iconColor: "text-indigo-600" + }; + case "PQ_FAILED": + return { + variant: "outline", + className: "bg-red-100 text-red-800 border-red-300", + iconColor: "text-red-600" + }; + case "PQ_APPROVED": + return { + variant: "outline", + className: "bg-green-100 text-green-800 border-green-300", + iconColor: "text-green-600" + }; + case "APPROVED": + return { + variant: "outline", + className: "bg-green-100 text-green-800 border-green-300", + iconColor: "text-green-600" + }; + case "READY_TO_SEND": + return { + variant: "outline", + className: "bg-emerald-100 text-emerald-800 border-emerald-300", + iconColor: "text-emerald-600" + }; + case "ACTIVE": + return { + variant: "outline", + className: "bg-emerald-100 text-emerald-800 border-emerald-300 font-semibold", + iconColor: "text-emerald-600" + }; + case "INACTIVE": + return { + variant: "outline", + className: "bg-gray-100 text-gray-800 border-gray-300", + iconColor: "text-gray-600" + }; + case "BLACKLISTED": + return { + variant: "outline", + className: "bg-slate-800 text-white border-slate-900", + iconColor: "text-white" + }; + default: + return { + variant: "outline", + className: "bg-gray-100 text-gray-800 border-gray-300", + iconColor: "text-gray-600" + }; + } + }; + + // Translate status for display + const getStatusDisplay = (status: StatusType): string => { + const statusMap: StatusDisplayMap = { + "PENDING_REVIEW": "가입 신청 중", + "IN_REVIEW": "심사 중", + "REJECTED": "심사 거부됨", + "IN_PQ": "PQ 진행 중", + "PQ_SUBMITTED": "PQ 제출", + "PQ_FAILED": "PQ 실패", + "PQ_APPROVED": "PQ 통과", + "APPROVED": "승인됨", + "READY_TO_SEND": "MDG 송부대기", + "ACTIVE": "활성 상태", + "INACTIVE": "비활성 상태", + "BLACKLISTED": "거래 금지" + }; + + return statusMap[status] || status; + }; + + const config = getStatusConfig(statusVal); + const displayText = getStatusDisplay(statusVal); + const StatusIcon = getVendorStatusIcon(statusVal); - if (cfg.id === "status") { - const statusVal = row.original.status - if (!statusVal) return null - // const Icon = getStatusIcon(statusVal) return ( - <div className="flex w-[6.25rem] items-center"> - {/* <Icon className="mr-2 size-4 text-muted-foreground" aria-hidden="true" /> */} - <span className="capitalize">{statusVal}</span> - </div> - ) + <Badge variant={config.variant} className={`flex items-center px-2 py-1 ${config.className}`}> + <StatusIcon className={`mr-1 h-3.5 w-3.5 ${config.iconColor}`} /> + <span>{displayText}</span> + </Badge> + ); } + // 업체 유형 컬럼 처리 + if (cfg.id === "vendorTypeName") { + const typeVal = row.original.vendorTypeName as string | null; + return typeVal ? ( + <span className="text-sm font-medium"> + {typeVal} + </span> + ) : ( + <span className="text-sm text-gray-400">미지정</span> + ); + } + + // 업체 분류 컬럼 처리 (별도로 표시하고 싶은 경우) + if (cfg.id === "vendorCategory") { + const categoryVal = row.original.vendorCategory as string | null; + if (!categoryVal) return null; + + let badgeClass = ""; + + if (categoryVal === "정규업체") { + badgeClass = "bg-green-50 text-green-700 border-green-200"; + } else if (categoryVal === "잠재업체") { + badgeClass = "bg-blue-50 text-blue-700 border-blue-200"; + } + + return ( + <Badge variant="outline" className={badgeClass}> + {categoryVal} + </Badge> + ); + } if (cfg.id === "createdAt") { const dateVal = cell.getValue() as Date @@ -222,10 +372,10 @@ const actionsColumn: ColumnDef<Vendor> = { return formatDate(dateVal) } - // code etc... return row.getValue(cfg.id) ?? "" }, + minSize: 150 } groupMap[groupName].push(childCol) @@ -234,7 +384,7 @@ const actionsColumn: ColumnDef<Vendor> = { // ---------------------------------------------------------------- // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 // ---------------------------------------------------------------- - const nestedColumns: ColumnDef<Vendor>[] = [] + const nestedColumns: ColumnDef<VendorWithType>[] = [] // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 // 여기서는 그냥 Object.entries 순서 @@ -252,34 +402,35 @@ const actionsColumn: ColumnDef<Vendor> = { } }) - const attachmentsColumn: ColumnDef<VendorWithAttachments> = { + // attachments 컬럼 타입 문제 해결을 위한 타입 단언 + const attachmentsColumn: ColumnDef<VendorWithType> = { id: "attachments", header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title="" /> ), cell: ({ row }) => { // hasAttachments 및 attachmentsList 속성이 추가되었다고 가정 - const hasAttachments = row.original.hasAttachments; - const attachmentsList = row.original.attachmentsList || []; - - if(hasAttachments){ + const hasAttachments = (row.original as VendorWithAttachments).hasAttachments; + const attachmentsList = (row.original as VendorWithAttachments).attachmentsList || []; - // 서버 액션을 사용하는 컴포넌트로 교체 - return ( - <AttachmentsButton - vendorId={row.original.id} - hasAttachments={hasAttachments} - attachmentsList={attachmentsList} - /> - );}{ - return null + if (hasAttachments) { + // 서버 액션을 사용하는 컴포넌트로 교체 + return ( + <AttachmentsButton + vendorId={row.original.id} + hasAttachments={hasAttachments} + attachmentsList={attachmentsList} + /> + ); + } else { + return null; } }, enableSorting: false, enableHiding: false, minSize: 45, }; - + // ---------------------------------------------------------------- // 4) 최종 컬럼 배열: select, nestedColumns, actions diff --git a/lib/vendors/table/vendors-table-toolbar-actions.tsx b/lib/vendors/table/vendors-table-toolbar-actions.tsx index 3cb2c552..1c788911 100644 --- a/lib/vendors/table/vendors-table-toolbar-actions.tsx +++ b/lib/vendors/table/vendors-table-toolbar-actions.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { type Table } from "@tanstack/react-table" -import { Download, Upload, Check, BuildingIcon } from "lucide-react" +import { Download, FileSpreadsheet, Upload, Check, BuildingIcon, FileText } from "lucide-react" import { toast } from "sonner" import { exportTableToExcel } from "@/lib/export" @@ -11,25 +11,29 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { Vendor } from "@/db/schema/vendors" +import { VendorWithType } 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" +import { RequestContractDialog } from "./request-basicContract-dialog" +import { exportVendorsWithRelatedData } from "./vendor-all-export" interface VendorsTableToolbarActionsProps { - table: Table<Vendor> + table: Table<VendorWithType> } export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActionsProps) { + const [isExporting, setIsExporting] = React.useState(false); // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 const fileInputRef = React.useRef<HTMLInputElement>(null) - // 선택된 벤더 중 PENDING_REVIEW 상태인 벤더만 필터링 + // 선택된 협력업체 중 PENDING_REVIEW 상태인 협력업체만 필터링 const pendingReviewVendors = React.useMemo(() => { return table .getFilteredSelectedRowModel() @@ -38,7 +42,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions .filter(vendor => vendor.status === "PENDING_REVIEW"); }, [table.getFilteredSelectedRowModel().rows]); - // 선택된 벤더 중 IN_REVIEW 상태인 벤더만 필터링 + // 선택된 협력업체 중 IN_REVIEW 상태인 협력업체만 필터링 const inReviewVendors = React.useMemo(() => { return table .getFilteredSelectedRowModel() @@ -71,7 +75,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions .filter(vendor => vendor.status === "PQ_APPROVED"); }, [table.getFilteredSelectedRowModel().rows]); - // 프로젝트 PQ를 보낼 수 있는 벤더 상태 필터링 + // 프로젝트 PQ를 보낼 수 있는 협력업체 상태 필터링 const projectPQEligibleVendors = React.useMemo(() => { return table .getFilteredSelectedRowModel() @@ -81,10 +85,66 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions ["PENDING_REVIEW", "IN_REVIEW", "IN_PQ", "PQ_APPROVED", "APPROVED", "READY_TO_SEND", "ACTIVE"].includes(vendor.status) ); }, [table.getFilteredSelectedRowModel().rows]); + + // 선택된 모든 벤더 가져오기 + const selectedVendors = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original); + }, [table.getFilteredSelectedRowModel().rows]); + + // 테이블의 모든 벤더 가져오기 (필터링된 결과) + const allFilteredVendors = React.useMemo(() => { + return table + .getFilteredRowModel() + .rows + .map(row => row.original); + }, [table.getFilteredRowModel().rows]); + + // 선택된 벤더 통합 내보내기 함수 실행 + const handleSelectedExport = async () => { + if (selectedVendors.length === 0) { + toast.warning("내보낼 협력업체를 선택해주세요."); + return; + } + + try { + setIsExporting(true); + toast.info(`선택된 ${selectedVendors.length}개 업체의 정보를 내보내는 중입니다...`); + await exportVendorsWithRelatedData(selectedVendors, "selected-vendors-detailed"); + toast.success(`${selectedVendors.length}개 업체 정보 내보내기가 완료되었습니다.`); + } catch (error) { + console.error("상세 정보 내보내기 오류:", error); + toast.error("상세 정보 내보내기 중 오류가 발생했습니다."); + } finally { + setIsExporting(false); + } + }; + + // 모든 벤더 통합 내보내기 함수 실행 + const handleAllFilteredExport = async () => { + if (allFilteredVendors.length === 0) { + toast.warning("내보낼 협력업체가 없습니다."); + return; + } + + try { + setIsExporting(true); + toast.info(`총 ${allFilteredVendors.length}개 업체의 정보를 내보내는 중입니다...`); + await exportVendorsWithRelatedData(allFilteredVendors, "all-vendors-detailed"); + toast.success(`${allFilteredVendors.length}개 업체 정보 내보내기가 완료되었습니다.`); + } catch (error) { + console.error("상세 정보 내보내기 오류:", error); + toast.error("상세 정보 내보내기 중 오류가 발생했습니다."); + } finally { + setIsExporting(false); + } + }; return ( <div className="flex items-center gap-2"> - {/* 승인 다이얼로그: PENDING_REVIEW 상태인 벤더가 있을 때만 표시 */} + {/* 승인 다이얼로그: PENDING_REVIEW 상태인 협력업체가 있을 때만 표시 */} {pendingReviewVendors.length > 0 && ( <ApproveVendorsDialog vendors={pendingReviewVendors} @@ -92,7 +152,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions /> )} - {/* 일반 PQ 요청: IN_REVIEW 상태인 벤더가 있을 때만 표시 */} + {/* 일반 PQ 요청: IN_REVIEW 상태인 협력업체가 있을 때만 표시 */} {inReviewVendors.length > 0 && ( <RequestPQVendorsDialog vendors={inReviewVendors} @@ -100,7 +160,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions /> )} - {/* 프로젝트 PQ 요청: 적격 상태의 벤더가 있을 때만 표시 */} + {/* 프로젝트 PQ 요청: 적격 상태의 협력업체가 있을 때만 표시 */} {projectPQEligibleVendors.length > 0 && ( <RequestProjectPQDialog vendors={projectPQEligibleVendors} @@ -109,13 +169,13 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions )} {approvedVendors.length > 0 && ( - <RequestInfoDialog + <RequestContractDialog vendors={approvedVendors} onSuccess={() => table.toggleAllRowsSelected(false)} /> )} - {sendVendors.length > 0 && ( + {pqApprovedVendors.length > 0 && ( <RequestInfoDialog vendors={sendVendors} onSuccess={() => table.toggleAllRowsSelected(false)} @@ -129,21 +189,63 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions /> )} - {/** 4) Export 버튼 */} - <Button - variant="outline" - size="sm" - onClick={() => - exportTableToExcel(table, { - filename: "vendors", - excludeColumns: ["select", "actions"], - }) - } - className="gap-2" - > - <Download className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">Export</span> - </Button> + {/* Export 드롭다운 메뉴로 변경 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="outline" + size="sm" + className="gap-2" + disabled={isExporting} + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline"> + {isExporting ? "내보내는 중..." : "Export"} + </span> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + {/* 기본 내보내기 - 현재 테이블에 보이는 데이터만 */} + <DropdownMenuItem + onClick={() => + exportTableToExcel(table, { + filename: "vendors", + excludeColumns: ["select", "actions"], + }) + } + disabled={isExporting} + > + <FileText className="mr-2 size-4" /> + <span>현재 테이블 데이터 내보내기</span> + </DropdownMenuItem> + + <DropdownMenuSeparator /> + + {/* 선택된 벤더만 상세 내보내기 */} + <DropdownMenuItem + onClick={handleSelectedExport} + disabled={selectedVendors.length === 0 || isExporting} + > + <FileSpreadsheet className="mr-2 size-4" /> + <span>선택한 업체 상세 정보 내보내기</span> + {selectedVendors.length > 0 && ( + <span className="ml-1 text-xs text-muted-foreground">({selectedVendors.length}개)</span> + )} + </DropdownMenuItem> + + {/* 모든 필터링된 벤더 상세 내보내기 */} + <DropdownMenuItem + onClick={handleAllFilteredExport} + disabled={allFilteredVendors.length === 0 || isExporting} + > + <Download className="mr-2 size-4" /> + <span>모든 업체 상세 정보 내보내기</span> + {allFilteredVendors.length > 0 && ( + <span className="ml-1 text-xs text-muted-foreground">({allFilteredVendors.length}개)</span> + )} + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> </div> ) }
\ No newline at end of file diff --git a/lib/vendors/table/vendors-table.tsx b/lib/vendors/table/vendors-table.tsx index 36fd45bd..02768f32 100644 --- a/lib/vendors/table/vendors-table.tsx +++ b/lib/vendors/table/vendors-table.tsx @@ -8,19 +8,18 @@ import type { DataTableRowAction, } from "@/types/table" -import { toSentenceCase } from "@/lib/utils" import { useDataTable } from "@/hooks/use-data-table" import { DataTable } from "@/components/data-table/data-table" import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" import { useFeatureFlags } from "./feature-flags-provider" import { getColumns } from "./vendors-table-columns" import { getVendors, getVendorStatusCounts } from "../service" -import { Vendor, vendors } from "@/db/schema/vendors" +import { VendorWithType, vendors } from "@/db/schema/vendors" 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" +import { ViewVendorLogsDialog } from "./view-vendors_logs-dialog" +import { useSession } from "next-auth/react" interface VendorsTableProps { promises: Promise< @@ -32,58 +31,83 @@ interface VendorsTableProps { } export function VendorsTable({ promises }: VendorsTableProps) { - const { featureFlags } = useFeatureFlags() - + const { data: session } = useSession() + const userId = Number(session?.user.id) + // Suspense로 받아온 데이터 const [{ data, pageCount }, statusCounts] = React.use(promises) + const [isCompact, setIsCompact] = React.useState<boolean>(false) - const [rowAction, setRowAction] = React.useState<DataTableRowAction<Vendor> | null>(null) - + const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithType> | null>(null) + // **router** 획득 const router = useRouter() - + // getColumns() 호출 시, router를 주입 const columns = React.useMemo( - () => getColumns({ setRowAction, router }), - [setRowAction, router] + () => getColumns({ setRowAction, router , userId}), + [setRowAction, router, userId] ) - - const filterFields: DataTableFilterField<Vendor>[] = [ + + // 상태 한글 변환 유틸리티 함수 + const getStatusDisplay = (status: string): string => { + const statusMap: Record<string, string> = { + "PENDING_REVIEW": "가입 신청 중", + "IN_REVIEW": "심사 중", + "REJECTED": "심사 거부됨", + "IN_PQ": "PQ 진행 중", + "PQ_SUBMITTED": "PQ 제출", + "PQ_FAILED": "PQ 실패", + "PQ_APPROVED": "PQ 통과", + "APPROVED": "승인됨", + "READY_TO_SEND": "MDG 송부대기", + "ACTIVE": "활성 상태", + "INACTIVE": "비활성 상태", + "BLACKLISTED": "거래 금지" + }; + + return statusMap[status] || status; + }; + + const filterFields: DataTableFilterField<VendorWithType>[] = [ { id: "status", - label: "Status", + label: "상태", options: vendors.status.enumValues.map((status) => ({ - label: toSentenceCase(status), + label: getStatusDisplay(status), value: status, count: statusCounts[status], })), }, - - { id: "vendorCode", label: "Vendor Code" }, - + + { id: "vendorCode", label: "업체 코드" }, ] - - const advancedFilterFields: DataTableAdvancedFilterField<Vendor>[] = [ - { id: "vendorName", label: "Vendor Name", type: "text" }, - { id: "vendorCode", label: "Vendor Code", type: "text" }, - { id: "email", label: "Email", type: "text" }, - { id: "country", label: "Country", type: "text" }, + + const advancedFilterFields: DataTableAdvancedFilterField<VendorWithType>[] = [ + { id: "vendorName", label: "업체명", type: "text" }, + { id: "vendorCode", label: "업체코드", type: "text" }, + { id: "email", label: "이메일", type: "text" }, + { id: "country", label: "국가", type: "text" }, + { id: "vendorTypeName", label: "업체 유형", type: "text" }, + { id: "vendorCategory", label: "업체 분류", type: "select", options: [ + { label: "정규업체", value: "정규업체" }, + { label: "잠재업체", value: "잠재업체" }, + ]}, { id: "status", - label: "Status", + label: "업체승인상태", type: "multi-select", options: vendors.status.enumValues.map((status) => ({ - label: (status), + label: getStatusDisplay(status), value: status, count: statusCounts[status], icon: getVendorStatusIcon(status), - })), }, - { id: "createdAt", label: "Created at", type: "date" }, - { id: "updatedAt", label: "Updated at", type: "date" }, + { id: "createdAt", label: "등록일", type: "date" }, + { id: "updatedAt", label: "수정일", type: "date" }, ] - + const { table } = useDataTable({ data, columns, @@ -100,16 +124,25 @@ export function VendorsTable({ promises }: VendorsTableProps) { clearOnDefault: true, }) + const handleCompactChange = React.useCallback((compact: boolean) => { + setIsCompact(compact) + }, []) + + return ( <> <DataTable table={table} + compact={isCompact} // floatingBar={<VendorsTableFloatingBar table={table} />} > <DataTableAdvancedToolbar table={table} filterFields={advancedFilterFields} shallow={false} + enableCompactToggle={true} + compactStorageKey="vendorsTableCompact" + onCompactChange={handleCompactChange} > <VendorsTableToolbarActions table={table} /> </DataTableAdvancedToolbar> @@ -119,6 +152,12 @@ export function VendorsTable({ promises }: VendorsTableProps) { onOpenChange={() => setRowAction(null)} vendor={rowAction?.row.original ?? null} /> + + <ViewVendorLogsDialog + open={rowAction?.type === "log"} + onOpenChange={() => setRowAction(null)} + vendorId={rowAction?.row.original?.id ?? null} + /> </> ) }
\ No newline at end of file diff --git a/lib/vendors/table/view-vendors_logs-dialog.tsx b/lib/vendors/table/view-vendors_logs-dialog.tsx new file mode 100644 index 00000000..7402ae55 --- /dev/null +++ b/lib/vendors/table/view-vendors_logs-dialog.tsx @@ -0,0 +1,244 @@ +"use client" + +import * as React from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { formatDateTime } from "@/lib/utils" +import { useToast } from "@/hooks/use-toast" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Badge } from "@/components/ui/badge" +import { Download, Search, User } from "lucide-react" +import { VendorsLogWithUser, getVendorLogs } from "../service" + +interface VendorLogsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + vendorId: number | null +} + +export function ViewVendorLogsDialog({ + open, + onOpenChange, + vendorId, +}: VendorLogsDialogProps) { + const [logs, setLogs] = React.useState<VendorsLogWithUser[]>([]) + const [filteredLogs, setFilteredLogs] = React.useState<VendorsLogWithUser[]>([]) + const [loading, setLoading] = React.useState(false) + const [error, setError] = React.useState<string | null>(null) + const [searchQuery, setSearchQuery] = React.useState("") + const [actionFilter, setActionFilter] = React.useState<string>("all") + const { toast } = useToast() + + // Get unique action types for filter dropdown + const actionTypes = React.useMemo(() => { + if (!logs.length) return [] + return Array.from(new Set(logs.map(log => log.action))) + }, [logs]) + + // Fetch logs when dialog opens + React.useEffect(() => { + if (open && vendorId) { + setLoading(true) + setError(null) + getVendorLogs(vendorId) + .then((res) => { + setLogs(res) + setFilteredLogs(res) + }) + .catch((err) => { + console.error(err) + setError("Failed to load logs. Please try again.") + toast({ + variant: "destructive", + title: "Error", + description: "Failed to load candidate logs", + }) + }) + .finally(() => setLoading(false)) + } else { + // Reset state when dialog closes + setSearchQuery("") + setActionFilter("all") + } + }, [open, vendorId, toast]) + + // Filter logs based on search query and action filter + React.useEffect(() => { + if (!logs.length) return + + let result = [...logs] + + // Apply action filter + if (actionFilter !== "all") { + result = result.filter(log => log.action === actionFilter) + } + + // Apply search filter (case insensitive) + if (searchQuery) { + const query = searchQuery.toLowerCase() + result = result.filter(log => + log.action.toLowerCase().includes(query) || + (log.comment && log.comment.toLowerCase().includes(query)) || + (log.oldStatus && log.oldStatus.toLowerCase().includes(query)) || + (log.newStatus && log.newStatus.toLowerCase().includes(query)) || + (log.userName && log.userName.toLowerCase().includes(query)) || + (log.userEmail && log.userEmail.toLowerCase().includes(query)) + ) + } + + setFilteredLogs(result) + }, [logs, searchQuery, actionFilter]) + + // Export logs as CSV + const exportLogs = () => { + if (!filteredLogs.length) return + + const headers = ["Action", "Old Status", "New Status", "Comment", "User", "Email", "Date"] + const csvContent = [ + headers.join(","), + ...filteredLogs.map(log => [ + `"${log.action}"`, + `"${log.oldStatus || ''}"`, + `"${log.newStatus || ''}"`, + `"${log.comment?.replace(/"/g, '""') || ''}"`, + `"${log.userName || ''}"`, + `"${log.userEmail || ''}"`, + `"${formatDateTime(log.createdAt)}"` + ].join(",")) + ].join("\n") + + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.setAttribute("href", url) + link.setAttribute("download", `vendor-logs-${vendorId}-${new Date().toISOString().split('T')[0]}.csv`) + link.style.visibility = "hidden" + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } + + // Render status change with appropriate badge + const renderStatusChange = (oldStatus: string, newStatus: string) => { + return ( + <div className="text-sm flex flex-wrap gap-2 items-center"> + <strong>Status:</strong> + <Badge variant="outline" className="text-xs">{oldStatus}</Badge> + <span>→</span> + <Badge variant="outline" className="bg-primary/10 text-xs">{newStatus}</Badge> + </div> + ) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[700px]"> + <DialogHeader> + <DialogTitle>Audit Logs</DialogTitle> + </DialogHeader> + + {/* Filters and search */} + <div className="flex items-center gap-2 mb-4"> + <div className="relative flex-1"> + <div className="absolute inset-y-0 left-0 flex items-center pl-2 pointer-events-none"> + <Search className="h-4 w-4 text-muted-foreground" /> + </div> + <Input + placeholder="Search logs..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="pl-8" + /> + </div> + + <Select + value={actionFilter} + onValueChange={setActionFilter} + > + <SelectTrigger className="w-[180px]"> + <SelectValue placeholder="Filter by action" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all">All actions</SelectItem> + {actionTypes.map(action => ( + <SelectItem key={action} value={action}>{action}</SelectItem> + ))} + </SelectContent> + </Select> + + <Button + size="icon" + variant="outline" + onClick={exportLogs} + disabled={filteredLogs.length === 0} + title="Export to CSV" + > + <Download className="h-4 w-4" /> + </Button> + </div> + + <div className="space-y-2"> + {loading && ( + <div className="flex justify-center py-8"> + <p className="text-muted-foreground">Loading logs...</p> + </div> + )} + + {error && !loading && ( + <div className="bg-destructive/10 text-destructive p-3 rounded-md"> + {error} + </div> + )} + + {!loading && !error && filteredLogs.length === 0 && ( + <p className="text-muted-foreground text-center py-8"> + {logs.length > 0 ? "No logs match your search criteria." : "No logs found for this candidate."} + </p> + )} + + {!loading && !error && filteredLogs.length > 0 && ( + <> + <div className="text-xs text-muted-foreground mb-2"> + Showing {filteredLogs.length} {filteredLogs.length === 1 ? 'log' : 'logs'} + {filteredLogs.length !== logs.length && ` (filtered from ${logs.length})`} + </div> + <div className="max-h-96 space-y-4 pr-4 overflow-y-auto"> + {filteredLogs.map((log) => ( + <div key={log.id} className="rounded-md border p-4 mb-3 hover:bg-muted/50 transition-colors"> + <div className="flex justify-between items-start mb-2"> + <Badge className="text-xs">{log.action}</Badge> + <div className="text-xs text-muted-foreground"> + {formatDateTime(log.createdAt)} + </div> + </div> + + {log.oldStatus && log.newStatus && ( + <div className="my-2"> + {renderStatusChange(log.oldStatus, log.newStatus)} + </div> + )} + + {log.comment && ( + <div className="my-2 text-sm bg-muted/50 p-2 rounded-md"> + <strong>Comment:</strong> {log.comment} + </div> + )} + + {(log.userName || log.userEmail) && ( + <div className="mt-3 pt-2 border-t flex items-center text-xs text-muted-foreground"> + <User className="h-3 w-3 mr-1" /> + {log.userName || "Unknown"} + {log.userEmail && <span className="ml-1">({log.userEmail})</span>} + </div> + )} + </div> + ))} + </div> + </> + )} + </div> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendors/validations.ts b/lib/vendors/validations.ts index 1c08f8ff..e01fa8b9 100644 --- a/lib/vendors/validations.ts +++ b/lib/vendors/validations.ts @@ -8,7 +8,7 @@ import { import * as z from "zod" import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" -import { Vendor, VendorContact, vendorInvestigationsView, VendorItemsView, vendors } from "@/db/schema/vendors"; +import { Vendor, VendorContact, vendorInvestigationsView, VendorItemsView, vendors, VendorWithType } from "@/db/schema/vendors"; import { rfqs } from "@/db/schema/rfq" @@ -24,7 +24,7 @@ export const searchParamsCache = createSearchParamsCache({ perPage: parseAsInteger.withDefault(10), // 정렬 (vendors 테이블에 맞춰 Vendor 타입 지정) - sort: getSortingStateParser<Vendor>().withDefault([ + sort: getSortingStateParser<VendorWithType>().withDefault([ { id: "createdAt", desc: true }, // createdAt 기준 내림차순 ]), @@ -36,12 +36,12 @@ export const searchParamsCache = createSearchParamsCache({ search: parseAsString.withDefault(""), // ----------------------------------------------------------------- - // 여기부터는 "벤더"에 특화된 검색 필드 예시 + // 여기부터는 "협력업체"에 특화된 검색 필드 예시 // ----------------------------------------------------------------- // 상태 (ACTIVE, INACTIVE, BLACKLISTED 등) 중에서 선택 status: parseAsStringEnum(["ACTIVE", "INACTIVE", "BLACKLISTED"]), - // 벤더명 검색 + // 협력업체명 검색 vendorName: parseAsString.withDefault(""), // 국가 검색 @@ -114,21 +114,32 @@ export const searchParamsItemCache = createSearchParamsCache({ description: parseAsString.withDefault(""), }); +const creditAgencyEnum = z.enum(["NICE", "KIS", "KED", "SCI"]); +export type CreditAgencyType = z.infer<typeof creditAgencyEnum>; + export const updateVendorSchema = z.object({ - vendorName: z.string().min(1, "Vendor name is required").max(255, "Max length 255").optional(), - vendorCode: z.string().max(100, "Max length 100").optional(), + vendorName: z.string().min(1, "업체명은 필수 입력사항입니다"), + vendorCode: z.string().optional(), address: z.string().optional(), - country: z.string().max(100, "Max length 100").optional(), - phone: z.string().max(50, "Max length 50").optional(), - email: z.string().email("Invalid email").max(255).optional(), - website: z.string().url("Invalid URL").max(255).optional(), - - // status는 특정 값만 허용하도록 enum 사용 예시 - // 필요 시 'SUSPENDED', 'BLACKLISTED' 등 추가하거나 제거 가능 - status: z.enum(vendors.status.enumValues) - .optional() - .default("ACTIVE"), + country: z.string().optional(), + phone: z.string().optional(), + email: z.string().email("유효한 이메일 주소를 입력해주세요").optional(), + website: z.string().url("유효한 URL을 입력해주세요").optional(), + status: z.enum(vendors.status.enumValues).optional(), + vendorTypeId: z.number().optional(), + + // Optional fields for buyer information + buyerName: z.string().optional(), + buyerDepartment: z.string().optional(), + contractStartDate: z.date().optional(), + contractEndDate: z.date().optional(), + internalNotes: z.string().optional(), + creditRating: z.string().optional(), + cashFlowRating: z.string().optional(), + creditAgency: creditAgencyEnum.optional(), + + // evaluationScore: z.string().optional(), }); @@ -151,9 +162,9 @@ export const createVendorSchema = z .string() .min(1, "Vendor name is required") .max(255, "Max length 255"), - email: z.string().email("Invalid email").max(255), - taxId: z.string().max(100, "Max length 100"), + vendorTypeId: z.number({ required_error: "업체유형을 선택해주세요" }), + email: z.string().email("Invalid email").max(255), // 나머지 optional vendorCode: z.string().max(100, "Max length 100").optional(), address: z.string().optional(), @@ -163,8 +174,6 @@ export const createVendorSchema = z phone: z.string().max(50, "Max length 50").optional(), website: z.string().url("Invalid URL").max(255).optional(), - creditRatingAttachment: z.any().optional(), // 신용평가 첨부 - cashFlowRatingAttachment: z.any().optional(), // 현금흐름 첨부 attachedFiles: z.any() .refine( val => { @@ -183,10 +192,9 @@ export const createVendorSchema = z representativeEmail: z.union([z.string().email("Invalid email").max(255), z.literal("")]).optional(), representativePhone: z.union([z.string().max(50), z.literal("")]).optional(), corporateRegistrationNumber: z.union([z.string().max(100), z.literal("")]).optional(), + taxId: z.string().min(1, { message: "사업자등록번호를 입력해주세요" }), - creditAgency: z.string().max(50).optional(), - creditRating: z.string().max(50).optional(), - cashFlowRating: z.string().max(50).optional(), + items: z.string().min(1, { message: "공급품목을 입력해주세요" }), contacts: z .array(contactSchema) @@ -233,28 +241,7 @@ export const createVendorSchema = z }) } - // 2) 신용/현금흐름 등급도 필수라면 - if (!data.creditAgency) { - ctx.addIssue({ - code: "custom", - path: ["creditAgency"], - message: "신용평가사 선택은 한국(KR) 업체일 경우 필수입니다.", - }) - } - if (!data.creditRating) { - ctx.addIssue({ - code: "custom", - path: ["creditRating"], - message: "신용평가등급은 한국(KR) 업체일 경우 필수입니다.", - }) - } - if (!data.cashFlowRating) { - ctx.addIssue({ - code: "custom", - path: ["cashFlowRating"], - message: "현금흐름등급은 한국(KR) 업체일 경우 필수입니다.", - }) - } + } } ) @@ -349,7 +336,7 @@ export const updateVendorInfoSchema = z.object({ phone: z.string().optional(), email: z.string().email("유효한 이메일을 입력해 주세요."), website: z.string().optional(), - + // 한국 사업자 정보 (KR일 경우 필수 항목들) representativeName: z.string().optional(), representativeBirth: z.string().optional(), |
