diff options
| -rw-r--r-- | lib/rfqs-tech/service.ts | 1780 | ||||
| -rw-r--r-- | lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table-column.tsx | 2 | ||||
| -rw-r--r-- | lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table.tsx | 68 | ||||
| -rw-r--r-- | lib/tech-vendor-rfq-response/service.ts | 541 |
4 files changed, 1101 insertions, 1290 deletions
diff --git a/lib/rfqs-tech/service.ts b/lib/rfqs-tech/service.ts index 26012491..fac18a43 100644 --- a/lib/rfqs-tech/service.ts +++ b/lib/rfqs-tech/service.ts @@ -1,11 +1,10 @@ // src/lib/tasks/service.ts "use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) -import { revalidatePath, revalidateTag, unstable_noStore } from "next/cache"; +import { revalidatePath, revalidateTag } from "next/cache"; import db from "@/db/db"; import { filterColumns } from "@/lib/filter-columns"; -import { unstable_cache } from "@/lib/unstable-cache"; import { getErrorMessage } from "@/lib/handle-error"; import { GetRfqsSchema, CreateRfqSchema, UpdateRfqSchema, CreateRfqItemSchema, GetMatchedVendorsSchema, UpdateRfqVendorSchema, GetTBESchema, GetCBESchema, createCbeEvaluationSchema } from "./validations"; @@ -41,113 +40,94 @@ interface InviteVendorsInput { /** * 복잡한 조건으로 Rfq 목록을 조회 (+ pagination) 하고, * 총 개수에 따라 pageCount를 계산해서 리턴. - * Next.js의 unstable_cache를 사용해 일정 시간 캐시. */ export async function getRfqs(input: GetRfqsSchema) { - return unstable_cache( - async () => { - try { - const offset = (input.page - 1) * input.perPage; - // const advancedTable = input.flags.includes("advancedTable"); - const advancedTable = true; - - // advancedTable 모드면 filterColumns()로 where 절 구성 - const advancedWhere = filterColumns({ - table: rfqsView, - filters: input.filters, - joinOperator: input.joinOperator, - }); + try { + const offset = (input.page - 1) * input.perPage; + // const advancedTable = input.flags.includes("advancedTable"); + const advancedTable = true; + + // advancedTable 모드면 filterColumns()로 where 절 구성 + const advancedWhere = filterColumns({ + table: rfqsView, + filters: input.filters, + joinOperator: input.joinOperator, + }); - let globalWhere - if (input.search) { - const s = `%${input.search}%` - globalWhere = or(ilike(rfqsView.rfqCode, s), ilike(rfqsView.projectCode, s) - , ilike(rfqsView.projectName, s), ilike(rfqsView.dueDate, s), ilike(rfqsView.status, s) - ) - // 필요시 여러 칼럼 OR조건 (status, priority, etc) - } + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or(ilike(rfqsView.rfqCode, s), ilike(rfqsView.projectCode, s) + , ilike(rfqsView.projectName, s), ilike(rfqsView.dueDate, s), ilike(rfqsView.status, s) + ) + // 필요시 여러 칼럼 OR조건 (status, priority, etc) + } - const whereConditions = []; - if (advancedWhere) whereConditions.push(advancedWhere); - if (globalWhere) whereConditions.push(globalWhere); + const whereConditions = []; + if (advancedWhere) whereConditions.push(advancedWhere); + if (globalWhere) whereConditions.push(globalWhere); - // 조건이 있을 때만 and() 사용 - const finalWhere = whereConditions.length > 0 - ? and(...whereConditions) - : undefined; + // 조건이 있을 때만 and() 사용 + const finalWhere = whereConditions.length > 0 + ? and(...whereConditions) + : undefined; - const orderBy = - input.sort.length > 0 - ? input.sort.map((item) => - item.desc ? desc(rfqsView[item.id]) : asc(rfqsView[item.id]) - ) - : [asc(rfqsView.createdAt)]; - - // 트랜잭션 내부에서 Repository 호출 - const { data, total } = await db.transaction(async (tx) => { - const data = await selectRfqs(tx, { - where: finalWhere, - orderBy, - offset, - limit: input.perPage, - }); + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(rfqsView[item.id]) : asc(rfqsView[item.id]) + ) + : [asc(rfqsView.createdAt)]; + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectRfqs(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); - const total = await countRfqs(tx, finalWhere); - return { data, total }; - }); + const total = await countRfqs(tx, finalWhere); + return { data, total }; + }); - const pageCount = Math.ceil(total / input.perPage); + const pageCount = Math.ceil(total / input.perPage); - return { data, pageCount }; - } catch (err) { - console.error("getRfqs 에러:", err); // 자세한 에러 로깅 + return { data, pageCount }; + } catch (err) { + console.error("getRfqs 에러:", err); // 자세한 에러 로깅 - // 에러 발생 시 디폴트 - return { data: [], pageCount: 0 }; - } - }, - [JSON.stringify(input)], - { - revalidate: 3600, - tags: [`rfqs`], - } - )(); + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } } /** Status별 개수 */ export async function getRfqStatusCounts() { - return unstable_cache( - async () => { - try { - const initial: Record<Rfq["status"], number> = { - DRAFT: 0, - PUBLISHED: 0, - EVALUATION: 0, - AWARDED: 0, - }; + try { + const initial: Record<Rfq["status"], number> = { + DRAFT: 0, + PUBLISHED: 0, + EVALUATION: 0, + AWARDED: 0, + }; - const result = await db.transaction(async (tx) => { - const rows = await groupByStatus(tx); - return rows.reduce<Record<Rfq["status"], number>>((acc, { status, count }) => { - acc[status] = count; - return acc; - }, initial); - }); + const result = await db.transaction(async (tx) => { + const rows = await groupByStatus(tx); + return rows.reduce<Record<Rfq["status"], number>>((acc, { status, count }) => { + acc[status] = count; + return acc; + }, initial); + }); - return result; - } catch { - return {} as Record<Rfq["status"], number>; - } - }, - [`rfq-status-counts`], - { - revalidate: 3600, - tags: [`rfqs`], - } - )(); + return result; + } catch { + return {} as Record<Rfq["status"], number>; + } } @@ -161,7 +141,6 @@ export async function getRfqStatusCounts() { * 전체 Rfq 개수를 고정 */ export async function createRfq(input: CreateRfqSchema) { - unstable_noStore(); try { await db.transaction(async (tx) => { await insertRfq(tx, { @@ -174,8 +153,6 @@ export async function createRfq(input: CreateRfqSchema) { }); }); - revalidateTag("rfqs"); - return { data: null, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; @@ -188,7 +165,6 @@ export async function createRfq(input: CreateRfqSchema) { /** 단건 업데이트 */ export async function modifyRfq(input: UpdateRfqSchema & { id: number }) { - unstable_noStore(); try { await db.transaction(async (tx) => { await updateRfq(tx, input.id, { @@ -200,7 +176,6 @@ export async function modifyRfq(input: UpdateRfqSchema & { id: number }) { }); }); - revalidateTag("rfqs"); return { data: null, error: null }; } catch (err) { @@ -213,7 +188,6 @@ export async function modifyRfqs(input: { status?: Rfq["status"]; dueDate?: Date }) { - unstable_noStore(); try { await db.transaction(async (tx) => { await updateRfqs(tx, input.ids, { @@ -222,7 +196,7 @@ export async function modifyRfqs(input: { }); }); - revalidateTag("rfqs"); + return { data: null, error: null }; } catch (err) { @@ -237,7 +211,6 @@ export async function modifyRfqs(input: { /** 단건 삭제 */ export async function removeRfq(input: { id: number }) { - unstable_noStore(); try { await db.transaction(async (tx) => { // 삭제 @@ -245,7 +218,7 @@ export async function removeRfq(input: { id: number }) { // 바로 새 Rfq 생성 }); - revalidateTag("rfqs"); + return { data: null, error: null }; } catch (err) { @@ -255,14 +228,13 @@ export async function removeRfq(input: { id: number }) { /** 복수 삭제 */ export async function removeRfqs(input: { ids: number[] }) { - unstable_noStore(); try { await db.transaction(async (tx) => { // 삭제 await deleteRfqsByIds(tx, input.ids); }); - revalidateTag("rfqs"); + return { data: null, error: null }; } catch (err) { @@ -275,8 +247,6 @@ export async function removeRfqs(input: { ids: number[] }) { * RFQ 아이템 삭제 함수 */ export async function deleteRfqItem(input: { id: number, rfqId: number }) { - unstable_noStore(); // Next.js 서버 액션 캐싱 방지 - try { // 삭제 작업 수행 await db @@ -288,12 +258,6 @@ export async function deleteRfqItem(input: { id: number, rfqId: number }) { ) ); - console.log(`Deleted RFQ item: ${input.id} for RFQ ${input.rfqId}`); - - // 캐시 무효화 - revalidateTag("rfqs"); - revalidateTag(`rfq-${input.rfqId}`); - return { data: null, error: null }; } catch (err) { console.error("Error in deleteRfqItem:", err); @@ -303,8 +267,6 @@ export async function deleteRfqItem(input: { id: number, rfqId: number }) { // createRfqItem 함수 수정 (id 파라미터 추가) export async function createRfqItem(input: CreateRfqItemSchema & { id?: number }) { - unstable_noStore(); - try { // DB 트랜잭션 await db.transaction(async (tx) => { @@ -321,7 +283,6 @@ export async function createRfqItem(input: CreateRfqItemSchema & { id?: number } }) .where(eq(rfqItems.id, input.id)); - console.log(`Updated RFQ item with id: ${input.id}`); } else { // 기존 로직: 같은 itemCode로 이미 존재하는지 확인 후 업데이트/생성 const existingItems = await tx @@ -363,10 +324,6 @@ export async function createRfqItem(input: CreateRfqItemSchema & { id?: number } } }); - // 캐시 무효화 - revalidateTag("rfqs"); - revalidateTag(`rfq-${input.rfqId}`); - return { data: null, error: null }; } catch (err) { console.error("Error in createRfqItem:", err); @@ -463,9 +420,6 @@ export async function processRfqAttachments(args: { const newCount = countRow?.cnt ?? 0; - // 3) revalidateTag 등 캐시 무효화 - revalidateTag("rfq-attachments"); - return { ok: true, updatedItemCount: newCount }; } catch (error) { console.error("processRfqAttachments error:", error); @@ -529,243 +483,240 @@ export const findRfqById = async (id: number): Promise<RfqViewWithItems | null> }; export async function getMatchedVendors(input: GetMatchedVendorsSchema, rfqId: number) { - return unstable_cache( - async () => { - // ───────────────────────────────────────────────────── - // 1) rfq_items에서 distinct itemCode - // ───────────────────────────────────────────────────── - const itemRows = await db - .select({ code: rfqItems.itemCode }) - .from(rfqItems) - .where(eq(rfqItems.rfqId, rfqId)) - .groupBy(rfqItems.itemCode) + try { + // ───────────────────────────────────────────────────── + // 1) rfq_items에서 distinct itemCode + // ───────────────────────────────────────────────────── + const itemRows = await db + .select({ code: rfqItems.itemCode }) + .from(rfqItems) + .where(eq(rfqItems.rfqId, rfqId)) + .groupBy(rfqItems.itemCode) - const itemCodes = itemRows.map((r) => r.code) - const itemCount = itemCodes.length - if (itemCount === 0) { - return { data: [], pageCount: 0 } - } + const itemCodes = itemRows.map((r) => r.code) + const itemCount = itemCodes.length + if (itemCount === 0) { + return { data: [], pageCount: 0 } + } - // ───────────────────────────────────────────────────── - // 2) vendorPossibleItems에서 모든 itemCodes를 보유한 vendor - // ───────────────────────────────────────────────────── - const inList = itemCodes.map((c) => `'${c}'`).join(",") - const sqlVendorIds = await db.execute( - sql` - SELECT vpi.vendor_id AS "vendorId" - FROM ${vendorPossibleItems} vpi - WHERE vpi.item_code IN (${sql.raw(inList)}) - GROUP BY vpi.vendor_id - HAVING COUNT(DISTINCT vpi.item_code) = ${itemCount} - ` - ) - const vendorIdList = sqlVendorIds.rows.map((row: any) => +row.vendorId) - if (vendorIdList.length === 0) { - return { data: [], pageCount: 0 } - } + // ───────────────────────────────────────────────────── + // 2) vendorPossibleItems에서 모든 itemCodes를 보유한 vendor + // ───────────────────────────────────────────────────── + const inList = itemCodes.map((c) => `'${c}'`).join(",") + const sqlVendorIds = await db.execute( + sql` + SELECT vpi.vendor_id AS "vendorId" + FROM ${vendorPossibleItems} vpi + WHERE vpi.item_code IN (${sql.raw(inList)}) + GROUP BY vpi.vendor_id + HAVING COUNT(DISTINCT vpi.item_code) = ${itemCount} + ` + ) + const vendorIdList = sqlVendorIds.rows.map((row: any) => +row.vendorId) + if (vendorIdList.length === 0) { + return { data: [], pageCount: 0 } + } - // ───────────────────────────────────────────────────── - // 3) 필터/검색/정렬 - // ───────────────────────────────────────────────────── - const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10) - const limit = input.perPage ?? 10 + // ───────────────────────────────────────────────────── + // 3) 필터/검색/정렬 + // ───────────────────────────────────────────────────── + const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10) + const limit = input.perPage ?? 10 + + // (가) 커스텀 필터 + // 여기서는 "뷰(vendorRfqView)"의 컬럼들에 대해 필터합니다. + const advancedWhere = filterColumns({ + // 테이블이 아니라 "뷰"를 넘길 수도 있고, + // 혹은 columns 객체(연결된 모든 컬럼)로 넘겨도 됩니다. + table: vendorRfqView, + filters: input.filters ?? [], + joinOperator: input.joinOperator ?? "and", + }) - // (가) 커스텀 필터 - // 여기서는 "뷰(vendorRfqView)"의 컬럼들에 대해 필터합니다. - const advancedWhere = filterColumns({ - // 테이블이 아니라 "뷰"를 넘길 수도 있고, - // 혹은 columns 객체(연결된 모든 컬럼)로 넘겨도 됩니다. - table: vendorRfqView, - filters: input.filters ?? [], - joinOperator: input.joinOperator ?? "and", - }) + // (나) 글로벌 검색 + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or( + sql`${vendorRfqView.vendorName} ILIKE ${s}`, + sql`${vendorRfqView.vendorCode} ILIKE ${s}`, + sql`${vendorRfqView.email} ILIKE ${s}` + ) + } - // (나) 글로벌 검색 - let globalWhere - if (input.search) { - const s = `%${input.search}%` - globalWhere = or( - sql`${vendorRfqView.vendorName} ILIKE ${s}`, - sql`${vendorRfqView.vendorCode} ILIKE ${s}`, - sql`${vendorRfqView.email} ILIKE ${s}` - ) - } + // (다) 최종 where + // vendorId가 vendorIdList 내에 있어야 하고, + // 특정 rfqId(뷰에 담긴 값)도 일치해야 함. + const finalWhere = and( + inArray(vendorRfqView.vendorId, vendorIdList), + // 아래 라인은 rfq에 초대된 벤더만 필터링하는 조건으로 추정되지만 + // rfq 를 진행하기 전에도 벤더를 보여줘야 하므로 주석처리하겠습니다 + // eq(vendorRfqView.rfqId, rfqId), + advancedWhere, + globalWhere + ) + + // (라) 정렬 + const orderBy = input.sort?.length + ? input.sort.map((s) => { + // "column id" -> vendorRfqView.* 중 하나 + const col = (vendorRfqView as any)[s.id] + return s.desc ? desc(col) : asc(col) + }) + : [asc(vendorRfqView.vendorId)] - // (다) 최종 where - // vendorId가 vendorIdList 내에 있어야 하고, - // 특정 rfqId(뷰에 담긴 값)도 일치해야 함. - const finalWhere = and( - inArray(vendorRfqView.vendorId, vendorIdList), - // 아래 라인은 rfq에 초대된 벤더만 필터링하는 조건으로 추정되지만 - // rfq 를 진행하기 전에도 벤더를 보여줘야 하므로 주석처리하겠습니다 - // eq(vendorRfqView.rfqId, rfqId), - advancedWhere, - globalWhere + // ───────────────────────────────────────────────────── + // 4) View에서 데이터 SELECT + // ───────────────────────────────────────────────────── + const [rows, total] = await db.transaction(async (tx) => { + const data = await tx + .select({ + id: vendorRfqView.vendorId, + vendorID: vendorRfqView.vendorId, + vendorName: vendorRfqView.vendorName, + vendorCode: vendorRfqView.vendorCode, + address: vendorRfqView.address, + country: vendorRfqView.country, + email: vendorRfqView.email, + website: vendorRfqView.website, + vendorStatus: vendorRfqView.vendorStatus, + // rfqVendorStatus와 rfqVendorUpdated는 나중에 정확한 데이터로 교체할 예정 + rfqVendorStatus: vendorRfqView.rfqVendorStatus, + rfqVendorUpdated: vendorRfqView.rfqVendorUpdated, + }) + .from(vendorRfqView) + .where(finalWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(limit) + + // 중복 제거된 데이터 생성 + const distinctData = Array.from( + new Map(data.map(row => [row.id, row])).values() ) - // (라) 정렬 - const orderBy = input.sort?.length - ? input.sort.map((s) => { - // "column id" -> vendorRfqView.* 중 하나 - const col = (vendorRfqView as any)[s.id] - return s.desc ? desc(col) : asc(col) - }) - : [asc(vendorRfqView.vendorId)] + // 중복 제거된 총 개수 계산 + const [{ count }] = await tx + .select({ count: sql<number>`count(DISTINCT ${vendorRfqView.vendorId})`.as("count") }) + .from(vendorRfqView) + .where(finalWhere) - // ───────────────────────────────────────────────────── - // 4) View에서 데이터 SELECT - // ───────────────────────────────────────────────────── - const [rows, total] = await db.transaction(async (tx) => { - const data = await tx - .select({ - id: vendorRfqView.vendorId, - vendorID: vendorRfqView.vendorId, - vendorName: vendorRfqView.vendorName, - vendorCode: vendorRfqView.vendorCode, - address: vendorRfqView.address, - country: vendorRfqView.country, - email: vendorRfqView.email, - website: vendorRfqView.website, - vendorStatus: vendorRfqView.vendorStatus, - // rfqVendorStatus와 rfqVendorUpdated는 나중에 정확한 데이터로 교체할 예정 - rfqVendorStatus: vendorRfqView.rfqVendorStatus, - rfqVendorUpdated: vendorRfqView.rfqVendorUpdated, - }) - .from(vendorRfqView) - .where(finalWhere) - .orderBy(...orderBy) - .offset(offset) - .limit(limit) + return [distinctData, Number(count)] + }) - // 중복 제거된 데이터 생성 - const distinctData = Array.from( - new Map(data.map(row => [row.id, row])).values() - ) - // 중복 제거된 총 개수 계산 - const [{ count }] = await tx - .select({ count: sql<number>`count(DISTINCT ${vendorRfqView.vendorId})`.as("count") }) - .from(vendorRfqView) - .where(finalWhere) + // ───────────────────────────────────────────────────── + // 4-1) 정확한 rfqVendorStatus와 rfqVendorUpdated 조회 + // ───────────────────────────────────────────────────── + const distinctVendorIds = [...new Set(rows.map((r) => r.id))] - return [distinctData, Number(count)] + // vendorResponses 테이블에서 정확한 상태와 업데이트 시간 조회 + const vendorStatuses = await db + .select({ + vendorId: vendorResponses.vendorId, + status: vendorResponses.responseStatus, + updatedAt: vendorResponses.updatedAt }) - - - // ───────────────────────────────────────────────────── - // 4-1) 정확한 rfqVendorStatus와 rfqVendorUpdated 조회 - // ───────────────────────────────────────────────────── - const distinctVendorIds = [...new Set(rows.map((r) => r.id))] - - // vendorResponses 테이블에서 정확한 상태와 업데이트 시간 조회 - const vendorStatuses = await db - .select({ - vendorId: vendorResponses.vendorId, - status: vendorResponses.responseStatus, - updatedAt: vendorResponses.updatedAt - }) - .from(vendorResponses) - .where( - and( - inArray(vendorResponses.vendorId, distinctVendorIds), - eq(vendorResponses.rfqId, rfqId) - ) + .from(vendorResponses) + .where( + and( + inArray(vendorResponses.vendorId, distinctVendorIds), + eq(vendorResponses.rfqId, rfqId) ) + ) - // vendorId별 상태정보 맵 생성 - const statusMap = new Map<number, { status: string, updatedAt: Date }>() - for (const vs of vendorStatuses) { - statusMap.set(vs.vendorId, { - status: vs.status, - updatedAt: vs.updatedAt - }) - } + // vendorId별 상태정보 맵 생성 + const statusMap = new Map<number, { status: string, updatedAt: Date }>() + for (const vs of vendorStatuses) { + statusMap.set(vs.vendorId, { + status: vs.status, + updatedAt: vs.updatedAt + }) + } - // 정확한 상태 정보로 업데이트된 rows 생성 - const updatedRows = rows.map(row => ({ - ...row, - rfqVendorStatus: statusMap.get(row.id)?.status || null, - rfqVendorUpdated: statusMap.get(row.id)?.updatedAt || null - })) - - // ───────────────────────────────────────────────────── - // 5) 코멘트 조회: 기존과 동일 - // ───────────────────────────────────────────────────── - console.log("distinctVendorIds", distinctVendorIds) - const commAll = await db - .select() - .from(rfqComments) - .where( - and( - inArray(rfqComments.vendorId, distinctVendorIds), - eq(rfqComments.rfqId, rfqId), - isNull(rfqComments.evaluationId), - isNull(rfqComments.cbeId) - ) + // 정확한 상태 정보로 업데이트된 rows 생성 + const updatedRows = rows.map(row => ({ + ...row, + rfqVendorStatus: statusMap.get(row.id)?.status || null, + rfqVendorUpdated: statusMap.get(row.id)?.updatedAt || null + })) + + // ───────────────────────────────────────────────────── + // 5) 코멘트 조회: 기존과 동일 + // ───────────────────────────────────────────────────── + console.log("distinctVendorIds", distinctVendorIds) + const commAll = await db + .select() + .from(rfqComments) + .where( + and( + inArray(rfqComments.vendorId, distinctVendorIds), + eq(rfqComments.rfqId, rfqId), + isNull(rfqComments.evaluationId), + isNull(rfqComments.cbeId) ) + ) - const commByVendorId = new Map<number, any[]>() - // 먼저 모든 사용자 ID를 수집 - const userIds = new Set(commAll.map(c => c.commentedBy)); - const userIdsArray = Array.from(userIds); - - // Drizzle의 select 메서드를 사용하여 사용자 정보를 가져옴 - const usersData = await db - .select({ - id: users.id, - email: users.email, - }) - .from(users) - .where(inArray(users.id, userIdsArray)); - - // 사용자 ID를 키로 하는 맵 생성 - const userMap = new Map(); - for (const user of usersData) { - userMap.set(user.id, user); - } - - // 댓글 정보를 협력업체 ID별로 그룹화하고, 사용자 이메일 추가 - for (const c of commAll) { - const vid = c.vendorId! - if (!commByVendorId.has(vid)) { - commByVendorId.set(vid, []) - } + const commByVendorId = new Map<number, any[]>() + // 먼저 모든 사용자 ID를 수집 + const userIds = new Set(commAll.map(c => c.commentedBy)); + const userIdsArray = Array.from(userIds); - // 사용자 정보 가져오기 - const user = userMap.get(c.commentedBy); - const userEmail = user ? user.email : 'unknown@example.com'; // 사용자를 찾지 못한 경우 기본값 설정 + // Drizzle의 select 메서드를 사용하여 사용자 정보를 가져옴 + const usersData = await db + .select({ + id: users.id, + email: users.email, + }) + .from(users) + .where(inArray(users.id, userIdsArray)); - commByVendorId.get(vid)!.push({ - id: c.id, - commentText: c.commentText, - vendorId: c.vendorId, - evaluationId: c.evaluationId, - createdAt: c.createdAt, - commentedBy: c.commentedBy, - commentedByEmail: userEmail, // 이메일 추가 - }) - } - // ───────────────────────────────────────────────────── - // 6) rows에 comments 병합 - // ───────────────────────────────────────────────────── - const final = updatedRows.map((row) => ({ - ...row, - comments: commByVendorId.get(row.id) ?? [], - })) + // 사용자 ID를 키로 하는 맵 생성 + const userMap = new Map(); + for (const user of usersData) { + userMap.set(user.id, user); + } - // ───────────────────────────────────────────────────── - // 7) 반환 - // ───────────────────────────────────────────────────── - const pageCount = Math.ceil(total / limit) - return { data: final, pageCount } - }, - [JSON.stringify({ input, rfqId })], - { revalidate: 3600, tags: ["vendors", `rfq-${rfqId}`] } - )() + // 댓글 정보를 협력업체 ID별로 그룹화하고, 사용자 이메일 추가 + for (const c of commAll) { + const vid = c.vendorId! + if (!commByVendorId.has(vid)) { + commByVendorId.set(vid, []) + } + + // 사용자 정보 가져오기 + const user = userMap.get(c.commentedBy); + const userEmail = user ? user.email : 'unknown@example.com'; // 사용자를 찾지 못한 경우 기본값 설정 + + commByVendorId.get(vid)!.push({ + id: c.id, + commentText: c.commentText, + vendorId: c.vendorId, + evaluationId: c.evaluationId, + createdAt: c.createdAt, + commentedBy: c.commentedBy, + commentedByEmail: userEmail, // 이메일 추가 + }) + } + // ───────────────────────────────────────────────────── + // 6) rows에 comments 병합 + // ───────────────────────────────────────────────────── + const final = updatedRows.map((row) => ({ + ...row, + comments: commByVendorId.get(row.id) ?? [], + })) + + // ───────────────────────────────────────────────────── + // 7) 반환 + // ───────────────────────────────────────────────────── + const pageCount = Math.ceil(total / limit) + return { data: final, pageCount } + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } } export async function inviteVendors(input: InviteVendorsInput) { - unstable_noStore() // 서버 액션 캐싱 방지 try { const { rfqId, vendorIds } = input if (!rfqId || !Array.isArray(vendorIds) || vendorIds.length === 0) { @@ -840,7 +791,7 @@ export async function inviteVendors(input: InviteVendorsInput) { }) const { rfqRow, items, vendorRows, attachments } = rfqData - const loginUrl = `http://${host}/en/partners/rfq` + const loginUrl = `http://${host}/en/partners/rfq-tech` // 이메일 전송 오류를 기록할 배열 const emailErrors = [] @@ -922,22 +873,6 @@ export async function inviteVendors(input: InviteVendorsInput) { console.log(`Updated RFQ #${rfqId} status to PUBLISHED`) }) - - // 캐시 무효화 - revalidateTag("tbe-vendors") - revalidateTag("all-tbe-vendors") - revalidateTag("rfq-vendors") - revalidateTag("cbe-vendors") - revalidateTag("rfqs") - revalidateTag(`rfq-${rfqId}`) - // revalidateTag("rfqs"); - // revalidateTag(`rfq-${rfqId}`); - // revalidateTag("vendors"); - // revalidateTag("rfq-vendors"); - // vendorIds.forEach(vendorId => { - // revalidateTag(`vendor-${vendorId}`); - // }); - // 이메일 오류가 있었는지 확인 if (emailErrors.length > 0) { return { @@ -960,273 +895,268 @@ export async function inviteVendors(input: InviteVendorsInput) { * TBE용 평가 데이터 목록 조회 */ export async function getTBE(input: GetTBESchema, rfqId: number) { - return unstable_cache( - async () => { - // 1) 페이징 - const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10) - const limit = input.perPage ?? 10 - - // 2) 고급 필터 - const advancedWhere = filterColumns({ - table: vendorTbeView, - filters: input.filters ?? [], - joinOperator: input.joinOperator ?? "and", - }) - - // 3) 글로벌 검색 - let globalWhere - if (input.search) { - const s = `%${input.search}%` - globalWhere = or( - sql`${vendorTbeView.vendorName} ILIKE ${s}`, - sql`${vendorTbeView.vendorCode} ILIKE ${s}`, - sql`${vendorTbeView.email} ILIKE ${s}` - ) - } + try { + // 1) 페이징 + const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10) + const limit = input.perPage ?? 10 + + // 2) 고급 필터 + const advancedWhere = filterColumns({ + table: vendorTbeView, + filters: input.filters ?? [], + joinOperator: input.joinOperator ?? "and", + }) - // 4) REJECTED 아니거나 NULL - const notRejected = or( - ne(vendorTbeView.rfqVendorStatus, "REJECTED"), - isNull(vendorTbeView.rfqVendorStatus) + // 3) 글로벌 검색 + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or( + sql`${vendorTbeView.vendorName} ILIKE ${s}`, + sql`${vendorTbeView.vendorCode} ILIKE ${s}`, + sql`${vendorTbeView.email} ILIKE ${s}` ) + } - // 5) finalWhere - const finalWhere = and( - eq(vendorTbeView.rfqId, rfqId), - advancedWhere, - globalWhere - ) + // 4) REJECTED 아니거나 NULL + const notRejected = or( + ne(vendorTbeView.rfqVendorStatus, "REJECTED"), + isNull(vendorTbeView.rfqVendorStatus) + ) + + // 5) finalWhere + const finalWhere = and( + eq(vendorTbeView.rfqId, rfqId), + advancedWhere, + globalWhere + ) + + // 6) 정렬 + const orderBy = input.sort?.length + ? input.sort.map((s) => { + const col = (vendorTbeView as any)[s.id] + return s.desc ? desc(col) : asc(col) + }) + : [asc(vendorTbeView.vendorId)] - // 6) 정렬 - const orderBy = input.sort?.length - ? input.sort.map((s) => { - const col = (vendorTbeView as any)[s.id] - return s.desc ? desc(col) : asc(col) + // 7) 메인 SELECT + const [rows, total] = await db.transaction(async (tx) => { + const data = await tx + .select({ + // 원하는 컬럼들 + id: vendorTbeView.vendorId, + tbeId: vendorTbeView.tbeId, + vendorId: vendorTbeView.vendorId, + vendorName: vendorTbeView.vendorName, + vendorCode: vendorTbeView.vendorCode, + address: vendorTbeView.address, + country: vendorTbeView.country, + email: vendorTbeView.email, + website: vendorTbeView.website, + vendorStatus: vendorTbeView.vendorStatus, + + rfqId: vendorTbeView.rfqId, + rfqCode: vendorTbeView.rfqCode, + projectCode: vendorTbeView.projectCode, + projectName: vendorTbeView.projectName, + description: vendorTbeView.description, + dueDate: vendorTbeView.dueDate, + + rfqVendorStatus: vendorTbeView.rfqVendorStatus, + rfqVendorUpdated: vendorTbeView.rfqVendorUpdated, + + tbeResult: vendorTbeView.tbeResult, + tbeNote: vendorTbeView.tbeNote, + tbeUpdated: vendorTbeView.tbeUpdated, + + technicalResponseId:vendorTbeView.technicalResponseId, + technicalResponseStatus:vendorTbeView.technicalResponseStatus, + technicalSummary:vendorTbeView.technicalSummary, + technicalNotes:vendorTbeView.technicalNotes, + technicalUpdated:vendorTbeView.technicalUpdated, }) - : [asc(vendorTbeView.vendorId)] - - // 7) 메인 SELECT - const [rows, total] = await db.transaction(async (tx) => { - const data = await tx - .select({ - // 원하는 컬럼들 - id: vendorTbeView.vendorId, - tbeId: vendorTbeView.tbeId, - vendorId: vendorTbeView.vendorId, - vendorName: vendorTbeView.vendorName, - vendorCode: vendorTbeView.vendorCode, - address: vendorTbeView.address, - country: vendorTbeView.country, - email: vendorTbeView.email, - website: vendorTbeView.website, - vendorStatus: vendorTbeView.vendorStatus, - - rfqId: vendorTbeView.rfqId, - rfqCode: vendorTbeView.rfqCode, - projectCode: vendorTbeView.projectCode, - projectName: vendorTbeView.projectName, - description: vendorTbeView.description, - dueDate: vendorTbeView.dueDate, - - rfqVendorStatus: vendorTbeView.rfqVendorStatus, - rfqVendorUpdated: vendorTbeView.rfqVendorUpdated, - - tbeResult: vendorTbeView.tbeResult, - tbeNote: vendorTbeView.tbeNote, - tbeUpdated: vendorTbeView.tbeUpdated, + .from(vendorTbeView) + .where(finalWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(limit) + + const [{ count }] = await tx + .select({ count: sql<number>`count(*)`.as("count") }) + .from(vendorTbeView) + .where(finalWhere) + + return [data, Number(count)] + }) - technicalResponseId:vendorTbeView.technicalResponseId, - technicalResponseStatus:vendorTbeView.technicalResponseStatus, - technicalSummary:vendorTbeView.technicalSummary, - technicalNotes:vendorTbeView.technicalNotes, - technicalUpdated:vendorTbeView.technicalUpdated, - }) - .from(vendorTbeView) - .where(finalWhere) - .orderBy(...orderBy) - .offset(offset) - .limit(limit) + if (!rows.length) { + return { data: [], pageCount: 0 } + } - const [{ count }] = await tx - .select({ count: sql<number>`count(*)`.as("count") }) - .from(vendorTbeView) - .where(finalWhere) + // 8) Comments 조회 + const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))] - return [data, Number(count)] + const commAll = await db + .select({ + id: rfqComments.id, + commentText: rfqComments.commentText, + vendorId: rfqComments.vendorId, + evaluationId: rfqComments.evaluationId, + createdAt: rfqComments.createdAt, + commentedBy: rfqComments.commentedBy, + evalType: rfqEvaluations.evalType, }) - - if (!rows.length) { - return { data: [], pageCount: 0 } - } - - // 8) Comments 조회 - const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))] - - const commAll = await db - .select({ - id: rfqComments.id, - commentText: rfqComments.commentText, - vendorId: rfqComments.vendorId, - evaluationId: rfqComments.evaluationId, - createdAt: rfqComments.createdAt, - commentedBy: rfqComments.commentedBy, - evalType: rfqEvaluations.evalType, - }) - .from(rfqComments) - .innerJoin( - rfqEvaluations, - and( - eq(rfqEvaluations.id, rfqComments.evaluationId), - eq(rfqEvaluations.evalType, "TBE") - ) + .from(rfqComments) + .innerJoin( + rfqEvaluations, + and( + eq(rfqEvaluations.id, rfqComments.evaluationId), + eq(rfqEvaluations.evalType, "TBE") ) - .where( - and( - isNotNull(rfqComments.evaluationId), - eq(rfqComments.rfqId, rfqId), - inArray(rfqComments.vendorId, distinctVendorIds) - ) + ) + .where( + and( + isNotNull(rfqComments.evaluationId), + eq(rfqComments.rfqId, rfqId), + inArray(rfqComments.vendorId, distinctVendorIds) ) + ) - // 8-A) vendorId -> comments grouping - const commByVendorId = new Map<number, any[]>() - for (const c of commAll) { - const vid = c.vendorId! - if (!commByVendorId.has(vid)) { - commByVendorId.set(vid, []) - } - commByVendorId.get(vid)!.push({ - id: c.id, - commentText: c.commentText, - vendorId: c.vendorId, - evaluationId: c.evaluationId, - createdAt: c.createdAt, - commentedBy: c.commentedBy, - }) - } + // 8-A) vendorId -> comments grouping + const commByVendorId = new Map<number, any[]>() + for (const c of commAll) { + const vid = c.vendorId! + if (!commByVendorId.has(vid)) { + commByVendorId.set(vid, []) + } + commByVendorId.get(vid)!.push({ + id: c.id, + commentText: c.commentText, + vendorId: c.vendorId, + evaluationId: c.evaluationId, + createdAt: c.createdAt, + commentedBy: c.commentedBy, + }) + } - // 9) TBE 파일 조회 - vendorResponseAttachments로 대체 - // Step 1: Get vendorResponses for the rfqId and vendorIds - const responsesAll = await db - .select({ - id: vendorResponses.id, - vendorId: vendorResponses.vendorId - }) - .from(vendorResponses) - .where( - and( - eq(vendorResponses.rfqId, rfqId), - inArray(vendorResponses.vendorId, distinctVendorIds) - ) - ); + // 9) TBE 파일 조회 - vendorResponseAttachments로 대체 + // Step 1: Get vendorResponses for the rfqId and vendorIds + const responsesAll = await db + .select({ + id: vendorResponses.id, + vendorId: vendorResponses.vendorId + }) + .from(vendorResponses) + .where( + and( + eq(vendorResponses.rfqId, rfqId), + inArray(vendorResponses.vendorId, distinctVendorIds) + ) + ); - // Group responses by vendorId for later lookup - const responsesByVendorId = new Map<number, number[]>(); - for (const resp of responsesAll) { - if (!responsesByVendorId.has(resp.vendorId)) { - responsesByVendorId.set(resp.vendorId, []); - } - responsesByVendorId.get(resp.vendorId)!.push(resp.id); + // Group responses by vendorId for later lookup + const responsesByVendorId = new Map<number, number[]>(); + for (const resp of responsesAll) { + if (!responsesByVendorId.has(resp.vendorId)) { + responsesByVendorId.set(resp.vendorId, []); } + responsesByVendorId.get(resp.vendorId)!.push(resp.id); + } - // Step 2: Get all responseIds - const allResponseIds = responsesAll.map(r => r.id); + // Step 2: Get all responseIds + const allResponseIds = responsesAll.map(r => r.id); - // Step 3: Get technicalResponses for these responseIds - const technicalResponsesAll = await db - .select({ - id: vendorTechnicalResponses.id, - responseId: vendorTechnicalResponses.responseId - }) - .from(vendorTechnicalResponses) - .where(inArray(vendorTechnicalResponses.responseId, allResponseIds)); + // Step 3: Get technicalResponses for these responseIds + const technicalResponsesAll = await db + .select({ + id: vendorTechnicalResponses.id, + responseId: vendorTechnicalResponses.responseId + }) + .from(vendorTechnicalResponses) + .where(inArray(vendorTechnicalResponses.responseId, allResponseIds)); - // Create mapping from responseId to technicalResponseIds - const technicalResponseIdsByResponseId = new Map<number, number[]>(); - for (const tr of technicalResponsesAll) { - if (!technicalResponseIdsByResponseId.has(tr.responseId)) { - technicalResponseIdsByResponseId.set(tr.responseId, []); - } - technicalResponseIdsByResponseId.get(tr.responseId)!.push(tr.id); + // Create mapping from responseId to technicalResponseIds + const technicalResponseIdsByResponseId = new Map<number, number[]>(); + for (const tr of technicalResponsesAll) { + if (!technicalResponseIdsByResponseId.has(tr.responseId)) { + technicalResponseIdsByResponseId.set(tr.responseId, []); } + technicalResponseIdsByResponseId.get(tr.responseId)!.push(tr.id); + } - // Step 4: Get all technicalResponseIds - const allTechnicalResponseIds = technicalResponsesAll.map(tr => tr.id); + // Step 4: Get all technicalResponseIds + const allTechnicalResponseIds = technicalResponsesAll.map(tr => tr.id); - // Step 5: Get attachments for these technicalResponseIds - const filesAll = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - technicalResponseId: vendorResponseAttachments.technicalResponseId, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - uploadedAt: vendorResponseAttachments.uploadedAt, - uploadedBy: vendorResponseAttachments.uploadedBy - }) - .from(vendorResponseAttachments) - .where( - and( - inArray(vendorResponseAttachments.technicalResponseId, allTechnicalResponseIds), - isNotNull(vendorResponseAttachments.technicalResponseId) - ) - ); - - // Step 6: Create mapping from technicalResponseId to attachments - const filesByTechnicalResponseId = new Map<number, any[]>(); - for (const file of filesAll) { - // Skip if technicalResponseId is null (should never happen due to our filter above) - if (file.technicalResponseId === null) continue; + // Step 5: Get attachments for these technicalResponseIds + const filesAll = await db + .select({ + id: vendorResponseAttachments.id, + fileName: vendorResponseAttachments.fileName, + filePath: vendorResponseAttachments.filePath, + technicalResponseId: vendorResponseAttachments.technicalResponseId, + fileType: vendorResponseAttachments.fileType, + attachmentType: vendorResponseAttachments.attachmentType, + description: vendorResponseAttachments.description, + uploadedAt: vendorResponseAttachments.uploadedAt, + uploadedBy: vendorResponseAttachments.uploadedBy + }) + .from(vendorResponseAttachments) + .where( + and( + inArray(vendorResponseAttachments.technicalResponseId, allTechnicalResponseIds), + isNotNull(vendorResponseAttachments.technicalResponseId) + ) + ); - if (!filesByTechnicalResponseId.has(file.technicalResponseId)) { - filesByTechnicalResponseId.set(file.technicalResponseId, []); - } - filesByTechnicalResponseId.get(file.technicalResponseId)!.push({ - id: file.id, - fileName: file.fileName, - filePath: file.filePath, - fileType: file.fileType, - attachmentType: file.attachmentType, - description: file.description, - uploadedAt: file.uploadedAt, - uploadedBy: file.uploadedBy - }); - } + // Step 6: Create mapping from technicalResponseId to attachments + const filesByTechnicalResponseId = new Map<number, any[]>(); + for (const file of filesAll) { + // Skip if technicalResponseId is null (should never happen due to our filter above) + if (file.technicalResponseId === null) continue; + + if (!filesByTechnicalResponseId.has(file.technicalResponseId)) { + filesByTechnicalResponseId.set(file.technicalResponseId, []); + } + filesByTechnicalResponseId.get(file.technicalResponseId)!.push({ + id: file.id, + fileName: file.fileName, + filePath: file.filePath, + fileType: file.fileType, + attachmentType: file.attachmentType, + description: file.description, + uploadedAt: file.uploadedAt, + uploadedBy: file.uploadedBy + }); + } - // Step 7: Create the final filesByVendorId map - const filesByVendorId = new Map<number, any[]>(); - for (const [vendorId, responseIds] of responsesByVendorId.entries()) { - filesByVendorId.set(vendorId, []); + // Step 7: Create the final filesByVendorId map + const filesByVendorId = new Map<number, any[]>(); + for (const [vendorId, responseIds] of responsesByVendorId.entries()) { + filesByVendorId.set(vendorId, []); - for (const responseId of responseIds) { - const technicalResponseIds = technicalResponseIdsByResponseId.get(responseId) || []; + for (const responseId of responseIds) { + const technicalResponseIds = technicalResponseIdsByResponseId.get(responseId) || []; - for (const technicalResponseId of technicalResponseIds) { - const files = filesByTechnicalResponseId.get(technicalResponseId) || []; - filesByVendorId.get(vendorId)!.push(...files); - } + for (const technicalResponseId of technicalResponseIds) { + const files = filesByTechnicalResponseId.get(technicalResponseId) || []; + filesByVendorId.get(vendorId)!.push(...files); } } + } - // 10) 최종 합치기 - const final = rows.map((row) => ({ - ...row, - dueDate: row.dueDate ? new Date(row.dueDate) : null, - comments: commByVendorId.get(row.vendorId) ?? [], - files: filesByVendorId.get(row.vendorId) ?? [], - })) + // 10) 최종 합치기 + const final = rows.map((row) => ({ + ...row, + dueDate: row.dueDate ? new Date(row.dueDate) : null, + comments: commByVendorId.get(row.vendorId) ?? [], + files: filesByVendorId.get(row.vendorId) ?? [], + })) - const pageCount = Math.ceil(total / limit) - return { data: final, pageCount } - }, - [JSON.stringify({ input, rfqId })], - { - revalidate: 3600, - tags: ["tbe", `rfq-${rfqId}`], - } - )() + const pageCount = Math.ceil(total / limit) + return { data: final, pageCount } + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } } export async function getTBEforVendor(input: GetTBESchema, vendorId: number) { @@ -1235,246 +1165,239 @@ export async function getTBEforVendor(input: GetTBESchema, vendorId: number) { throw new Error("유효하지 않은 vendorId: 숫자 값이 필요합니다"); } - return unstable_cache( - async () => { - // 1) 페이징 - const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10) - const limit = input.perPage ?? 10 - - // 2) 고급 필터 - const advancedWhere = filterColumns({ - table: vendorTbeView, - filters: input.filters ?? [], - joinOperator: input.joinOperator ?? "and", - }) - - // 3) 글로벌 검색 - let globalWhere - if (input.search) { - const s = `%${input.search}%` - globalWhere = or( - sql`${vendorTbeView.vendorName} ILIKE ${s}`, - sql`${vendorTbeView.vendorCode} ILIKE ${s}`, - sql`${vendorTbeView.email} ILIKE ${s}` - ) - } + try { + // 1) 페이징 + const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10) + const limit = input.perPage ?? 10 + + // 2) 고급 필터 + const advancedWhere = filterColumns({ + table: vendorTbeView, + filters: input.filters ?? [], + joinOperator: input.joinOperator ?? "and", + }) - // 4) REJECTED 아니거나 NULL - const notRejected = or( - ne(vendorTbeView.rfqVendorStatus, "REJECTED"), - isNull(vendorTbeView.rfqVendorStatus) + // 3) 글로벌 검색 + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or( + sql`${vendorTbeView.vendorName} ILIKE ${s}`, + sql`${vendorTbeView.vendorCode} ILIKE ${s}`, + sql`${vendorTbeView.email} ILIKE ${s}` ) + } - // 5) finalWhere - const finalWhere = and( - isNotNull(vendorTbeView.tbeId), - eq(vendorTbeView.vendorId, vendorId), - // notRejected, - advancedWhere, - globalWhere - ) + // 4) REJECTED 아니거나 NULL + const notRejected = or( + ne(vendorTbeView.rfqVendorStatus, "REJECTED"), + isNull(vendorTbeView.rfqVendorStatus) + ) + + // 5) finalWhere + const finalWhere = and( + isNotNull(vendorTbeView.tbeId), + eq(vendorTbeView.vendorId, vendorId), + // notRejected, + advancedWhere, + globalWhere + ) + + // 6) 정렬 + const orderBy = input.sort?.length + ? input.sort.map((s) => { + const col = (vendorTbeView as any)[s.id] + return s.desc ? desc(col) : asc(col) + }) + : [asc(vendorTbeView.vendorId)] - // 6) 정렬 - const orderBy = input.sort?.length - ? input.sort.map((s) => { - const col = (vendorTbeView as any)[s.id] - return s.desc ? desc(col) : asc(col) + // 7) 메인 SELECT - vendor 기준으로 GROUP BY + const [rows, total] = await db.transaction(async (tx) => { + const data = await tx + .select({ + // 원하는 컬럼들 + id: vendorTbeView.vendorId, + tbeId: vendorTbeView.tbeId, + vendorId: vendorTbeView.vendorId, + vendorName: vendorTbeView.vendorName, + vendorCode: vendorTbeView.vendorCode, + address: vendorTbeView.address, + country: vendorTbeView.country, + email: vendorTbeView.email, + website: vendorTbeView.website, + vendorStatus: vendorTbeView.vendorStatus, + + rfqId: vendorTbeView.rfqId, + rfqCode: vendorTbeView.rfqCode, + + rfqStatus:vendorTbeView.rfqStatus, + rfqDescription: vendorTbeView.description, + rfqDueDate: vendorTbeView.dueDate, + + projectCode: vendorTbeView.projectCode, + projectName: vendorTbeView.projectName, + description: vendorTbeView.description, + dueDate: vendorTbeView.dueDate, + + vendorResponseId: vendorTbeView.vendorResponseId, + rfqVendorStatus: vendorTbeView.rfqVendorStatus, + rfqVendorUpdated: vendorTbeView.rfqVendorUpdated, + + tbeResult: vendorTbeView.tbeResult, + tbeNote: vendorTbeView.tbeNote, + tbeUpdated: vendorTbeView.tbeUpdated, }) - : [asc(vendorTbeView.vendorId)] - - // 7) 메인 SELECT - vendor 기준으로 GROUP BY - const [rows, total] = await db.transaction(async (tx) => { - const data = await tx - .select({ - // 원하는 컬럼들 - id: vendorTbeView.vendorId, - tbeId: vendorTbeView.tbeId, - vendorId: vendorTbeView.vendorId, - vendorName: vendorTbeView.vendorName, - vendorCode: vendorTbeView.vendorCode, - address: vendorTbeView.address, - country: vendorTbeView.country, - email: vendorTbeView.email, - website: vendorTbeView.website, - vendorStatus: vendorTbeView.vendorStatus, - - rfqId: vendorTbeView.rfqId, - rfqCode: vendorTbeView.rfqCode, - - rfqStatus:vendorTbeView.rfqStatus, - rfqDescription: vendorTbeView.description, - rfqDueDate: vendorTbeView.dueDate, - - projectCode: vendorTbeView.projectCode, - projectName: vendorTbeView.projectName, - description: vendorTbeView.description, - dueDate: vendorTbeView.dueDate, - - vendorResponseId: vendorTbeView.vendorResponseId, - rfqVendorStatus: vendorTbeView.rfqVendorStatus, - rfqVendorUpdated: vendorTbeView.rfqVendorUpdated, + .from(vendorTbeView) + .where(finalWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(limit) + + const [{ count }] = await tx + .select({ count: sql<number>`count(*)`.as("count") }) + .from(vendorTbeView) + .where(finalWhere) + + return [data, Number(count)] + }) - tbeResult: vendorTbeView.tbeResult, - tbeNote: vendorTbeView.tbeNote, - tbeUpdated: vendorTbeView.tbeUpdated, - }) - .from(vendorTbeView) - .where(finalWhere) - .orderBy(...orderBy) - .offset(offset) - .limit(limit) + if (!rows.length) { + return { data: [], pageCount: 0 } + } - const [{ count }] = await tx - .select({ count: sql<number>`count(*)`.as("count") }) - .from(vendorTbeView) - .where(finalWhere) + // 8) Comments 조회 + // - evaluationId != null && evalType = "TBE" + // - => leftJoin(rfqEvaluations) or innerJoin + const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))] + const distinctTbeIds = [...new Set(rows.map((r) => r.tbeId).filter(Boolean))] - return [data, Number(count)] + // (A) 조인 방식 + const commAll = await db + .select({ + id: rfqComments.id, + commentText: rfqComments.commentText, + vendorId: rfqComments.vendorId, + evaluationId: rfqComments.evaluationId, + createdAt: rfqComments.createdAt, + commentedBy: rfqComments.commentedBy, + evalType: rfqEvaluations.evalType, // (optional) }) - - if (!rows.length) { - return { data: [], pageCount: 0 } - } - - // 8) Comments 조회 - // - evaluationId != null && evalType = "TBE" - // - => leftJoin(rfqEvaluations) or innerJoin - const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))] - const distinctTbeIds = [...new Set(rows.map((r) => r.tbeId).filter(Boolean))] - - // (A) 조인 방식 - const commAll = await db - .select({ - id: rfqComments.id, - commentText: rfqComments.commentText, - vendorId: rfqComments.vendorId, - evaluationId: rfqComments.evaluationId, - createdAt: rfqComments.createdAt, - commentedBy: rfqComments.commentedBy, - evalType: rfqEvaluations.evalType, // (optional) - }) - .from(rfqComments) - // evalType = 'TBE' - .innerJoin( - rfqEvaluations, - and( - eq(rfqEvaluations.id, rfqComments.evaluationId), - eq(rfqEvaluations.evalType, "TBE") // ★ TBE만 - ) + .from(rfqComments) + // evalType = 'TBE' + .innerJoin( + rfqEvaluations, + and( + eq(rfqEvaluations.id, rfqComments.evaluationId), + eq(rfqEvaluations.evalType, "TBE") // ★ TBE만 ) - .where( - and( - isNotNull(rfqComments.evaluationId), - inArray(rfqComments.vendorId, distinctVendorIds) - ) + ) + .where( + and( + isNotNull(rfqComments.evaluationId), + inArray(rfqComments.vendorId, distinctVendorIds) ) + ) - // 8-A) vendorId -> comments grouping - const commByVendorId = new Map<number, any[]>() - for (const c of commAll) { - const vid = c.vendorId! - if (!commByVendorId.has(vid)) { - commByVendorId.set(vid, []) - } - commByVendorId.get(vid)!.push({ - id: c.id, - commentText: c.commentText, - vendorId: c.vendorId, - evaluationId: c.evaluationId, - createdAt: c.createdAt, - commentedBy: c.commentedBy, - }) - } + // 8-A) vendorId -> comments grouping + const commByVendorId = new Map<number, any[]>() + for (const c of commAll) { + const vid = c.vendorId! + if (!commByVendorId.has(vid)) { + commByVendorId.set(vid, []) + } + commByVendorId.get(vid)!.push({ + id: c.id, + commentText: c.commentText, + vendorId: c.vendorId, + evaluationId: c.evaluationId, + createdAt: c.createdAt, + commentedBy: c.commentedBy, + }) + } - // 9) TBE 템플릿 파일 수 조회 - const templateFiles = await db - .select({ - tbeId: rfqAttachments.evaluationId, - fileCount: sql<number>`count(*)`.as("file_count"), - }) - .from(rfqAttachments) - .where( - and( - inArray(rfqAttachments.evaluationId, distinctTbeIds), - isNull(rfqAttachments.vendorId), - isNull(rfqAttachments.commentId) - ) + // 9) TBE 템플릿 파일 수 조회 + const templateFiles = await db + .select({ + tbeId: rfqAttachments.evaluationId, + fileCount: sql<number>`count(*)`.as("file_count"), + }) + .from(rfqAttachments) + .where( + and( + inArray(rfqAttachments.evaluationId, distinctTbeIds), + isNull(rfqAttachments.vendorId), + isNull(rfqAttachments.commentId) ) - .groupBy(rfqAttachments.evaluationId) + ) + .groupBy(rfqAttachments.evaluationId) - // tbeId -> fileCount 매핑 - null 체크 추가 - const templateFileCountMap = new Map<number, number>() - for (const tf of templateFiles) { - if (tf.tbeId !== null) { - templateFileCountMap.set(tf.tbeId, Number(tf.fileCount)) - } + // tbeId -> fileCount 매핑 - null 체크 추가 + const templateFileCountMap = new Map<number, number>() + for (const tf of templateFiles) { + if (tf.tbeId !== null) { + templateFileCountMap.set(tf.tbeId, Number(tf.fileCount)) } + } - // 10) TBE 응답 파일 확인 (각 tbeId + vendorId 조합에 대해) - const tbeResponseFiles = await db - .select({ - tbeId: rfqAttachments.evaluationId, - vendorId: rfqAttachments.vendorId, - responseFileCount: sql<number>`count(*)`.as("response_file_count"), - }) - .from(rfqAttachments) - .where( - and( - inArray(rfqAttachments.evaluationId, distinctTbeIds), - inArray(rfqAttachments.vendorId, distinctVendorIds), - isNull(rfqAttachments.commentId) - ) + // 10) TBE 응답 파일 확인 (각 tbeId + vendorId 조합에 대해) + const tbeResponseFiles = await db + .select({ + tbeId: rfqAttachments.evaluationId, + vendorId: rfqAttachments.vendorId, + responseFileCount: sql<number>`count(*)`.as("response_file_count"), + }) + .from(rfqAttachments) + .where( + and( + inArray(rfqAttachments.evaluationId, distinctTbeIds), + inArray(rfqAttachments.vendorId, distinctVendorIds), + isNull(rfqAttachments.commentId) ) - .groupBy(rfqAttachments.evaluationId, rfqAttachments.vendorId) + ) + .groupBy(rfqAttachments.evaluationId, rfqAttachments.vendorId) - // 10-A) TBE 제출 파일 상세 정보 조회 (vendor별로 그룹화) - + // 10-A) TBE 제출 파일 상세 정보 조회 (vendor별로 그룹화) + - // tbeId_vendorId -> hasResponse 매핑 - null 체크 추가 - const tbeResponseMap = new Map<string, number>() - for (const rf of tbeResponseFiles) { - if (rf.tbeId !== null && rf.vendorId !== null) { - const key = `${rf.tbeId}_${rf.vendorId}` - tbeResponseMap.set(key, Number(rf.responseFileCount)) - } + // tbeId_vendorId -> hasResponse 매핑 - null 체크 추가 + const tbeResponseMap = new Map<string, number>() + for (const rf of tbeResponseFiles) { + if (rf.tbeId !== null && rf.vendorId !== null) { + const key = `${rf.tbeId}_${rf.vendorId}` + tbeResponseMap.set(key, Number(rf.responseFileCount)) } + } - // 11) 최종 합치기 - const final = rows.map((row) => { - const tbeId = row.tbeId - const vendorId = row.vendorId + // 11) 최종 합치기 + const final = rows.map((row) => { + const tbeId = row.tbeId + const vendorId = row.vendorId - // 템플릿 파일 수 - const templateFileCount = tbeId !== null ? templateFileCountMap.get(tbeId) || 0 : 0 + // 템플릿 파일 수 + const templateFileCount = tbeId !== null ? templateFileCountMap.get(tbeId) || 0 : 0 - // 응답 파일 여부 - const responseKey = tbeId !== null ? `${tbeId}_${vendorId}` : "" - const responseFileCount = responseKey ? tbeResponseMap.get(responseKey) || 0 : 0 + // 응답 파일 여부 + const responseKey = tbeId !== null ? `${tbeId}_${vendorId}` : "" + const responseFileCount = responseKey ? tbeResponseMap.get(responseKey) || 0 : 0 - return { - ...row, - dueDate: row.dueDate ? new Date(row.dueDate) : null, - comments: commByVendorId.get(row.vendorId) ?? [], - templateFileCount, // 추가: 템플릿 파일 수 - hasResponse: responseFileCount > 0, // 추가: 응답 파일 제출 여부 - } - }) + return { + ...row, + dueDate: row.dueDate ? new Date(row.dueDate) : null, + comments: commByVendorId.get(row.vendorId) ?? [], + templateFileCount, // 추가: 템플릿 파일 수 + hasResponse: responseFileCount > 0, // 추가: 응답 파일 제출 여부 + } + }) - const pageCount = Math.ceil(total / limit) - return { data: final, pageCount } - }, - [JSON.stringify(input), String(vendorId)], // 캐싱 키에 packagesId 추가 - { - revalidate: 3600, - tags: [`tbe-vendor-${vendorId}`], - } - )() + const pageCount = Math.ceil(total / limit) + return { data: final, pageCount } + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } } export async function inviteTbeVendorsAction(formData: FormData) { // 캐싱 방지 - unstable_noStore() - try { // 1) FormData에서 기본 필드 추출 const rfqId = Number(formData.get("rfqId")) @@ -1624,7 +1547,7 @@ export async function inviteTbeVendorsAction(formData: FormData) { // 5) 각 고유 이메일 주소로 초대 메일 발송 const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000' - const loginUrl = `${baseUrl}/ko/partners/rfq` + const loginUrl = `${baseUrl}/ko/partners/rfq-tech` console.log(`협력업체 ID ${vendor.id}(${vendor.name})에 대해 ${uniqueEmails.length}개의 고유 이메일로 TBE 초대 발송`) @@ -1670,12 +1593,6 @@ export async function inviteTbeVendorsAction(formData: FormData) { } } - // 6) 캐시 무효화 - revalidateTag("tbe") - revalidateTag("vendors") - revalidateTag(`rfq-${rfqId}`) - revalidateTag("tbe-vendors") - }) // 성공 @@ -1689,7 +1606,6 @@ export async function inviteTbeVendorsAction(formData: FormData) { export async function modifyRfqVendor(input: UpdateRfqVendorSchema) { - unstable_noStore(); try { const data = await db.transaction(async (tx) => { const [res] = await updateRfqVendor(tx, input.id, { @@ -1698,9 +1614,6 @@ export async function modifyRfqVendor(input: UpdateRfqVendorSchema) { return res; }); - revalidateTag("vendors"); - revalidateTag("tbe"); - return { data: null, error: null }; } catch (err) { return { data: null, error: getErrorMessage(err) }; @@ -1768,26 +1681,6 @@ export async function createRfqCommentWithAttachments(params: { }) } } - - revalidateTag("rfq-vendors"); - revalidateTag(`rfq-${rfqId}`); - - if (vendorId) { - revalidateTag(`vendor-${vendorId}`); - } - - if (evaluationId) { - revalidateTag("tbe"); - revalidateTag(`tbe-vendor-${vendorId}`); - revalidateTag("all-tbe-vendors"); - } - - if (cbeId) { - revalidateTag("cbe"); - revalidateTag(`cbe-vendor-${vendorId}`); - revalidateTag("all-cbe-vendors"); - } - return { ok: true, commentId: insertedComment.id, createdAt: insertedComment.createdAt } } @@ -1834,9 +1727,6 @@ export async function updateRfqComment(params: { // 해당 id가 없으면 예외 throw new Error("Comment not found or already deleted.") } - revalidateTag("rfq-vendors"); - revalidateTag("tbe"); - revalidateTag("cbe"); return { ok: true } } @@ -1902,119 +1792,74 @@ export async function getBidProjects(): Promise<Project[]> { } } +export async function getAllVendors(input?: { + page?: number; + perPage?: number; + search?: string; + filters?: any[]; + sort?: { id: string; desc: boolean }[]; + joinOperator?: "and" | "or"; +}) { + try { + const page = input?.page ?? 1; + const perPage = input?.perPage ?? 50; + const offset = (page - 1) * perPage; + + // 고급 필터 + const advancedWhere = input?.filters ? filterColumns({ + table: vendors, + filters: input.filters, + joinOperator: input.joinOperator ?? "and", + }) : undefined; + + // 글로벌 검색 + 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.country, s), + ilike(vendors.phone, s) + ); + } -// 반환 타입 명시적 정의 - rfqCode가 null일 수 있음을 반영 -export interface BudgetaryRfq { - id: number; - rfqCode: string | null; // null 허용으로 변경 - description: string | null; - projectId: number | null; - projectCode: string | null; - projectName: string | null; -} + // 최종 where 조건 + const finalWhere = and(advancedWhere, globalWhere); -type GetBudgetaryRfqsResponse = - | { rfqs: BudgetaryRfq[]; totalCount: number; error?: never } - | { error: string; rfqs?: never; totalCount: number } -/** - * Budgetary 타입의 RFQ 목록을 가져오는 서버 액션 - * Purchase RFQ 생성 시 부모 RFQ로 선택할 수 있도록 함 - * 페이징 및 필터링 기능 포함 - */ -export interface GetBudgetaryRfqsParams { - search?: string; - projectId?: number; - rfqId?: number; // 특정 ID로 단일 RFQ 검색 + // 정렬 + const orderBy = input?.sort?.length + ? input.sort.map((s) => { + const col = (vendors as any)[s.id]; + return s.desc ? desc(col) : asc(col); + }) + : [asc(vendors.vendorName)]; // 기본 정렬은 vendor 이름 - limit?: number; - offset?: number; -} + // 데이터 조회 + const [data, total] = await db.transaction(async (tx) => { + const vendorData = await tx + .select() + .from(vendors) + .where(finalWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(perPage); -export async function getBudgetaryRfqs(params: GetBudgetaryRfqsParams = {}): Promise<GetBudgetaryRfqsResponse> { - const { search, projectId, rfqId, limit = 50, offset = 0 } = params; - const cacheKey = `rfqs-query-${JSON.stringify(params)}`; + const [{ count }] = await tx + .select({ count: sql<number>`count(*)`.as("count") }) + .from(vendors) + .where(finalWhere); - return unstable_cache( - async () => { - try { - // 기본 검색 조건 구성 - let baseCondition; - + return [vendorData, Number(count)]; + }); - - // 특정 ID로 검색하는 경우 - if (rfqId) { - baseCondition = and(baseCondition, eq(rfqs.id, rfqId)); - } - - let where1; - // 검색어 조건 추가 (있을 경우) - if (search && search.trim()) { - const searchTerm = `%${search.trim()}%`; - const searchCondition = or( - ilike(rfqs.rfqCode, searchTerm), - ilike(rfqs.description, searchTerm), - ilike(projects.code, searchTerm), - ilike(projects.name, searchTerm) - ); - where1 = searchCondition; - } - - let where2; - // 프로젝트 ID 조건 추가 (있을 경우) - if (projectId) { - where2 = eq(rfqs.projectId, projectId); - } - - const finalWhere = and(baseCondition, where1, where2); - - // 총 개수 조회 - const [countResult] = await db - .select({ count: count() }) - .from(rfqs) - .leftJoin(projects, eq(rfqs.projectId, projects.id)) - .where(finalWhere); - - // 실제 데이터 조회 - const resultRfqs = await db - .select({ - id: rfqs.id, - rfqCode: rfqs.rfqCode, - description: rfqs.description, - projectId: rfqs.projectId, - projectCode: projects.code, - projectName: projects.name, - }) - .from(rfqs) - .leftJoin(projects, eq(rfqs.projectId, projects.id)) - .where(finalWhere) - .orderBy(desc(rfqs.createdAt)) - .limit(limit) - .offset(offset); - - return { - rfqs: resultRfqs, - totalCount: Number(countResult?.count) || 0 - }; - } catch (error) { - console.error("Error fetching RFQs:", error); - return { - error: "Failed to fetch RFQs", - totalCount: 0 - }; - } - }, - [cacheKey], - { - revalidate: 60, // 1분 캐시 - tags: ["rfqs-query"], - } - )(); -} -export async function getAllVendors() { - // Adjust the query as needed (add WHERE, ORDER, etc.) - const allVendors = await db.select().from(vendors) - return allVendors + const pageCount = Math.ceil(total / perPage); + return { data, pageCount, total }; + } catch (error) { + console.error("Error fetching vendors:", error); + return { data: [], pageCount: 0, total: 0 }; + } } @@ -2106,9 +1951,6 @@ export async function addItemToVendors(rfqId: number, vendorIds: number[]) { insertedCount = recordsToInsert.length; } - // 5. Revalidate to refresh data - revalidateTag("rfq-vendors"); - // 6. Return success with counts return { success: true, @@ -2259,11 +2101,6 @@ export async function uploadTbeResponseFile(formData: FormData) { uploadedAt: new Date(), }); - // 경로 재검증 (캐시된 데이터 새로고침) - // revalidatePath(`/rfq/${rfqId}/tbe`) // 화면 새로고침 방지를 위해 제거 - revalidateTag(`tbe-vendor-${vendorId}`) - revalidateTag("all-tbe-vendors") - return { success: true, message: "파일이 성공적으로 업로드되었습니다." @@ -2384,8 +2221,6 @@ export async function getTbeFilesForVendor(rfqId: number, vendorId: number) { } export async function getAllTBE(input: GetTBESchema) { - return unstable_cache( - async () => { // 1) 페이징 const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10) const limit = input.perPage ?? 10 @@ -2657,19 +2492,10 @@ export async function getAllTBE(input: GetTBESchema) { const pageCount = Math.ceil(total / limit) return { data: final, pageCount } - }, - [JSON.stringify(input)], - { - revalidate: 3600, - tags: ["tbe"], - } - )() } export async function getCBE(input: GetCBESchema, rfqId: number) { - return unstable_cache( - async () => { // [1] 페이징 const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10); const limit = input.perPage ?? 10; @@ -2933,14 +2759,6 @@ export async function getCBE(input: GetCBESchema, rfqId: number) { pageCount, total }; - }, - // 캐싱 키 & 옵션 - [`cbe-vendors-${rfqId}-${JSON.stringify(input)}`], - { - revalidate: 3600, - tags: [`cbe`, `rfq-${rfqId}`], - } - )(); } export async function generateNextRfqCode(): Promise<{ code: string; error?: string }> { @@ -3017,11 +2835,6 @@ export async function saveTbeResult({ ) ) - // Revalidate the tbe-vendors tag to refresh the data - revalidateTag("tbe-vendors") - revalidateTag("all-tbe-vendors") - - return { success: true, message: "TBE evaluation updated successfully", @@ -3286,7 +3099,7 @@ export async function createCbeEvaluation(formData: FormData) { incoterms: validData.incoterms, deliverySchedule: validData.deliverySchedule, notes: validData.notes, - loginUrl: `http://${host}/en/partners/cbe` + loginUrl: `http://${host}/en/partners/cbe-tech` } // 각 고유 이메일 주소로 이메일 발송 @@ -3321,11 +3134,6 @@ export async function createCbeEvaluation(formData: FormData) { failedVendors.push({ id: vendorId, reason: "예기치 않은 오류" }) } } - - // UI 업데이트를 위한 경로 재검증 - revalidatePath(`/rfq/${rfqId}`) - revalidateTag(`cbe-vendors-${rfqId}`) - revalidateTag("all-cbe-vendors") // 결과 반환 if (createdCbeIds.length === 0) { @@ -3347,8 +3155,6 @@ export async function createCbeEvaluation(formData: FormData) { } export async function getCBEbyVendorId(input: GetCBESchema, vendorId: number) { - return unstable_cache( - async () => { // [1] 페이징 const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10); const limit = input.perPage ?? 10; @@ -3613,14 +3419,6 @@ export async function getCBEbyVendorId(input: GetCBESchema, vendorId: number) { pageCount, total }; - }, - // 캐싱 키 & 옵션 - [`cbe-vendor-${vendorId}-${JSON.stringify(input)}`], - { - revalidate: 3600, - tags: [`cbe-vendor-${vendorId}`], - } - )(); } export async function fetchCbeFiles(vendorId: number, rfqId: number) { @@ -3683,8 +3481,6 @@ export async function fetchCbeFiles(vendorId: number, rfqId: number) { } export async function getAllCBE(input: GetCBESchema) { - return unstable_cache( - async () => { // [1] 페이징 const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10); const limit = input.perPage ?? 10; @@ -3987,12 +3783,4 @@ export async function getAllCBE(input: GetCBESchema) { pageCount, total }; - }, - // 캐싱 키 & 옵션 - [`all-cbe-vendors-${JSON.stringify(input)}`], - { - revalidate: 3600, - tags: ["all-cbe-vendors"], - } - )(); }
\ No newline at end of file diff --git a/lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table-column.tsx b/lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table-column.tsx index bfcbe75b..f9c5d9df 100644 --- a/lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table-column.tsx +++ b/lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table-column.tsx @@ -14,13 +14,11 @@ export interface DataTableRowAction<TData> { } interface GetColumnsProps { - setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorData> | null>> setSelectedVendorIds: React.Dispatch<React.SetStateAction<number[]>> // Changed to array } /** getColumns: return array of ColumnDef for 'vendors' data */ export function getColumns({ - setRowAction, setSelectedVendorIds, // Changed parameter name }: GetColumnsProps): ColumnDef<VendorData>[] { return [ diff --git a/lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table.tsx b/lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table.tsx index e34a5052..defbac86 100644 --- a/lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table.tsx +++ b/lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { ClientDataTable } from "@/components/client-data-table/data-table" -import { DataTableRowAction, getColumns } from "./vendor-list-table-column" +import { getColumns } from "./vendor-list-table-column" import { DataTableAdvancedFilterField } from "@/types/table" import { addItemToVendors, getAllVendors } from "../../service" import { Loader2, Plus } from "lucide-react" @@ -30,27 +30,63 @@ interface VendorsListTableProps { export function VendorsListTable({ rfqId }: VendorsListTableProps) { const { toast } = useToast() - const [rowAction, setRowAction] = - React.useState<DataTableRowAction<VendorData> | null>(null) // Changed to array for multiple selection const [selectedVendorIds, setSelectedVendorIds] = React.useState<number[]>([]) const [isSubmitting, setIsSubmitting] = React.useState(false) - const columns = React.useMemo( - () => getColumns({ setRowAction, setSelectedVendorIds }), - [setRowAction, setSelectedVendorIds] - ) - const [vendors, setVendors] = React.useState<VendorData[]>([]) const [isLoading, setIsLoading] = React.useState(false) + const columns = React.useMemo( + () => getColumns({ setSelectedVendorIds }), + [setSelectedVendorIds] + ) + + // 고급 필터 필드 정의 + const advancedFilterFields: DataTableAdvancedFilterField<VendorData>[] = [ + { + id: "vendorName", + label: "Vendor Name", + type: "text", + }, + { + id: "vendorCode", + label: "Vendor Code", + type: "text", + }, + { + id: "status", + label: "Status", + type: "select", + options: [ + { label: "Active", value: "ACTIVE" }, + { label: "Inactive", value: "INACTIVE" }, + { label: "Pending", value: "PENDING" }, + ], + }, + { + id: "country", + label: "Country", + type: "text", + }, + { + id: "email", + label: "Email", + type: "text", + }, + ] + + // 초기 데이터 로드 React.useEffect(() => { - async function loadAllVendors() { + async function loadVendors() { setIsLoading(true) try { - const allVendors = await getAllVendors() - setVendors(allVendors) + const result = await getAllVendors() + + if (result.data) { + setVendors(result.data) + } } catch (error) { console.error("협력업체 목록 로드 오류:", error) toast({ @@ -62,11 +98,10 @@ export function VendorsListTable({ rfqId }: VendorsListTableProps) { setIsLoading(false) } } - loadAllVendors() + + loadVendors() }, [toast]) - const advancedFilterFields: DataTableAdvancedFilterField<VendorData>[] = [] - async function handleAddVendors() { if (selectedVendorIds.length === 0) return // Safety check @@ -100,9 +135,9 @@ export function VendorsListTable({ rfqId }: VendorsListTableProps) { setIsSubmitting(false) } } - + // If loading, show a flex container that fills the parent and centers the spinner - if (isLoading) { + if (isLoading && vendors.length === 0) { return ( <div className="flex h-full w-full items-center justify-center"> <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> @@ -110,7 +145,6 @@ export function VendorsListTable({ rfqId }: VendorsListTableProps) { ) } - // Otherwise, show the table return ( <ClientDataTable data={vendors} diff --git a/lib/tech-vendor-rfq-response/service.ts b/lib/tech-vendor-rfq-response/service.ts index b706d42a..e6b67406 100644 --- a/lib/tech-vendor-rfq-response/service.ts +++ b/lib/tech-vendor-rfq-response/service.ts @@ -1,6 +1,6 @@ 'use server' -import { revalidateTag, unstable_cache } from "next/cache"; +import { revalidateTag } from "next/cache"; import db from "@/db/db"; import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm"; import { rfqAttachments, rfqComments, rfqItems, vendorResponses } from "@/db/schema/rfq"; @@ -10,301 +10,294 @@ import { GetRfqsForVendorsSchema } from "../rfqs-tech/validations"; import { ItemData } from "./vendor-cbe-table/rfq-items-table/rfq-items-table"; import * as z from "zod" - - export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, vendorId: number) { - return unstable_cache( - async () => { - const offset = (input.page - 1) * input.perPage; - const limit = input.perPage; - - // 1) 메인 쿼리: vendorResponsesView 사용 - const { rows, total } = await db.transaction(async (tx) => { - // 검색 조건 - let globalWhere; - if (input.search) { - const s = `%${input.search}%`; - globalWhere = or( - sql`${vendorResponsesView.rfqCode} ILIKE ${s}`, - sql`${vendorResponsesView.projectName} ILIKE ${s}`, - sql`${vendorResponsesView.rfqDescription} ILIKE ${s}` - ); - } - - // 협력업체 ID 필터링 - const mainWhere = and(eq(vendorResponsesView.vendorId, vendorId), globalWhere); - - // 정렬: 응답 시간순 - const orderBy = [desc(vendorResponsesView.respondedAt)]; - - // (A) 데이터 조회 - const data = await tx - .select() - .from(vendorResponsesView) - .where(mainWhere) - .orderBy(...orderBy) - .offset(offset) - .limit(limit); - - // (B) 전체 개수 카운트 - const [{ count }] = await tx - .select({ - count: sql<number>`count(*)`.as("count"), - }) - .from(vendorResponsesView) - .where(mainWhere); + try { + const offset = (input.page - 1) * input.perPage; + const limit = input.perPage; + + // 1) 메인 쿼리: vendorResponsesView 사용 + const { rows, total } = await db.transaction(async (tx) => { + // 검색 조건 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + sql`${vendorResponsesView.rfqCode} ILIKE ${s}`, + sql`${vendorResponsesView.projectName} ILIKE ${s}`, + sql`${vendorResponsesView.rfqDescription} ILIKE ${s}` + ); + } - return { rows: data, total: Number(count) }; - }); + // 협력업체 ID 필터링 + const mainWhere = and(eq(vendorResponsesView.vendorId, vendorId), globalWhere); - // 2) rfqId 고유 목록 추출 - const distinctRfqs = [...new Set(rows.map((r) => r.rfqId))]; - if (distinctRfqs.length === 0) { - return { data: [], pageCount: 0 }; - } + // 정렬: 응답 시간순 + const orderBy = [desc(vendorResponsesView.respondedAt)]; - // 3) 추가 데이터 조회 - // 3-A) RFQ 아이템 - const itemsAll = await db + // (A) 데이터 조회 + const data = await tx + .select() + .from(vendorResponsesView) + .where(mainWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(limit); + + // (B) 전체 개수 카운트 + const [{ count }] = await tx .select({ - id: rfqItems.id, - rfqId: rfqItems.rfqId, - itemCode: rfqItems.itemCode, - itemList: sql<string>`COALESCE(${itemOffshoreTop.itemList}, ${itemOffshoreHull.itemList})`.as('itemList'), - subItemList: sql<string>`COALESCE(${itemOffshoreTop.subItemList}, ${itemOffshoreHull.subItemList})`.as('subItemList'), - quantity: rfqItems.quantity, - description: rfqItems.description, - uom: rfqItems.uom, + count: sql<number>`count(*)`.as("count"), }) - .from(rfqItems) - .leftJoin(itemOffshoreTop, eq(rfqItems.itemCode, itemOffshoreTop.itemCode)) - .leftJoin(itemOffshoreHull, eq(rfqItems.itemCode, itemOffshoreHull.itemCode)) - .where(inArray(rfqItems.rfqId, distinctRfqs)); + .from(vendorResponsesView) + .where(mainWhere); - // 3-B) RFQ 첨부 파일 (협력업체용) - const attachAll = await db - .select() - .from(rfqAttachments) - .where( - and( - inArray(rfqAttachments.rfqId, distinctRfqs), - isNull(rfqAttachments.vendorId) - ) - ); - - // 3-C) RFQ 코멘트 - const commAll = await db - .select() - .from(rfqComments) - .where( - and( - inArray(rfqComments.rfqId, distinctRfqs), - or( - isNull(rfqComments.vendorId), - eq(rfqComments.vendorId, vendorId) - ) - ) - ); + return { rows: data, total: Number(count) }; + }); - - // 3-E) 협력업체 응답 상세 - 기술 - const technicalResponsesAll = await db - .select() - .from(vendorTechnicalResponses) - .where( - inArray( - vendorTechnicalResponses.responseId, - rows.map((r) => r.responseId) - ) - ); + // 2) rfqId 고유 목록 추출 + const distinctRfqs = [...new Set(rows.map((r) => r.rfqId))]; + if (distinctRfqs.length === 0) { + return { data: [], pageCount: 0 }; + } - // 3-F) 협력업체 응답 상세 - 상업 - const commercialResponsesAll = await db - .select() - .from(vendorCommercialResponses) - .where( - inArray( - vendorCommercialResponses.responseId, - rows.map((r) => r.responseId) - ) - ); + // 3) 추가 데이터 조회 + // 3-A) RFQ 아이템 + const itemsAll = await db + .select({ + id: rfqItems.id, + rfqId: rfqItems.rfqId, + itemCode: rfqItems.itemCode, + itemList: sql<string>`COALESCE(${itemOffshoreTop.itemList}, ${itemOffshoreHull.itemList})`.as('itemList'), + subItemList: sql<string>`COALESCE(${itemOffshoreTop.subItemList}, ${itemOffshoreHull.subItemList})`.as('subItemList'), + quantity: rfqItems.quantity, + description: rfqItems.description, + uom: rfqItems.uom, + }) + .from(rfqItems) + .leftJoin(itemOffshoreTop, eq(rfqItems.itemCode, itemOffshoreTop.itemCode)) + .leftJoin(itemOffshoreHull, eq(rfqItems.itemCode, itemOffshoreHull.itemCode)) + .where(inArray(rfqItems.rfqId, distinctRfqs)); - // 3-G) 협력업체 응답 첨부 파일 - const responseAttachmentsAll = await db - .select() - .from(vendorResponseAttachments) - .where( - inArray( - vendorResponseAttachments.responseId, - rows.map((r) => r.responseId) + // 3-B) RFQ 첨부 파일 (협력업체용) + const attachAll = await db + .select() + .from(rfqAttachments) + .where( + and( + inArray(rfqAttachments.rfqId, distinctRfqs), + isNull(rfqAttachments.vendorId) + ) + ); + + // 3-C) RFQ 코멘트 + const commAll = await db + .select() + .from(rfqComments) + .where( + and( + inArray(rfqComments.rfqId, distinctRfqs), + or( + isNull(rfqComments.vendorId), + eq(rfqComments.vendorId, vendorId) ) - ); + ) + ); - // 4) 데이터 그룹화 - // RFQ 아이템 그룹화 - const itemsByRfqId = new Map<number, any[]>(); - for (const it of itemsAll) { - if (!itemsByRfqId.has(it.rfqId)) { - itemsByRfqId.set(it.rfqId, []); - } - itemsByRfqId.get(it.rfqId)!.push({ - id: it.id, - itemCode: it.itemCode, - itemList: it.itemList, - subItemList: it.subItemList, - quantity: it.quantity, - description: it.description, - uom: it.uom, - }); - } - // RFQ 첨부 파일 그룹화 - const attachByRfqId = new Map<number, any[]>(); - for (const att of attachAll) { - const rid = att.rfqId!; - if (!attachByRfqId.has(rid)) { - attachByRfqId.set(rid, []); - } - attachByRfqId.get(rid)!.push({ - id: att.id, - fileName: att.fileName, - filePath: att.filePath, - vendorId: att.vendorId, - evaluationId: att.evaluationId, - }); + // 3-E) 협력업체 응답 상세 - 기술 + const technicalResponsesAll = await db + .select() + .from(vendorTechnicalResponses) + .where( + inArray( + vendorTechnicalResponses.responseId, + rows.map((r) => r.responseId) + ) + ); + + // 3-F) 협력업체 응답 상세 - 상업 + const commercialResponsesAll = await db + .select() + .from(vendorCommercialResponses) + .where( + inArray( + vendorCommercialResponses.responseId, + rows.map((r) => r.responseId) + ) + ); + + // 3-G) 협력업체 응답 첨부 파일 + const responseAttachmentsAll = await db + .select() + .from(vendorResponseAttachments) + .where( + inArray( + vendorResponseAttachments.responseId, + rows.map((r) => r.responseId) + ) + ); + + // 4) 데이터 그룹화 + // RFQ 아이템 그룹화 + const itemsByRfqId = new Map<number, any[]>(); + for (const it of itemsAll) { + if (!itemsByRfqId.has(it.rfqId)) { + itemsByRfqId.set(it.rfqId, []); } + itemsByRfqId.get(it.rfqId)!.push({ + id: it.id, + itemCode: it.itemCode, + itemList: it.itemList, + subItemList: it.subItemList, + quantity: it.quantity, + description: it.description, + uom: it.uom, + }); + } - // RFQ 코멘트 그룹화 - const commByRfqId = new Map<number, any[]>(); - for (const c of commAll) { - const rid = c.rfqId!; - if (!commByRfqId.has(rid)) { - commByRfqId.set(rid, []); - } - commByRfqId.get(rid)!.push({ - id: c.id, - commentText: c.commentText, - vendorId: c.vendorId, - evaluationId: c.evaluationId, - createdAt: c.createdAt, - }); + // RFQ 첨부 파일 그룹화 + const attachByRfqId = new Map<number, any[]>(); + for (const att of attachAll) { + const rid = att.rfqId!; + if (!attachByRfqId.has(rid)) { + attachByRfqId.set(rid, []); } + attachByRfqId.get(rid)!.push({ + id: att.id, + fileName: att.fileName, + filePath: att.filePath, + vendorId: att.vendorId, + evaluationId: att.evaluationId, + }); + } - - // 기술 응답 그룹화 - const techResponseByResponseId = new Map<number, any>(); - for (const tr of technicalResponsesAll) { - techResponseByResponseId.set(tr.responseId, { - id: tr.id, - summary: tr.summary, - notes: tr.notes, - createdAt: tr.createdAt, - updatedAt: tr.updatedAt, - }); + // RFQ 코멘트 그룹화 + const commByRfqId = new Map<number, any[]>(); + for (const c of commAll) { + const rid = c.rfqId!; + if (!commByRfqId.has(rid)) { + commByRfqId.set(rid, []); } + commByRfqId.get(rid)!.push({ + id: c.id, + commentText: c.commentText, + vendorId: c.vendorId, + evaluationId: c.evaluationId, + createdAt: c.createdAt, + }); + } - // 상업 응답 그룹화 - const commResponseByResponseId = new Map<number, any>(); - for (const cr of commercialResponsesAll) { - commResponseByResponseId.set(cr.responseId, { - id: cr.id, - totalPrice: cr.totalPrice, - currency: cr.currency, - paymentTerms: cr.paymentTerms, - incoterms: cr.incoterms, - deliveryPeriod: cr.deliveryPeriod, - warrantyPeriod: cr.warrantyPeriod, - validityPeriod: cr.validityPeriod, - priceBreakdown: cr.priceBreakdown, - commercialNotes: cr.commercialNotes, - createdAt: cr.createdAt, - updatedAt: cr.updatedAt, - }); - } - // 응답 첨부 파일 그룹화 - const respAttachByResponseId = new Map<number, any[]>(); - for (const ra of responseAttachmentsAll) { - const rid = ra.responseId!; - if (!respAttachByResponseId.has(rid)) { - respAttachByResponseId.set(rid, []); - } - respAttachByResponseId.get(rid)!.push({ - id: ra.id, - fileName: ra.fileName, - filePath: ra.filePath, - attachmentType: ra.attachmentType, - description: ra.description, - uploadedAt: ra.uploadedAt, - uploadedBy: ra.uploadedBy, - }); - } + // 기술 응답 그룹화 + const techResponseByResponseId = new Map<number, any>(); + for (const tr of technicalResponsesAll) { + techResponseByResponseId.set(tr.responseId, { + id: tr.id, + summary: tr.summary, + notes: tr.notes, + createdAt: tr.createdAt, + updatedAt: tr.updatedAt, + }); + } - // 5) 최종 데이터 결합 - const final = rows.map((row) => { - return { - // 응답 정보 - responseId: row.responseId, - responseStatus: row.responseStatus, - respondedAt: row.respondedAt, - - // RFQ 기본 정보 - rfqId: row.rfqId, - rfqCode: row.rfqCode, - rfqDescription: row.rfqDescription, - rfqDueDate: row.rfqDueDate, - rfqStatus: row.rfqStatus, - - rfqCreatedAt: row.rfqCreatedAt, - rfqUpdatedAt: row.rfqUpdatedAt, - rfqCreatedBy: row.rfqCreatedBy, - - // 프로젝트 정보 - projectId: row.projectId, - projectCode: row.projectCode, - projectName: row.projectName, - - // 협력업체 정보 - vendorId: row.vendorId, - vendorName: row.vendorName, - vendorCode: row.vendorCode, - - // RFQ 관련 데이터 - items: itemsByRfqId.get(row.rfqId) || [], - attachments: attachByRfqId.get(row.rfqId) || [], - comments: commByRfqId.get(row.rfqId) || [], - - // 평가 정보 - tbeEvaluation: row.tbeId ? { - id: row.tbeId, - result: row.tbeResult, - } : null, - cbeEvaluation: row.cbeId ? { - id: row.cbeId, - result: row.cbeResult, - } : null, - - // 협력업체 응답 상세 - technicalResponse: techResponseByResponseId.get(row.responseId) || null, - commercialResponse: commResponseByResponseId.get(row.responseId) || null, - responseAttachments: respAttachByResponseId.get(row.responseId) || [], - - // 응답 상태 표시 - hasTechnicalResponse: row.hasTechnicalResponse, - hasCommercialResponse: row.hasCommercialResponse, - attachmentCount: row.attachmentCount || 0, - }; + // 상업 응답 그룹화 + const commResponseByResponseId = new Map<number, any>(); + for (const cr of commercialResponsesAll) { + commResponseByResponseId.set(cr.responseId, { + id: cr.id, + totalPrice: cr.totalPrice, + currency: cr.currency, + paymentTerms: cr.paymentTerms, + incoterms: cr.incoterms, + deliveryPeriod: cr.deliveryPeriod, + warrantyPeriod: cr.warrantyPeriod, + validityPeriod: cr.validityPeriod, + priceBreakdown: cr.priceBreakdown, + commercialNotes: cr.commercialNotes, + createdAt: cr.createdAt, + updatedAt: cr.updatedAt, }); + } - const pageCount = Math.ceil(total / input.perPage); - return { data: final, pageCount }; - }, - [JSON.stringify(input), `${vendorId}`], - { - revalidate: 600, - tags: ["rfqs-vendor", `vendor-${vendorId}`], + // 응답 첨부 파일 그룹화 + const respAttachByResponseId = new Map<number, any[]>(); + for (const ra of responseAttachmentsAll) { + const rid = ra.responseId!; + if (!respAttachByResponseId.has(rid)) { + respAttachByResponseId.set(rid, []); + } + respAttachByResponseId.get(rid)!.push({ + id: ra.id, + fileName: ra.fileName, + filePath: ra.filePath, + attachmentType: ra.attachmentType, + description: ra.description, + uploadedAt: ra.uploadedAt, + uploadedBy: ra.uploadedBy, + }); } - )(); + + // 5) 최종 데이터 결합 + const final = rows.map((row) => { + return { + // 응답 정보 + responseId: row.responseId, + responseStatus: row.responseStatus, + respondedAt: row.respondedAt, + + // RFQ 기본 정보 + rfqId: row.rfqId, + rfqCode: row.rfqCode, + rfqDescription: row.rfqDescription, + rfqDueDate: row.rfqDueDate, + rfqStatus: row.rfqStatus, + + rfqCreatedAt: row.rfqCreatedAt, + rfqUpdatedAt: row.rfqUpdatedAt, + rfqCreatedBy: row.rfqCreatedBy, + + // 프로젝트 정보 + projectId: row.projectId, + projectCode: row.projectCode, + projectName: row.projectName, + + // 협력업체 정보 + vendorId: row.vendorId, + vendorName: row.vendorName, + vendorCode: row.vendorCode, + + // RFQ 관련 데이터 + items: itemsByRfqId.get(row.rfqId) || [], + attachments: attachByRfqId.get(row.rfqId) || [], + comments: commByRfqId.get(row.rfqId) || [], + + // 평가 정보 + tbeEvaluation: row.tbeId ? { + id: row.tbeId, + result: row.tbeResult, + } : null, + cbeEvaluation: row.cbeId ? { + id: row.cbeId, + result: row.cbeResult, + } : null, + + // 협력업체 응답 상세 + technicalResponse: techResponseByResponseId.get(row.responseId) || null, + commercialResponse: commResponseByResponseId.get(row.responseId) || null, + responseAttachments: respAttachByResponseId.get(row.responseId) || [], + + // 응답 상태 표시 + hasTechnicalResponse: row.hasTechnicalResponse, + hasCommercialResponse: row.hasCommercialResponse, + attachmentCount: row.attachmentCount || 0, + }; + }); + + const pageCount = Math.ceil(total / input.perPage); + return { data: final, pageCount }; + } catch (err) { + return { data: null, error: err instanceof Error ? err.message : "Unknown error" }; + } } @@ -425,9 +418,7 @@ export async function updateCommercialResponse(input: CommercialResponseInput): .where(eq(vendorResponses.id, validated.responseId)) } } - - // Use vendorId for revalidateTag - revalidateTag(`cbe-vendor-${validated.vendorId}`) + return { success: true, |
