diff options
25 files changed, 10153 insertions, 0 deletions
diff --git a/lib/rfqs-ship/repository.ts b/lib/rfqs-ship/repository.ts new file mode 100644 index 00000000..24d09ec3 --- /dev/null +++ b/lib/rfqs-ship/repository.ts @@ -0,0 +1,232 @@ +// src/lib/tasks/repository.ts +import db from "@/db/db"; +import { items } from "@/db/schema/items"; +import { rfqItems, rfqs, RfqWithItems, rfqsView, type Rfq,VendorResponse, vendorResponses, RfqViewWithItems } from "@/db/schema/rfq"; +import { users } from "@/db/schema/users"; +import { + eq, + inArray, + not, + asc, + desc, + and, + ilike, + gte, + lte, + count, + gt, sql +} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; +import { RfqType } from "./validations"; +export type NewRfq = typeof rfqs.$inferInsert +export type NewRfqItem = typeof rfqItems.$inferInsert + +/** + * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시 + * - 트랜잭션(tx)을 받아서 사용하도록 구현 + */ +export async function selectRfqs( + tx: PgTransaction<any, any, any>, + params: { + where?: any; + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; + offset?: number; + limit?: number; + } +) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select({ + rfqId: rfqsView.id, + id: rfqsView.id, + rfqCode: rfqsView.rfqCode, + description: rfqsView.description, + projectCode: rfqsView.projectCode, + projectName: rfqsView.projectName, + dueDate: rfqsView.dueDate, + status: rfqsView.status, + // createdBy → user 이메일 + createdBy: rfqsView.createdBy, // still the numeric user ID + createdByEmail: rfqsView.userEmail, // string + + createdAt: rfqsView.createdAt, + updatedAt: rfqsView.updatedAt, + // ==================== + // 1) itemCount via subselect + // ==================== + itemCount:rfqsView.itemCount, + attachCount: rfqsView.attachmentCount, + + // user info + // userId: users.id, + userEmail: rfqsView.userEmail, + userName: rfqsView.userName, + }) + .from(rfqsView) + .where(where ?? undefined) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); +} +/** 총 개수 count */ +export async function countRfqs( + tx: PgTransaction<any, any, any>, + where?: any +) { + const res = await tx.select({ count: count() }).from(rfqsView).where(where); + return res[0]?.count ?? 0; +} + +/** 단건 Insert 예시 */ +export async function insertRfq( + tx: PgTransaction<any, any, any>, + data: NewRfq // DB와 동일한 insert 가능한 타입 +) { + // returning() 사용 시 배열로 돌아오므로 [0]만 리턴 + return tx + .insert(rfqs) + .values(data) + .returning({ id: rfqs.id, createdAt: rfqs.createdAt }); +} + +/** 복수 Insert 예시 */ +export async function insertRfqs( + tx: PgTransaction<any, any, any>, + data: Rfq[] +) { + return tx.insert(rfqs).values(data).onConflictDoNothing(); +} + +/** 단건 삭제 */ +export async function deleteRfqById( + tx: PgTransaction<any, any, any>, + rfqId: number +) { + return tx.delete(rfqs).where(eq(rfqs.id, rfqId)); +} + +/** 복수 삭제 */ +export async function deleteRfqsByIds( + tx: PgTransaction<any, any, any>, + ids: number[] +) { + return tx.delete(rfqs).where(inArray(rfqs.id, ids)); +} + +/** 전체 삭제 */ +export async function deleteAllRfqs( + tx: PgTransaction<any, any, any>, +) { + return tx.delete(rfqs); +} + +/** 단건 업데이트 */ +export async function updateRfq( + tx: PgTransaction<any, any, any>, + rfqId: number, + data: Partial<Rfq> +) { + return tx + .update(rfqs) + .set(data) + .where(eq(rfqs.id, rfqId)) + .returning({ status: rfqs.status }); +} + +// /** 복수 업데이트 */ +export async function updateRfqs( + tx: PgTransaction<any, any, any>, + ids: number[], + data: Partial<Rfq> +) { + return tx + .update(rfqs) + .set(data) + .where(inArray(rfqs.id, ids)) + .returning({ status: rfqs.status, dueDate: rfqs.dueDate }); +} + + +// 모든 task 조회 +export const getAllRfqs = async (): Promise<Rfq[]> => { + const datas = await db.select().from(rfqs).execute(); + return datas +}; + + +export async function groupByStatus( + tx: PgTransaction<any, any, any>, + rfqType: RfqType = RfqType.PURCHASE +) { + return tx + .select({ + status: rfqs.status, + count: count(), + }) + .from(rfqs) + .where(eq(rfqs.rfqType, rfqType)) // rfqType으로 필터링 추가 + .groupBy(rfqs.status) + .having(gt(count(), 0)); +} + +export async function insertRfqItem( + tx: PgTransaction<any, any, any>, + data: NewRfqItem +) { + return tx.insert(rfqItems).values(data).returning(); +} + +export const getRfqById = async (id: number): Promise<RfqViewWithItems | null> => { + // 1) RFQ 단건 조회 + const rfqsRes = await db + .select() + .from(rfqsView) + .where(eq(rfqsView.id, id)) + .limit(1); + + if (rfqsRes.length === 0) return null; + const rfqRow = rfqsRes[0]; + + // 2) 해당 RFQ 아이템 목록 조회 + const itemsRes = await db + .select() + .from(rfqItems) + .where(eq(rfqItems.rfqId, id)); + + // itemsRes: RfqItem[] + + // 3) RfqWithItems 형태로 반환 + const result: RfqViewWithItems = { + ...rfqRow, + lines: itemsRes, + }; + + return result; +}; + +/** 단건 업데이트 */ +export async function updateRfqVendor( + tx: PgTransaction<any, any, any>, + rfqVendorId: number, + data: Partial<VendorResponse> +) { + return tx + .update(vendorResponses) + .set(data) + .where(eq(vendorResponses.id, rfqVendorId)) + .returning({ status: vendorResponses.responseStatus }); +} + +/** 복수 업데이트 */ +export async function updateRfqVendors( + tx: PgTransaction<any, any, any>, + ids: number[], + data: Partial<VendorResponse> +) { + return tx + .update(vendorResponses) + .set(data) + .where(inArray(vendorResponses.id, ids)) + .returning({ status: vendorResponses.responseStatus }); +} diff --git a/lib/rfqs-ship/service.ts b/lib/rfqs-ship/service.ts new file mode 100644 index 00000000..c7d1c3cd --- /dev/null +++ b/lib/rfqs-ship/service.ts @@ -0,0 +1,4027 @@ +// src/lib/tasks/service.ts +"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) + +import { revalidatePath, revalidateTag, unstable_noStore } 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, GetRfqsForVendorsSchema, UpdateRfqVendorSchema, GetTBESchema, RfqType, GetCBESchema, createCbeEvaluationSchema } from "./validations"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or, sql, eq, isNull, ne, isNotNull, count } from "drizzle-orm"; +import path from "path"; +import fs from "fs/promises"; +import { randomUUID } from "crypto"; +import { writeFile, mkdir } from 'fs/promises' +import { join } from 'path' + +import { vendorResponses, vendorResponsesView, Rfq, rfqs, rfqAttachments, rfqItems, RfqWithItems, rfqComments, rfqEvaluations, vendorRfqView, vendorTbeView, rfqsView, vendorResponseAttachments, vendorTechnicalResponses, vendorCbeView, cbeEvaluations, vendorCommercialResponses, vendorResponseCBEView, RfqViewWithItems } from "@/db/schema/rfq"; +import { countRfqs, deleteRfqById, deleteRfqsByIds, getRfqById, groupByStatus, insertRfq, insertRfqItem, selectRfqs, updateRfq, updateRfqs, updateRfqVendor } from "./repository"; +import logger from '@/lib/logger'; +import { vendorContacts, vendorPossibleItems, vendors } from "@/db/schema/vendors"; +import { sendEmail } from "../mail/sendEmail"; +import { biddingProjects, projects } from "@/db/schema/projects"; +import { items } from "@/db/schema/items"; +import * as z from "zod" +import { users } from "@/db/schema/users"; +import { headers } from 'next/headers'; + + +interface InviteVendorsInput { + rfqId: number + vendorIds: number[] + rfqType: RfqType +} + +/* ----------------------------------------------------- + 1) 조회 관련 +----------------------------------------------------- */ + +/** + * 복잡한 조건으로 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, + }); + + + 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 rfqTypeWhere; + if (input.rfqType) { + rfqTypeWhere = eq(rfqsView.rfqType, input.rfqType); + } + + let whereConditions = []; + if (advancedWhere) whereConditions.push(advancedWhere); + if (globalWhere) whereConditions.push(globalWhere); + if (rfqTypeWhere) whereConditions.push(rfqTypeWhere); + + // 조건이 있을 때만 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 total = await countRfqs(tx, finalWhere); + return { data, total }; + }); + + + const pageCount = Math.ceil(total / input.perPage); + + + return { data, pageCount }; + } catch (err) { + console.error("getRfqs 에러:", err); // 자세한 에러 로깅 + + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], + { + revalidate: 3600, + tags: [`rfqs-${input.rfqType}`], + } + )(); +} + +/** Status별 개수 */ +export async function getRfqStatusCounts(rfqType: RfqType = RfqType.PURCHASE) { + return unstable_cache( + async () => { + try { + const initial: Record<Rfq["status"], number> = { + DRAFT: 0, + PUBLISHED: 0, + EVALUATION: 0, + AWARDED: 0, + }; + + const result = await db.transaction(async (tx) => { + // rfqType을 기준으로 필터링 추가 + const rows = await groupByStatus(tx, rfqType); + return rows.reduce<Record<Rfq["status"], number>>((acc, { status, count }) => { + acc[status] = count; + return acc; + }, initial); + }); + + return result; + } catch (err) { + return {} as Record<Rfq["status"], number>; + } + }, + [`rfq-status-counts-${rfqType}`], // 캐싱 키에 rfqType 추가 + { + revalidate: 3600, + } + )(); +} + + + +/* ----------------------------------------------------- + 2) 생성(Create) +----------------------------------------------------- */ + +/** + * Rfq 생성 후, (가장 오래된 Rfq 1개) 삭제로 + * 전체 Rfq 개수를 고정 + */ +export async function createRfq(input: CreateRfqSchema) { + + console.log(input.createdBy, "input.createdBy") + + unstable_noStore(); // Next.js 서버 액션 캐싱 방지 + try { + await db.transaction(async (tx) => { + // 새 Rfq 생성 + const [newTask] = await insertRfq(tx, { + rfqCode: input.rfqCode, + projectId: input.projectId || null, + bidProjectId: input.bidProjectId || null, + description: input.description || null, + dueDate: input.dueDate, + status: input.status, + rfqType: input.rfqType, // rfqType 추가 + createdBy: input.createdBy, + }); + return newTask; + }); + + // 캐시 무효화 + revalidateTag(`rfqs-${input.rfqType}`); + revalidateTag(`rfq-status-counts-${input.rfqType}`); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +/* ----------------------------------------------------- + 3) 업데이트 +----------------------------------------------------- */ + +/** 단건 업데이트 */ +export async function modifyRfq(input: UpdateRfqSchema & { id: number }) { + unstable_noStore(); + try { + const data = await db.transaction(async (tx) => { + const [res] = await updateRfq(tx, input.id, { + rfqCode: input.rfqCode, + projectId: input.projectId || null, + dueDate: input.dueDate, + rfqType: input.rfqType, + status: input.status as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED", + createdBy: input.createdBy, + }); + return res; + }); + + revalidateTag("rfqs"); + if (data.status === input.status) { + revalidateTag("rfqs-status-counts"); + } + + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +export async function modifyRfqs(input: { + ids: number[]; + status?: Rfq["status"]; + dueDate?: Date +}) { + unstable_noStore(); + try { + const data = await db.transaction(async (tx) => { + const [res] = await updateRfqs(tx, input.ids, { + status: input.status, + dueDate: input.dueDate, + }); + return res; + }); + + revalidateTag("rfqs"); + if (data.status === input.status) { + revalidateTag("rfq-status-counts"); + } + + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + + +/* ----------------------------------------------------- + 4) 삭제 +----------------------------------------------------- */ + +/** 단건 삭제 */ +export async function removeRfq(input: { id: number }) { + unstable_noStore(); + try { + await db.transaction(async (tx) => { + // 삭제 + await deleteRfqById(tx, input.id); + // 바로 새 Rfq 생성 + }); + + revalidateTag("rfqs"); + revalidateTag("rfq-status-counts"); + + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +/** 복수 삭제 */ +export async function removeRfqs(input: { ids: number[] }) { + unstable_noStore(); + try { + await db.transaction(async (tx) => { + // 삭제 + await deleteRfqsByIds(tx, input.ids); + }); + + revalidateTag("rfqs"); + revalidateTag("rfq-status-counts"); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +// 삭제를 위한 입력 스키마 +const deleteRfqItemSchema = z.object({ + id: z.number().int(), + rfqId: z.number().int(), + rfqType: z.nativeEnum(RfqType).default(RfqType.PURCHASE), +}); + +type DeleteRfqItemSchema = z.infer<typeof deleteRfqItemSchema>; + +/** + * RFQ 아이템 삭제 함수 + */ +export async function deleteRfqItem(input: DeleteRfqItemSchema) { + unstable_noStore(); // Next.js 서버 액션 캐싱 방지 + + try { + // 삭제 작업 수행 + await db + .delete(rfqItems) + .where( + and( + eq(rfqItems.id, input.id), + eq(rfqItems.rfqId, input.rfqId) + ) + ); + + console.log(`Deleted RFQ item: ${input.id} for RFQ ${input.rfqId}`); + + // 캐시 무효화 + revalidateTag("rfq-items"); + revalidateTag(`rfqs-${input.rfqType}`); + revalidateTag(`rfq-${input.rfqId}`); + + return { data: null, error: null }; + } catch (err) { + console.error("Error in deleteRfqItem:", err); + return { data: null, error: getErrorMessage(err) }; + } +} + +// createRfqItem 함수 수정 (id 파라미터 추가) +export async function createRfqItem(input: CreateRfqItemSchema & { id?: number }) { + unstable_noStore(); + + try { + // DB 트랜잭션 + await db.transaction(async (tx) => { + // id가 전달되었으면 해당 id로 업데이트, 그렇지 않으면 기존 로직대로 진행 + if (input.id) { + // 기존 아이템 업데이트 + await tx + .update(rfqItems) + .set({ + description: input.description ?? null, + quantity: input.quantity ?? 1, + uom: input.uom ?? "", + updatedAt: new Date(), + }) + .where(eq(rfqItems.id, input.id)); + + console.log(`Updated RFQ item with id: ${input.id}`); + } else { + // 기존 로직: 같은 itemCode로 이미 존재하는지 확인 후 업데이트/생성 + const existingItems = await tx + .select() + .from(rfqItems) + .where( + and( + eq(rfqItems.rfqId, input.rfqId), + eq(rfqItems.itemCode, input.itemCode) + ) + ); + + if (existingItems.length > 0) { + // 이미 존재하는 경우 업데이트 + const existingItem = existingItems[0]; + await tx + .update(rfqItems) + .set({ + description: input.description ?? null, + quantity: input.quantity ?? 1, + uom: input.uom ?? "", + updatedAt: new Date(), + }) + .where(eq(rfqItems.id, existingItem.id)); + + console.log(`Updated existing RFQ item: ${existingItem.id} for RFQ ${input.rfqId}, Item ${input.itemCode}`); + } else { + // 존재하지 않는 경우 새로 생성 + const [newItem] = await insertRfqItem(tx, { + rfqId: input.rfqId, + itemCode: input.itemCode, + description: input.description ?? null, + quantity: input.quantity ?? 1, + uom: input.uom ?? "", + }); + + console.log(`Created new RFQ item for RFQ ${input.rfqId}, Item ${input.itemCode}`); + } + } + }); + + // 캐시 무효화 + revalidateTag("rfq-items"); + revalidateTag(`rfqs-${input.rfqType}`); + revalidateTag(`rfq-${input.rfqId}`); + + return { data: null, error: null }; + } catch (err) { + console.error("Error in createRfqItem:", err); + return { data: null, error: getErrorMessage(err) }; + } +} +/** + * 서버 액션: 파일 첨부/삭제 처리 + * @param rfqId RFQ ID + * @param removedExistingIds 기존 첨부 중 삭제된 record ID 배열 + * @param newFiles 새로 업로드된 파일 (File[]) - Next.js server action에서 + * @param vendorId (optional) 업로더가 vendor인지 구분 + */ +export async function processRfqAttachments(args: { + rfqId: number; + removedExistingIds?: number[]; + newFiles?: File[]; + vendorId?: number | null; + rfqType?: RfqType | null; +}) { + const { rfqId, removedExistingIds = [], newFiles = [], vendorId = null } = args; + + try { + // 1) 삭제된 기존 첨부: DB + 파일시스템에서 제거 + if (removedExistingIds.length > 0) { + // 1-1) DB에서 filePath 조회 + const rows = await db + .select({ + id: rfqAttachments.id, + filePath: rfqAttachments.filePath + }) + .from(rfqAttachments) + .where(inArray(rfqAttachments.id, removedExistingIds)); + + // 1-2) DB 삭제 + await db + .delete(rfqAttachments) + .where(inArray(rfqAttachments.id, removedExistingIds)); + + // 1-3) 파일 삭제 + for (const row of rows) { + // filePath: 예) "/rfq/123/...xyz" + const absolutePath = path.join( + process.cwd(), + "public", + row.filePath.replace(/^\/+/, "") // 슬래시 제거 + ); + try { + await fs.unlink(absolutePath); + } catch (err) { + console.error("File remove error:", err); + } + } + } + + // 2) 새 파일 업로드 + if (newFiles.length > 0) { + const rfqDir = path.join("public", "rfq", String(rfqId)); + // 폴더 없으면 생성 + await fs.mkdir(rfqDir, { recursive: true }); + + for (const file of newFiles) { + // 2-1) File -> Buffer + const ab = await file.arrayBuffer(); + const buffer = Buffer.from(ab); + + // 2-2) 고유 파일명 + const uniqueName = `${randomUUID()}-${file.name}`; + // 예) "rfq/123/xxx" + const relativePath = path.join("rfq", String(rfqId), uniqueName); + const absolutePath = path.join("public", relativePath); + + // 2-3) 파일 저장 + await fs.writeFile(absolutePath, buffer); + + // 2-4) DB Insert + await db.insert(rfqAttachments).values({ + rfqId, + vendorId, + fileName: file.name, + filePath: "/" + relativePath.replace(/\\/g, "/"), + // (Windows 경로 대비) + }); + } + } + + const [countRow] = await db + .select({ cnt: sql<number>`count(*)`.as("cnt") }) + .from(rfqAttachments) + .where(eq(rfqAttachments.rfqId, rfqId)); + + const newCount = countRow?.cnt ?? 0; + + // 3) revalidateTag 등 캐시 무효화 + revalidateTag("rfq-attachments"); + revalidateTag(`rfqs-${args.rfqType}`) + + return { ok: true, updatedItemCount: newCount }; + } catch (error) { + console.error("processRfqAttachments error:", error); + return { ok: false, error: String(error) }; + } +} + + + +export async function fetchRfqAttachments(rfqId: number) { + // DB select + const rows = await db + .select() + .from(rfqAttachments) + .where(eq(rfqAttachments.rfqId, rfqId)) + + // rows: { id, fileName, filePath, createdAt, vendorId, ... } + // 필요 없는 필드는 omit하거나 transform 가능 + return rows.map((row) => ({ + id: row.id, + fileName: row.fileName, + filePath: row.filePath, + createdAt: row.createdAt, // or string + vendorId: row.vendorId, + size: undefined, // size를 DB에 저장하지 않았다면 + })) +} + +export async function fetchRfqItems(rfqId: number) { + // DB select + const rows = await db + .select() + .from(rfqItems) + .where(eq(rfqItems.rfqId, rfqId)) + + // rows: { id, fileName, filePath, createdAt, vendorId, ... } + // 필요 없는 필드는 omit하거나 transform 가능 + return rows.map((row) => ({ + // id: row.id, + itemCode: row.itemCode, + description: row.description, + quantity: row.quantity, + uom: row.uom, + })) +} + +export const findRfqById = async (id: number): Promise<RfqViewWithItems | null> => { + try { + logger.info({ id }, 'Fetching user by ID'); + const rfq = await getRfqById(id); + if (!rfq) { + logger.warn({ id }, 'User not found'); + } else { + logger.debug({ rfq }, 'User fetched successfully'); + } + return rfq; + } catch (error) { + logger.error({ error }, 'Error fetching user by ID'); + throw new Error('Failed to fetch user'); + } +}; + +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) + + 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 } + } + + // ───────────────────────────────────────────────────── + // 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", + }) + + // (나) 글로벌 검색 + 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)] + + // ───────────────────────────────────────────────────── + // 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 [{ count }] = await tx + .select({ count: sql<number>`count(DISTINCT ${vendorRfqView.vendorId})`.as("count") }) + .from(vendorRfqView) + .where(finalWhere) + + return [distinctData, Number(count)] + }) + + + // ───────────────────────────────────────────────────── + // 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) + ) + ) + + // 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) + ) + ) + + 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 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 } + }, + [JSON.stringify({ input, rfqId })], + { revalidate: 3600, tags: ["rfq-vendors"] } + )() +} + +export async function inviteVendors(input: InviteVendorsInput) { + unstable_noStore() // 서버 액션 캐싱 방지 + try { + const { rfqId, vendorIds } = input + if (!rfqId || !Array.isArray(vendorIds) || vendorIds.length === 0) { + throw new Error("Invalid input") + } + + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + + // DB 데이터 준비 및 첨부파일 처리를 위한 트랜잭션 + const rfqData = await db.transaction(async (tx) => { + // 2-A) RFQ 기본 정보 조회 + const [rfqRow] = await tx + .select({ + rfqCode: rfqsView.rfqCode, + description: rfqsView.description, + projectCode: rfqsView.projectCode, + projectName: rfqsView.projectName, + dueDate: rfqsView.dueDate, + createdBy: rfqsView.createdBy, + }) + .from(rfqsView) + .where(eq(rfqsView.id, rfqId)) + + if (!rfqRow) { + throw new Error(`RFQ #${rfqId} not found`) + } + + // 2-B) 아이템 목록 조회 + const items = await tx + .select({ + itemCode: rfqItems.itemCode, + description: rfqItems.description, + quantity: rfqItems.quantity, + uom: rfqItems.uom, + }) + .from(rfqItems) + .where(eq(rfqItems.rfqId, rfqId)) + + // 2-C) 첨부파일 목록 조회 + const attachRows = await tx + .select({ + id: rfqAttachments.id, + fileName: rfqAttachments.fileName, + filePath: rfqAttachments.filePath, + }) + .from(rfqAttachments) + .where( + and( + eq(rfqAttachments.rfqId, rfqId), + isNull(rfqAttachments.vendorId), + isNull(rfqAttachments.evaluationId) + ) + ) + + const vendorRows = await tx + .select({ id: vendors.id, email: vendors.email }) + .from(vendors) + .where(inArray(vendors.id, vendorIds)) + + // NodeMailer attachments 형식 맞추기 + const attachments = [] + for (const att of attachRows) { + const absolutePath = path.join(process.cwd(), "public", att.filePath.replace(/^\/+/, "")) + attachments.push({ + path: absolutePath, + filename: att.fileName, + }) + } + + return { rfqRow, items, vendorRows, attachments } + }) + + const { rfqRow, items, vendorRows, attachments } = rfqData + const loginUrl = `http://${host}/en/partners/rfq` + + // 이메일 전송 오류를 기록할 배열 + const emailErrors = [] + + // 각 벤더에 대해 처리 + for (const v of vendorRows) { + if (!v.email) { + continue // 이메일 없는 협력업체 무시 + } + + try { + // DB 업데이트: 각 협력업체 상태 별도 트랜잭션 + await db.transaction(async (tx) => { + // rfq_vendors upsert + const existing = await tx + .select() + .from(vendorResponses) + .where(and(eq(vendorResponses.rfqId, rfqId), eq(vendorResponses.vendorId, v.id))) + + if (existing.length > 0) { + await tx + .update(vendorResponses) + .set({ + responseStatus: "INVITED", + updatedAt: new Date(), + }) + .where(eq(vendorResponses.id, existing[0].id)) + } else { + await tx.insert(vendorResponses).values({ + rfqId, + vendorId: v.id, + responseStatus: "INVITED", + }) + } + }) + + // 이메일 발송 (트랜잭션 외부) + await sendEmail({ + to: v.email, + subject: `[RFQ ${rfqRow.rfqCode}] You are invited from Samgsung Heavy Industries!`, + template: "rfq-invite", + context: { + language: "en", + rfqId, + vendorId: v.id, + rfqCode: rfqRow.rfqCode, + projectCode: rfqRow.projectCode, + projectName: rfqRow.projectName, + dueDate: rfqRow.dueDate, + description: rfqRow.description, + items: items.map((it) => ({ + itemCode: it.itemCode, + description: it.description, + quantity: it.quantity, + uom: it.uom, + })), + loginUrl + }, + attachments, + }) + } catch (err) { + // 개별 협력업체 처리 실패 로깅 + console.error(`Failed to process vendor ${v.id}: ${getErrorMessage(err)}`) + emailErrors.push({ vendorId: v.id, error: getErrorMessage(err) }) + // 계속 진행 (다른 협력업체 처리) + } + } + + // 최종적으로 RFQ 상태 업데이트 (별도 트랜잭션) + try { + await db.transaction(async (tx) => { + await tx + .update(rfqs) + .set({ + status: "PUBLISHED", + updatedAt: new Date(), + }) + .where(eq(rfqs.id, rfqId)) + + console.log(`Updated RFQ #${rfqId} status to PUBLISHED`) + }) + + // 캐시 무효화 + revalidateTag("rfq-vendors") + revalidateTag("cbe-vendors") + revalidateTag("rfqs") + revalidateTag(`rfqs-${input.rfqType}`) + revalidateTag(`rfq-${rfqId}`) + + // 이메일 오류가 있었는지 확인 + if (emailErrors.length > 0) { + return { + error: `일부 벤더에게 이메일 발송 실패 (${emailErrors.length}/${vendorRows.length}), RFQ 상태는 업데이트됨`, + emailErrors + } + } + + return { error: null } + } catch (err) { + return { error: `RFQ 상태 업데이트 실패: ${getErrorMessage(err)}` } + } + } catch (err) { + return { error: getErrorMessage(err) } + } +} + + +/** + * 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}` + ) + } + + // 4) REJECTED 아니거나 NULL + const notRejected = or( + ne(vendorTbeView.rfqVendorStatus, "REJECTED"), + isNull(vendorTbeView.rfqVendorStatus) + ) + + // 5) finalWhere + const finalWhere = and( + eq(vendorTbeView.rfqId, rfqId), + // 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)] + + // 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, + }) + .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)] + }) + + 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") + ) + ) + .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, + }) + } + + // 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); + } + + // 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)); + + // 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 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; + + 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, []); + + 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); + } + } + } + + // 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-vendors"], + } + )() +} + +export async function getTBEforVendor(input: GetTBESchema, vendorId: number) { + + if (isNaN(vendorId) || vendorId === null || vendorId === undefined) { + 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}` + ) + } + + // 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)] + + // 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, + rfqType:vendorTbeView.rfqType, + 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, + }) + .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)] + }) + + 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만 + ) + ) + .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, + }) + } + + // 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) + + // 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) + ) + ) + .groupBy(rfqAttachments.evaluationId, rfqAttachments.vendorId) + + // 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 + + // 템플릿 파일 수 + const templateFileCount = tbeId !== null ? templateFileCountMap.get(tbeId) || 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, // 추가: 응답 파일 제출 여부 + } + }) + + const pageCount = Math.ceil(total / limit) + return { data: final, pageCount } + }, + [JSON.stringify(input), String(vendorId)], // 캐싱 키에 packagesId 추가 + { + revalidate: 3600, + tags: [`tbe-vendors-${vendorId}`], + } + )() +} + +export async function inviteTbeVendorsAction(formData: FormData) { + // 캐싱 방지 + unstable_noStore() + + try { + // 1) FormData에서 기본 필드 추출 + const rfqId = Number(formData.get("rfqId")) + const vendorIdsRaw = formData.getAll("vendorIds[]") + const vendorIds = vendorIdsRaw.map((id) => Number(id)) + + // 2) FormData에서 파일들 추출 (multiple) + const tbeFiles = formData.getAll("tbeFiles") as File[] + if (!rfqId || !vendorIds.length || !tbeFiles.length) { + throw new Error("Invalid input or no files attached.") + } + + // /public/rfq/[rfqId] 경로 + const uploadDir = path.join(process.cwd(), "public", "rfq", String(rfqId)) + + // 디렉토리가 없다면 생성 + try { + await fs.mkdir(uploadDir, { recursive: true }) + } catch (err) { + console.error("디렉토리 생성 실패:", err) + } + + // DB 트랜잭션 + await db.transaction(async (tx) => { + // (A) RFQ 기본 정보 조회 + const [rfqRow] = await tx + .select({ + rfqCode: vendorResponsesView.rfqCode, + description: vendorResponsesView.rfqDescription, + projectCode: vendorResponsesView.projectCode, + projectName: vendorResponsesView.projectName, + dueDate: vendorResponsesView.rfqDueDate, + createdBy: vendorResponsesView.rfqCreatedBy, + }) + .from(vendorResponsesView) + .where(eq(vendorResponsesView.rfqId, rfqId)) + + if (!rfqRow) { + throw new Error(`RFQ #${rfqId} not found`) + } + + // (B) RFQ 아이템 목록 + const items = await tx + .select({ + itemCode: rfqItems.itemCode, + description: rfqItems.description, + quantity: rfqItems.quantity, + uom: rfqItems.uom, + }) + .from(rfqItems) + .where(eq(rfqItems.rfqId, rfqId)) + + // (C) 대상 벤더들 (이메일 정보 확장) + const vendorRows = await tx + .select({ + id: vendors.id, + name: vendors.vendorName, + email: vendors.email, + representativeEmail: vendors.representativeEmail // 대표자 이메일 추가 + }) + .from(vendors) + .where(sql`${vendors.id} in (${vendorIds})`) + + // (D) 모든 TBE 파일 저장 & 이후 협력업체 초대 처리 + // 파일은 한 번만 저장해도 되지만, 각 벤더별로 따로 저장/첨부가 필요하다면 루프를 돌려도 됨. + // 여기서는 "모든 파일"을 RFQ-DIR에 저장 + "각 협력업체"에는 동일 파일 목록을 첨부한다는 예시. + const savedFiles = [] + for (const file of tbeFiles) { + const originalName = file.name || "tbe-sheet.xlsx" + // 파일명 충돌 방지를 위한 타임스탬프 추가 + const timestamp = new Date().getTime() + const fileName = `${timestamp}-${originalName}` + const savePath = path.join(uploadDir, fileName) + + // 파일 ArrayBuffer → Buffer 변환 후 저장 + const arrayBuffer = await file.arrayBuffer() + await fs.writeFile(savePath, Buffer.from(arrayBuffer)) + + // 저장 경로 & 파일명 기록 + savedFiles.push({ + fileName: originalName, // 원본 파일명으로 첨부 + filePath: `/rfq/${rfqId}/${fileName}`, // public 이하 경로 + absolutePath: savePath, + }) + } + + // (E) 각 벤더별로 TBE 평가 레코드, 초대 처리, 메일 발송 + for (const vendor of vendorRows) { + // 1) 협력업체 연락처 조회 - 추가 이메일 수집 + const contacts = await tx + .select({ + contactName: vendorContacts.contactName, + contactEmail: vendorContacts.contactEmail, + isPrimary: vendorContacts.isPrimary, + }) + .from(vendorContacts) + .where(eq(vendorContacts.vendorId, vendor.id)) + + // 2) 모든 이메일 주소 수집 및 중복 제거 + const allEmails = new Set<string>() + + // 협력업체 이메일 추가 (있는 경우에만) + if (vendor.email) { + allEmails.add(vendor.email.trim().toLowerCase()) + } + + // 협력업체 대표자 이메일 추가 (있는 경우에만) + if (vendor.representativeEmail) { + allEmails.add(vendor.representativeEmail.trim().toLowerCase()) + } + + // 연락처 이메일 추가 + contacts.forEach(contact => { + if (contact.contactEmail) { + allEmails.add(contact.contactEmail.trim().toLowerCase()) + } + }) + + // 중복이 제거된 이메일 주소 배열로 변환 + const uniqueEmails = Array.from(allEmails) + + if (uniqueEmails.length === 0) { + console.warn(`협력업체 ID ${vendor.id}에 등록된 이메일 주소가 없습니다. TBE 초대를 건너뜁니다.`) + continue + } + + // 3) TBE 평가 레코드 생성 + const [evalRow] = await tx + .insert(rfqEvaluations) + .values({ + rfqId, + vendorId: vendor.id, + evalType: "TBE", + }) + .returning({ id: rfqEvaluations.id }) + + // 4) rfqAttachments에 저장한 파일들을 기록 + for (const sf of savedFiles) { + await tx.insert(rfqAttachments).values({ + rfqId, + vendorId: vendor.id, + evaluationId: evalRow.id, + fileName: sf.fileName, + filePath: sf.filePath, + }) + } + + // 5) 각 고유 이메일 주소로 초대 메일 발송 + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000' + const loginUrl = `${baseUrl}/ko/partners/rfq` + + console.log(`협력업체 ID ${vendor.id}(${vendor.name})에 대해 ${uniqueEmails.length}개의 고유 이메일로 TBE 초대 발송`) + + for (const email of uniqueEmails) { + try { + // 연락처 이름 찾기 (이메일과 일치하는 연락처가 있으면 사용, 없으면 '벤더명 담당자'로 대체) + const contact = contacts.find(c => + c.contactEmail && c.contactEmail.toLowerCase() === email.toLowerCase() + ) + const contactName = contact?.contactName || `${vendor.name} 담당자` + + await sendEmail({ + to: email, + subject: `[RFQ ${rfqRow.rfqCode}] You are invited for TBE!`, + template: "rfq-invite", + context: { + language: "en", + rfqId, + vendorId: vendor.id, + contactName, // 연락처 이름 추가 + rfqCode: rfqRow.rfqCode, + projectCode: rfqRow.projectCode, + projectName: rfqRow.projectName, + dueDate: rfqRow.dueDate, + description: rfqRow.description, + items: items.map((it) => ({ + itemCode: it.itemCode, + description: it.description, + quantity: it.quantity, + uom: it.uom, + })), + loginUrl, + }, + attachments: savedFiles.map((sf) => ({ + path: sf.absolutePath, + filename: sf.fileName, + })), + }) + console.log(`이메일 전송 성공: ${email} (${contactName})`) + } catch (emailErr) { + console.error(`이메일 전송 실패 (${email}):`, emailErr) + } + } + } + + // 6) 캐시 무효화 + revalidateTag("tbe-vendors") + }) + + // 성공 + return { error: null } + } catch (err) { + console.error("[inviteTbeVendorsAction] Error:", err) + return { error: getErrorMessage(err) } + } +} +////partners + + +export async function modifyRfqVendor(input: UpdateRfqVendorSchema) { + unstable_noStore(); + try { + const data = await db.transaction(async (tx) => { + const [res] = await updateRfqVendor(tx, input.id, { + responseStatus: input.status, + }); + return res; + }); + + revalidateTag("rfqs-vendor"); + revalidateTag("rfq-vendors"); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +export async function createRfqCommentWithAttachments(params: { + rfqId: number + vendorId?: number | null + commentText: string + commentedBy: number + evaluationId?: number | null + cbeId?: number | null + files?: File[] +}) { + const { rfqId, vendorId, commentText, commentedBy, evaluationId,cbeId, files } = params + console.log("cbeId", cbeId) + console.log("evaluationId", evaluationId) + // 1) 새로운 코멘트 생성 + const [insertedComment] = await db + .insert(rfqComments) + .values({ + rfqId, + vendorId: vendorId || null, + commentText, + commentedBy, + evaluationId: evaluationId || null, + cbeId: cbeId || null, + }) + .returning({ id: rfqComments.id, createdAt: rfqComments.createdAt }) // id만 반환하도록 + + if (!insertedComment) { + throw new Error("Failed to create comment") + } + + // 2) 첨부파일 처리 + if (files && files.length > 0) { + + const rfqDir = path.join(process.cwd(), "public", "rfq", String(rfqId)); + // 폴더 없으면 생성 + await fs.mkdir(rfqDir, { recursive: true }); + + for (const file of files) { + const ab = await file.arrayBuffer(); + const buffer = Buffer.from(ab); + + // 2-2) 고유 파일명 + const uniqueName = `${randomUUID()}-${file.name}`; + // 예) "rfq/123/xxx" + const relativePath = path.join("rfq", String(rfqId), uniqueName); + const absolutePath = path.join(process.cwd(), "public", relativePath); + + // 2-3) 파일 저장 + await fs.writeFile(absolutePath, buffer); + + // DB에 첨부파일 row 생성 + await db.insert(rfqAttachments).values({ + rfqId, + vendorId: vendorId || null, + evaluationId: evaluationId || null, + cbeId: cbeId || null, + commentId: insertedComment.id, // 새 코멘트와 연결 + fileName: file.name, + filePath: "/" + relativePath.replace(/\\/g, "/"), + }) + } + } + + revalidateTag("rfq-vendors"); + + return { ok: true, commentId: insertedComment.id, createdAt: insertedComment.createdAt } +} + +export async function fetchRfqAttachmentsbyCommentId(commentId: number) { + // DB select + const rows = await db + .select() + .from(rfqAttachments) + .where(eq(rfqAttachments.commentId, commentId)) + + // rows: { id, fileName, filePath, createdAt, vendorId, ... } + // 필요 없는 필드는 omit하거나 transform 가능 + return rows.map((row) => ({ + id: row.id, + fileName: row.fileName, + filePath: row.filePath, + createdAt: row.createdAt, // or string + vendorId: row.vendorId, + evaluationId: row.evaluationId, + size: undefined, // size를 DB에 저장하지 않았다면 + })) +} + +export async function updateRfqComment(params: { + commentId: number + commentText: string +}) { + const { commentId, commentText } = params + + // 예: 간단한 길이 체크 등 유효성 검사 + if (!commentText || commentText.trim().length === 0) { + throw new Error("Comment text must not be empty.") + } + + // DB 업데이트 + const updatedRows = await db + .update(rfqComments) + .set({ commentText }) // 필요한 컬럼만 set + .where(eq(rfqComments.id, commentId)) + .returning({ id: rfqComments.id }) + + // 혹은 returning 전체(row)를 받아서 확인할 수도 있음 + if (updatedRows.length === 0) { + // 해당 id가 없으면 예외 + throw new Error("Comment not found or already deleted.") + } + revalidateTag("rfq-vendors"); + return { ok: true } +} + +export type Project = { + id: number; + projectCode: string; + projectName: string; +} + +export async function getProjects(): Promise<Project[]> { + try { + // 트랜잭션을 사용하여 프로젝트 데이터 조회 + const projectList = await db.transaction(async (tx) => { + // 모든 프로젝트 조회 + const results = await tx + .select({ + id: projects.id, + projectCode: projects.code, // 테이블의 실제 컬럼명에 맞게 조정 + projectName: projects.name, // 테이블의 실제 컬럼명에 맞게 조정 + }) + .from(projects) + .orderBy(projects.code); + + return results; + }); + + return projectList; + } catch (error) { + console.error("프로젝트 목록 가져오기 실패:", error); + return []; // 오류 발생 시 빈 배열 반환 + } +} + + +export async function getBidProjects(): Promise<Project[]> { + try { + // 트랜잭션을 사용하여 프로젝트 데이터 조회 + const projectList = await db.transaction(async (tx) => { + // 모든 프로젝트 조회 + const results = await tx + .select({ + id: biddingProjects.id, + projectCode: biddingProjects.pspid, + projectName: biddingProjects.projNm, + }) + .from(biddingProjects) + .orderBy(biddingProjects.id); + + return results; + }); + + // Handle null projectName values + const validProjectList = projectList.map(project => ({ + ...project, + projectName: project.projectName || '' // Replace null with empty string + })); + + return validProjectList; + } catch (error) { + console.error("프로젝트 목록 가져오기 실패:", error); + return []; // 오류 발생 시 빈 배열 반환 + } +} + + +// 반환 타입 명시적 정의 - rfqCode가 null일 수 있음을 반영 +export interface BudgetaryRfq { + id: number; + rfqCode: string | null; // null 허용으로 변경 + description: string | null; + projectId: number | null; + projectCode: string | null; + projectName: string | null; +} + +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 검색 + rfqTypes?: RfqType[]; // 특정 RFQ 타입들로 필터링 + limit?: number; + offset?: number; +} + +export async function getBudgetaryRfqs(params: GetBudgetaryRfqsParams = {}): Promise<GetBudgetaryRfqsResponse> { + const { search, projectId, rfqId, rfqTypes, limit = 50, offset = 0 } = params; + const cacheKey = `rfqs-query-${JSON.stringify(params)}`; + + return unstable_cache( + async () => { + try { + // 기본 검색 조건 구성 + let baseCondition; + + // 특정 RFQ 타입들로 필터링 (rfqTypes 배열이 주어진 경우) + if (rfqTypes && rfqTypes.length > 0) { + // 여러 타입으로 필터링 (OR 조건) + baseCondition = inArray(rfqs.rfqType, rfqTypes); + } else { + // 기본적으로 BUDGETARY 타입만 검색 (이전 동작 유지) + baseCondition = eq(rfqs.rfqType, RfqType.BUDGETARY); + } + + // 특정 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, + rfqType: rfqs.rfqType, // RFQ 타입 필드 추가 + 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 +} + + +export async function getVendorContactsByVendorId(vendorId: number) { + try { + const contacts = await db.query.vendorContacts.findMany({ + where: eq(vendorContacts.vendorId, vendorId), + }); + + return { success: true, data: contacts }; + } catch (error) { + console.error("Error fetching vendor contacts:", error); + return { success: false, error: "Failed to fetch vendor contacts" }; + } +} +/** + * Server action to associate items from an RFQ with a vendor + * + * @param rfqId - The ID of the RFQ containing items to associate + * @param vendorId - The ID of the vendor to associate items with + * @returns Object indicating success or failure + */ +export async function addItemToVendors(rfqId: number, vendorIds: number[]) { + try { + // Input validation + if (!vendorIds.length) { + return { + success: false, + error: "No vendors selected" + }; + } + + // 1. Find all itemCodes associated with the given rfqId using select + const rfqItemResults = await db + .select({ itemCode: rfqItems.itemCode }) + .from(rfqItems) + .where(eq(rfqItems.rfqId, rfqId)); + + // Extract itemCodes + const itemCodes = rfqItemResults.map(item => item.itemCode); + + if (itemCodes.length === 0) { + return { + success: false, + error: "No items found for this RFQ" + }; + } + + // 2. Find existing vendor-item combinations to avoid duplicates + const existingCombinations = await db + .select({ + vendorId: vendorPossibleItems.vendorId, + itemCode: vendorPossibleItems.itemCode + }) + .from(vendorPossibleItems) + .where( + and( + inArray(vendorPossibleItems.vendorId, vendorIds), + inArray(vendorPossibleItems.itemCode, itemCodes) + ) + ); + + // Create a Set of existing combinations for easy lookups + const existingSet = new Set(); + existingCombinations.forEach(combo => { + existingSet.add(`${combo.vendorId}-${combo.itemCode}`); + }); + + // 3. Prepare records to insert (only non-existing combinations) + const recordsToInsert = []; + + for (const vendorId of vendorIds) { + for (const itemCode of itemCodes) { + const key = `${vendorId}-${itemCode}`; + if (!existingSet.has(key)) { + recordsToInsert.push({ + vendorId, + itemCode, + // createdAt and updatedAt will be set by defaultNow() + }); + } + } + } + + // 4. Bulk insert if there are records to insert + let insertedCount = 0; + if (recordsToInsert.length > 0) { + const result = await db.insert(vendorPossibleItems).values(recordsToInsert); + insertedCount = recordsToInsert.length; + } + + // 5. Revalidate to refresh data + revalidateTag("rfq-vendors"); + + // 6. Return success with counts + return { + success: true, + insertedCount, + totalPossibleItems: vendorIds.length * itemCodes.length, + vendorCount: vendorIds.length, + itemCount: itemCodes.length + }; + } catch (error) { + console.error("Error adding items to vendors:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error" + }; + } +} + +/** + * 특정 평가에 대한 TBE 템플릿 파일 목록 조회 + * evaluationId가 일치하고 vendorId가 null인 파일 목록 + */ +export async function fetchTbeTemplateFiles(evaluationId: number) { + try { + const files = await db + .select({ + id: rfqAttachments.id, + fileName: rfqAttachments.fileName, + filePath: rfqAttachments.filePath, + createdAt: rfqAttachments.createdAt, + }) + .from(rfqAttachments) + .where( + and( + isNull(rfqAttachments.commentId), + isNull(rfqAttachments.vendorId), + eq(rfqAttachments.evaluationId, evaluationId), + // eq(rfqAttachments.vendorId, vendorId), + + ) + ) + + return { files, error: null } + } catch (error) { + console.error("Error fetching TBE template files:", error) + return { + files: [], + error: "템플릿 파일을 가져오는 중 오류가 발생했습니다." + } + } +} + +export async function getFileFromRfqAttachmentsbyid(fileId: number) { + try { + const file = await db + .select({ + fileName: rfqAttachments.fileName, + filePath: rfqAttachments.filePath, + }) + .from(rfqAttachments) + .where(eq(rfqAttachments.id, fileId)) + .limit(1) + + if (!file.length) { + return { file: null, error: "파일을 찾을 수 없습니다." } + } + + return { file: file[0], error: null } + } catch (error) { + console.error("Error getting TBE template file info:", error) + return { + file: null, + error: "파일 정보를 가져오는 중 오류가 발생했습니다." + } + } +} + +/** + * TBE 응답 파일 업로드 처리 + */ +export async function uploadTbeResponseFile(formData: FormData) { + try { + const file = formData.get("file") as File + const rfqId = parseInt(formData.get("rfqId") as string) + const vendorId = parseInt(formData.get("vendorId") as string) + const evaluationId = parseInt(formData.get("evaluationId") as string) + const vendorResponseId = parseInt(formData.get("vendorResponseId") as string) + + if (!file || !rfqId || !vendorId || !evaluationId) { + return { + success: false, + error: "필수 필드가 누락되었습니다." + } + } + + // 타임스탬프 기반 고유 파일명 생성 + const timestamp = Date.now() + const originalName = file.name + const fileExtension = originalName.split(".").pop() + const fileName = `${originalName.split(".")[0]}-${timestamp}.${fileExtension}` + + // 업로드 디렉토리 및 경로 정의 + const uploadDir = join(process.cwd(), "rfq", "tbe-responses") + + // 디렉토리가 없으면 생성 + try { + await mkdir(uploadDir, { recursive: true }) + } catch (error) { + // 이미 존재하면 무시 + } + + const filePath = join(uploadDir, fileName) + + // 파일을 버퍼로 변환 + const bytes = await file.arrayBuffer() + const buffer = Buffer.from(bytes) + + // 파일을 서버에 저장 + await writeFile(filePath, buffer) + + // 먼저 vendorTechnicalResponses 테이블에 엔트리 생성 + const technicalResponse = await db.insert(vendorTechnicalResponses) + .values({ + responseId: vendorResponseId, + summary: "TBE 응답 파일 업로드", // 필요에 따라 수정 + notes: `파일명: ${originalName}`, + responseStatus:"SUBMITTED" + }) + .returning({ id: vendorTechnicalResponses.id }); + + // 생성된 기술 응답 ID 가져오기 + const technicalResponseId = technicalResponse[0].id; + + // 파일 정보를 데이터베이스에 저장 + const dbFilePath = `/rfq/tbe-responses/${fileName}` + + // vendorResponseAttachments 테이블 스키마에 맞게 데이터 삽입 + await db.insert(vendorResponseAttachments) + .values({ + // 오류 메시지를 기반으로 올바른 필드 이름 사용 + // 테이블 스키마에 정의된 필드만 포함해야 함 + responseId: vendorResponseId, + technicalResponseId: technicalResponseId, + // vendorId와 evaluationId 필드가 테이블에 있다면 포함, 없다면 제거 + // vendorId: vendorId, + // evaluationId: evaluationId, + fileName: originalName, + filePath: dbFilePath, + uploadedAt: new Date(), + }); + + // 경로 재검증 (캐시된 데이터 새로고침) + revalidatePath(`/rfq/${rfqId}/tbe`) + revalidateTag(`tbe-vendors-${vendorId}`) + + return { + success: true, + message: "파일이 성공적으로 업로드되었습니다." + } + } catch (error) { + console.error("Error uploading file:", error) + return { + success: false, + error: "파일 업로드에 실패했습니다." + } + } +} + +export async function getTbeSubmittedFiles(responseId: number) { + try { + // First, get the technical response IDs where vendorResponseId matches responseId + const technicalResponses = await db + .select({ + id: vendorTechnicalResponses.id, + }) + .from(vendorTechnicalResponses) + .where( + eq(vendorTechnicalResponses.responseId, responseId) + ) + + if (technicalResponses.length === 0) { + return { files: [], error: null } + } + + // Extract the IDs from the result + const technicalResponseIds = technicalResponses.map(tr => tr.id) + + // Then get attachments where technicalResponseId matches any of the IDs we found + const files = await db + .select({ + id: vendorResponseAttachments.id, + fileName: vendorResponseAttachments.fileName, + filePath: vendorResponseAttachments.filePath, + uploadedAt: vendorResponseAttachments.uploadedAt, + fileType: vendorResponseAttachments.fileType, + attachmentType: vendorResponseAttachments.attachmentType, + description: vendorResponseAttachments.description, + }) + .from(vendorResponseAttachments) + .where( + inArray(vendorResponseAttachments.technicalResponseId, technicalResponseIds) + ) + .orderBy(vendorResponseAttachments.uploadedAt) + + return { files, error: null } + } catch (error) { + return { files: [], error: 'Failed to fetch TBE submitted files' } + } +} + + + +export async function getTbeFilesForVendor(rfqId: number, vendorId: number) { + try { + // Step 1: Get responseId from vendor_responses table + const response = await db + .select({ + id: vendorResponses.id, + }) + .from(vendorResponses) + .where( + and( + eq(vendorResponses.rfqId, rfqId), + eq(vendorResponses.vendorId, vendorId) + ) + ) + .limit(1); + + if (!response || response.length === 0) { + return { files: [], error: 'No vendor response found' }; + } + + const responseId = response[0].id; + + // Step 2: Get the technical response IDs + const technicalResponses = await db + .select({ + id: vendorTechnicalResponses.id, + }) + .from(vendorTechnicalResponses) + .where( + eq(vendorTechnicalResponses.responseId, responseId) + ); + + if (technicalResponses.length === 0) { + return { files: [], error: null }; + } + + // Extract the IDs from the result + const technicalResponseIds = technicalResponses.map(tr => tr.id); + + // Step 3: Get attachments where technicalResponseId matches any of the IDs + const files = await db + .select({ + id: vendorResponseAttachments.id, + fileName: vendorResponseAttachments.fileName, + filePath: vendorResponseAttachments.filePath, + uploadedAt: vendorResponseAttachments.uploadedAt, + fileType: vendorResponseAttachments.fileType, + attachmentType: vendorResponseAttachments.attachmentType, + description: vendorResponseAttachments.description, + }) + .from(vendorResponseAttachments) + .where( + inArray(vendorResponseAttachments.technicalResponseId, technicalResponseIds) + ) + .orderBy(vendorResponseAttachments.uploadedAt); + + return { files, error: null }; + } catch (error) { + return { files: [], error: 'Failed to fetch vendor files' }; + } +} + +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 + + // 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}`, + sql`${vendorTbeView.rfqCode} ILIKE ${s}`, + sql`${vendorTbeView.projectCode} ILIKE ${s}`, + sql`${vendorTbeView.projectName} ILIKE ${s}` + ) + } + + // 4) REJECTED 아니거나 NULL + const notRejected = or( + ne(vendorTbeView.rfqVendorStatus, "REJECTED"), + isNull(vendorTbeView.rfqVendorStatus) + ) + + // 5) rfqType 필터 추가 + const rfqTypeFilter = input.rfqType ? eq(vendorTbeView.rfqType, input.rfqType) : undefined + + // 6) finalWhere - rfqType 필터 추가 + const finalWhere = and( + notRejected, + advancedWhere, + globalWhere, + rfqTypeFilter // 새로 추가된 rfqType 필터 + ) + + // 6) 정렬 + const orderBy = input.sort?.length + ? input.sort.map((s) => { + const col = (vendorTbeView as any)[s.id] + return s.desc ? desc(col) : asc(col) + }) + : [desc(vendorTbeView.rfqId), asc(vendorTbeView.vendorId)] // Default sort by newest RFQ first + + // 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, + + technicalResponseStatus:vendorTbeView.technicalResponseStatus, + 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)] + }) + + if (!rows.length) { + return { data: [], pageCount: 0 } + } + + // 8) Get distinct rfqIds and vendorIds - filter out nulls + const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId).filter(Boolean))] as number[]; + const distinctRfqIds = [...new Set(rows.map((r) => r.rfqId).filter(Boolean))] as number[]; + + // 9) Comments 조회 + const commentsConditions = [isNotNull(rfqComments.evaluationId)]; + + // 배열이 비어있지 않을 때만 조건 추가 + if (distinctRfqIds.length > 0) { + commentsConditions.push(inArray(rfqComments.rfqId, distinctRfqIds)); + } + + if (distinctVendorIds.length > 0) { + commentsConditions.push(inArray(rfqComments.vendorId, distinctVendorIds)); + } + + const commAll = await db + .select({ + id: rfqComments.id, + commentText: rfqComments.commentText, + vendorId: rfqComments.vendorId, + rfqId: rfqComments.rfqId, + 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") + ) + ) + .where(and(...commentsConditions)); + + // 9-A) Create a composite key (rfqId-vendorId) -> comments mapping + const commByCompositeKey = new Map<string, any[]>() + for (const c of commAll) { + if (!c.rfqId || !c.vendorId) continue; + + const compositeKey = `${c.rfqId}-${c.vendorId}`; + if (!commByCompositeKey.has(compositeKey)) { + commByCompositeKey.set(compositeKey, []) + } + commByCompositeKey.get(compositeKey)!.push({ + id: c.id, + commentText: c.commentText, + vendorId: c.vendorId, + evaluationId: c.evaluationId, + createdAt: c.createdAt, + commentedBy: c.commentedBy, + }) + } + + // 10) Responses 조회 + const responsesAll = await db + .select({ + id: vendorResponses.id, + rfqId: vendorResponses.rfqId, + vendorId: vendorResponses.vendorId + }) + .from(vendorResponses) + .where( + and( + inArray(vendorResponses.rfqId, distinctRfqIds), + inArray(vendorResponses.vendorId, distinctVendorIds) + ) + ); + + // Group responses by rfqId-vendorId composite key + const responsesByCompositeKey = new Map<string, number[]>(); + for (const resp of responsesAll) { + const compositeKey = `${resp.rfqId}-${resp.vendorId}`; + if (!responsesByCompositeKey.has(compositeKey)) { + responsesByCompositeKey.set(compositeKey, []); + } + responsesByCompositeKey.get(compositeKey)!.push(resp.id); + } + + // Get all responseIds + const allResponseIds = responsesAll.map(r => r.id); + + // 11) 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); + } + + // Get all technicalResponseIds + const allTechnicalResponseIds = technicalResponsesAll.map(tr => tr.id); + + // 12) 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) + ) + ); + + // Create mapping from technicalResponseId to attachments + const filesByTechnicalResponseId = new Map<number, any[]>(); + for (const file of filesAll) { + 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 + }); + } + + // 13) Create the final filesByCompositeKey map + const filesByCompositeKey = new Map<string, any[]>(); + + for (const [compositeKey, responseIds] of responsesByCompositeKey.entries()) { + filesByCompositeKey.set(compositeKey, []); + + for (const responseId of responseIds) { + const technicalResponseIds = technicalResponseIdsByResponseId.get(responseId) || []; + + for (const technicalResponseId of technicalResponseIds) { + const files = filesByTechnicalResponseId.get(technicalResponseId) || []; + filesByCompositeKey.get(compositeKey)!.push(...files); + } + } + } + + // 14) 최종 합치기 + const final = rows.map((row) => { + const compositeKey = `${row.rfqId}-${row.vendorId}`; + + return { + ...row, + dueDate: row.dueDate ? new Date(row.dueDate) : null, + comments: commByCompositeKey.get(compositeKey) ?? [], + files: filesByCompositeKey.get(compositeKey) ?? [], + }; + }) + + const pageCount = Math.ceil(total / limit) + return { data: final, pageCount } + }, + [JSON.stringify(input)], + { + revalidate: 3600, + tags: ["all-tbe-vendors"], + } + )() +} + + +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; + + // [2] 고급 필터 + const advancedWhere = filterColumns({ + table: vendorResponseCBEView, + filters: input.filters ?? [], + joinOperator: input.joinOperator ?? "and", + }); + + // [3] 글로벌 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + sql`${vendorResponseCBEView.vendorName} ILIKE ${s}`, + sql`${vendorResponseCBEView.vendorCode} ILIKE ${s}`, + sql`${vendorResponseCBEView.rfqCode} ILIKE ${s}`, + sql`${vendorResponseCBEView.totalPrice}::text ILIKE ${s}` + ); + } + + // [4] DECLINED 상태 제외 (거절된 업체는 표시하지 않음) + const notDeclined = ne(vendorResponseCBEView.responseStatus, "DECLINED"); + + // [5] 최종 where 조건 + const finalWhere = and( + eq(vendorResponseCBEView.rfqId, rfqId), + notDeclined, + advancedWhere ?? undefined, + globalWhere ?? undefined + ); + + // [6] 정렬 + const orderBy = input.sort?.length + ? input.sort.map((s) => { + // vendorResponseCBEView 컬럼 중 정렬 대상이 되는 것만 매핑 + const col = (vendorResponseCBEView as any)[s.id]; + return s.desc ? desc(col) : asc(col); + }) + : [asc(vendorResponseCBEView.vendorName)]; // 기본 정렬은 벤더명 + + // [7] 메인 SELECT + const [rows, total] = await db.transaction(async (tx) => { + const data = await tx + .select({ + // 기본 식별 정보 + responseId: vendorResponseCBEView.responseId, + vendorId: vendorResponseCBEView.vendorId, + rfqId: vendorResponseCBEView.rfqId, + + // 협력업체 정보 + vendorName: vendorResponseCBEView.vendorName, + vendorCode: vendorResponseCBEView.vendorCode, + vendorStatus: vendorResponseCBEView.vendorStatus, + + // RFQ 정보 + rfqCode: vendorResponseCBEView.rfqCode, + rfqDescription: vendorResponseCBEView.rfqDescription, + rfqDueDate: vendorResponseCBEView.rfqDueDate, + rfqStatus: vendorResponseCBEView.rfqStatus, + rfqType: vendorResponseCBEView.rfqType, + + // 프로젝트 정보 + projectId: vendorResponseCBEView.projectId, + projectCode: vendorResponseCBEView.projectCode, + projectName: vendorResponseCBEView.projectName, + + // 응답 상태 정보 + responseStatus: vendorResponseCBEView.responseStatus, + responseNotes: vendorResponseCBEView.notes, + respondedAt: vendorResponseCBEView.respondedAt, + respondedBy: vendorResponseCBEView.respondedBy, + + // 상업 응답 정보 + commercialResponseId: vendorResponseCBEView.commercialResponseId, + commercialResponseStatus: vendorResponseCBEView.commercialResponseStatus, + totalPrice: vendorResponseCBEView.totalPrice, + currency: vendorResponseCBEView.currency, + paymentTerms: vendorResponseCBEView.paymentTerms, + incoterms: vendorResponseCBEView.incoterms, + deliveryPeriod: vendorResponseCBEView.deliveryPeriod, + warrantyPeriod: vendorResponseCBEView.warrantyPeriod, + validityPeriod: vendorResponseCBEView.validityPeriod, + commercialNotes: vendorResponseCBEView.commercialNotes, + + // 첨부파일 카운트 + attachmentCount: vendorResponseCBEView.attachmentCount, + commercialAttachmentCount: vendorResponseCBEView.commercialAttachmentCount, + technicalAttachmentCount: vendorResponseCBEView.technicalAttachmentCount, + }) + .from(vendorResponseCBEView) + .where(finalWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(limit); + + const [{ count }] = await tx + .select({ count: sql<number>`count(*)`.as("count") }) + .from(vendorResponseCBEView) + .where(finalWhere); + + return [data, Number(count)]; + }); + + if (!rows.length) { + return { data: [], pageCount: 0, total: 0 }; + } + + // [8] 협력업체 ID 목록 추출 + const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))]; + const distinctResponseIds = [...new Set(rows.map((r) => r.responseId))]; + const distinctCommercialResponseIds = [...new Set(rows.filter(r => r.commercialResponseId).map((r) => r.commercialResponseId!))]; + + // [9] CBE 평가 관련 코멘트 조회 + const commentsAll = await db + .select({ + id: rfqComments.id, + commentText: rfqComments.commentText, + vendorId: rfqComments.vendorId, + cbeId: rfqComments.cbeId, + createdAt: rfqComments.createdAt, + commentedBy: rfqComments.commentedBy, + }) + .from(rfqComments) + .innerJoin( + vendorResponses, + eq(vendorResponses.id, rfqComments.cbeId) + ) + .where( + and( + isNotNull(rfqComments.cbeId), + eq(rfqComments.rfqId, rfqId), + inArray(rfqComments.vendorId, distinctVendorIds) + ) + ); + + // vendorId별 코멘트 그룹화 + const commentsByVendorId = new Map<number, any[]>(); + for (const comment of commentsAll) { + const vendorId = comment.vendorId!; + if (!commentsByVendorId.has(vendorId)) { + commentsByVendorId.set(vendorId, []); + } + commentsByVendorId.get(vendorId)!.push({ + id: comment.id, + commentText: comment.commentText, + vendorId: comment.vendorId, + cbeId: comment.cbeId, + createdAt: comment.createdAt, + commentedBy: comment.commentedBy, + }); + } + + // [10] 첨부 파일 조회 - 일반 응답 첨부파일 + const responseAttachments = await db + .select({ + id: vendorResponseAttachments.id, + fileName: vendorResponseAttachments.fileName, + filePath: vendorResponseAttachments.filePath, + responseId: vendorResponseAttachments.responseId, + fileType: vendorResponseAttachments.fileType, + attachmentType: vendorResponseAttachments.attachmentType, + description: vendorResponseAttachments.description, + uploadedAt: vendorResponseAttachments.uploadedAt, + uploadedBy: vendorResponseAttachments.uploadedBy, + }) + .from(vendorResponseAttachments) + .where( + and( + inArray(vendorResponseAttachments.responseId, distinctResponseIds), + isNotNull(vendorResponseAttachments.responseId) + ) + ); + + // [11] 첨부 파일 조회 - 상업 응답 첨부파일 + const commercialResponseAttachments = await db + .select({ + id: vendorResponseAttachments.id, + fileName: vendorResponseAttachments.fileName, + filePath: vendorResponseAttachments.filePath, + commercialResponseId: vendorResponseAttachments.commercialResponseId, + fileType: vendorResponseAttachments.fileType, + attachmentType: vendorResponseAttachments.attachmentType, + description: vendorResponseAttachments.description, + uploadedAt: vendorResponseAttachments.uploadedAt, + uploadedBy: vendorResponseAttachments.uploadedBy, + }) + .from(vendorResponseAttachments) + .where( + and( + inArray(vendorResponseAttachments.commercialResponseId, distinctCommercialResponseIds), + isNotNull(vendorResponseAttachments.commercialResponseId) + ) + ); + + // [12] 첨부파일 그룹화 + // responseId별 첨부파일 맵 생성 + const filesByResponseId = new Map<number, any[]>(); + for (const file of responseAttachments) { + const responseId = file.responseId!; + if (!filesByResponseId.has(responseId)) { + filesByResponseId.set(responseId, []); + } + filesByResponseId.get(responseId)!.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, + attachmentSource: 'response' + }); + } + + // commercialResponseId별 첨부파일 맵 생성 + const filesByCommercialResponseId = new Map<number, any[]>(); + for (const file of commercialResponseAttachments) { + const commercialResponseId = file.commercialResponseId!; + if (!filesByCommercialResponseId.has(commercialResponseId)) { + filesByCommercialResponseId.set(commercialResponseId, []); + } + filesByCommercialResponseId.get(commercialResponseId)!.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, + attachmentSource: 'commercial' + }); + } + + // [13] 최종 데이터 병합 + const final = rows.map((row) => { + // 해당 응답의 모든 첨부파일 가져오기 + const responseFiles = filesByResponseId.get(row.responseId) || []; + const commercialFiles = row.commercialResponseId + ? filesByCommercialResponseId.get(row.commercialResponseId) || [] + : []; + + // 모든 첨부파일 병합 + const allFiles = [...responseFiles, ...commercialFiles]; + + return { + ...row, + rfqDueDate: row.rfqDueDate ? new Date(row.rfqDueDate) : null, + respondedAt: row.respondedAt ? new Date(row.respondedAt) : null, + comments: commentsByVendorId.get(row.vendorId) || [], + files: allFiles, + }; + }); + + const pageCount = Math.ceil(total / limit); + return { + data: final, + pageCount, + total + }; + }, + // 캐싱 키 & 옵션 + [`cbe-vendors-${rfqId}-${JSON.stringify(input)}`], + { + revalidate: 3600, + tags: [`cbe-vendors-${rfqId}`], + } + )(); +} + +export async function generateNextRfqCode(rfqType: RfqType): Promise<{ code: string; error?: string }> { + try { + if (!rfqType) { + return { code: "", error: 'RFQ 타입이 필요합니다' }; + } + + // 현재 연도 가져오기 + const currentYear = new Date().getFullYear(); + + // 현재 연도와 타입에 맞는 최신 RFQ 코드 찾기 + const latestRfqs = await db.select({ rfqCode: rfqs.rfqCode }) + .from(rfqs) + .where(and( + sql`SUBSTRING(${rfqs.rfqCode}, 5, 4) = ${currentYear.toString()}`, + eq(rfqs.rfqType, rfqType) + )) + .orderBy(desc(rfqs.rfqCode)) + .limit(1); + + let sequenceNumber = 1; + + if (latestRfqs.length > 0 && latestRfqs[0].rfqCode) { + // null 체크 추가 - TypeScript 오류 해결 + const latestCode = latestRfqs[0].rfqCode; + const matches = latestCode.match(/[A-Z]+-\d{4}-(\d{3})/); + + if (matches && matches[1]) { + sequenceNumber = parseInt(matches[1], 10) + 1; + } + } + + // 새로운 RFQ 코드 포맷팅 + const typePrefix = rfqType === RfqType.BUDGETARY ? 'BUD' : + rfqType === RfqType.PURCHASE_BUDGETARY ? 'PBU' : 'RFQ'; + + const newCode = `${typePrefix}-${currentYear}-${String(sequenceNumber).padStart(3, '0')}`; + + return { code: newCode }; + } catch (error) { + console.error('Error generating next RFQ code:', error); + return { code: "", error: '코드 생성에 실패했습니다' }; + } +} + +interface SaveTbeResultParams { + id: number // id from the rfq_evaluations table + vendorId: number // vendorId from the rfq_evaluations table + result: string // The selected evaluation result + notes: string // The evaluation notes +} + +export async function saveTbeResult({ + id, + vendorId, + result, + notes, +}: SaveTbeResultParams) { + try { + // Check if we have all required data + if (!id || !vendorId || !result) { + return { + success: false, + message: "Missing required data for evaluation update", + } + } + + // Update the record in the database + await db + .update(rfqEvaluations) + .set({ + result: result, + notes: notes, + updatedAt: new Date(), + }) + .where( + and( + eq(rfqEvaluations.id, id), + eq(rfqEvaluations.vendorId, vendorId), + eq(rfqEvaluations.evalType, "TBE") + ) + ) + + // Revalidate the tbe-vendors tag to refresh the data + revalidateTag("tbe-vendors") + revalidateTag("all-tbe-vendors") + + return { + success: true, + message: "TBE evaluation updated successfully", + } + } catch (error) { + console.error("Failed to update TBE evaluation:", error) + + return { + success: false, + message: error instanceof Error ? error.message : "An unknown error occurred", + } + } +} + + +export async function createCbeEvaluation(formData: FormData) { + try { + // 폼 데이터 추출 + const rfqId = Number(formData.get("rfqId")) + const vendorIds = formData.getAll("vendorIds[]").map(id => Number(id)) + const evaluatedBy = formData.get("evaluatedBy") ? Number(formData.get("evaluatedBy")) : null + + + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + + // 기본 CBE 데이터 추출 + const rawData = { + rfqId, + paymentTerms: formData.get("paymentTerms") as string, + incoterms: formData.get("incoterms") as string, + deliverySchedule: formData.get("deliverySchedule") as string, + notes: formData.get("notes") as string, + // 단일 협력업체 처리 시 사용할 vendorId (여러 협력업체 처리에선 사용하지 않음) + // vendorId: vendorIds[0] || 0, + } + + // zod 스키마 유효성 검사 (vendorId는 더미로 채워 검증하고 실제로는 배열로 처리) + const validationResult = createCbeEvaluationSchema.safeParse(rawData) + if (!validationResult.success) { + const errors = validationResult.error.format() + console.error("Validation errors:", errors) + return { error: "입력 데이터가 유효하지 않습니다." } + } + + const validData = validationResult.data + + // RFQ 정보 조회 + const [rfqInfo] = await db + .select({ + rfqCode: rfqsView.rfqCode, + projectCode: rfqsView.projectCode, + projectName: rfqsView.projectName, + dueDate: rfqsView.dueDate, + description: rfqsView.description, + }) + .from(rfqsView) + .where(eq(rfqsView.id, rfqId)) + + if (!rfqInfo) { + return { error: "RFQ 정보를 찾을 수 없습니다." } + } + + // 파일 처리 준비 + const files = formData.getAll("files") as File[] + const hasFiles = files && files.length > 0 && files[0].size > 0 + + // 파일 저장을 위한 디렉토리 생성 (파일이 있는 경우에만) + let uploadDir = "" + if (hasFiles) { + uploadDir = path.join(process.cwd(), "public", "rfq", String(rfqId)) + try { + await fs.mkdir(uploadDir, { recursive: true }) + } catch (err) { + console.error("디렉토리 생성 실패:", err) + return { error: "파일 업로드를 위한 디렉토리 생성에 실패했습니다." } + } + } + + // 첨부 파일 정보를 저장할 배열 + const attachments: { filename: string; path: string }[] = [] + + // 파일이 있는 경우, 파일을 저장하고 첨부 파일 정보 준비 + if (hasFiles) { + for (const file of files) { + if (file.size > 0) { + const originalFilename = file.name + const fileExtension = path.extname(originalFilename) + const timestamp = new Date().getTime() + const safeFilename = `cbe-${rfqId}-${timestamp}${fileExtension}` + const filePath = path.join("rfq", String(rfqId), safeFilename) + const fullPath = path.join(process.cwd(), "public", filePath) + + try { + // File을 ArrayBuffer로 변환하여 파일 시스템에 저장 + const arrayBuffer = await file.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + await fs.writeFile(fullPath, buffer) + + // 첨부 파일 정보 추가 + attachments.push({ + filename: originalFilename, + path: fullPath, // 이메일 첨부를 위한 전체 경로 + }) + } catch (err) { + console.error(`파일 저장 실패:`, err) + // 파일 저장 실패를 기록하지만 전체 프로세스는 계속 진행 + } + } + } + } + + // 각 벤더별로 CBE 평가 레코드 생성 및 알림 전송 + const createdCbeIds: number[] = [] + const failedVendors: { id: number, reason: string }[] = [] + + for (const vendorId of vendorIds) { + try { + // 협력업체 정보 조회 (이메일 포함) + const [vendorInfo] = await db + .select({ + id: vendors.id, + name: vendors.vendorName, + vendorCode: vendors.vendorCode, + email: vendors.email, // 협력업체 자체 이메일 추가 + representativeEmail: vendors.representativeEmail, // 협력업체 대표자 이메일 추가 + }) + .from(vendors) + .where(eq(vendors.id, vendorId)) + + if (!vendorInfo) { + failedVendors.push({ id: vendorId, reason: "협력업체 정보를 찾을 수 없습니다." }) + continue + } + + // 기존 협력업체 응답 레코드 찾기 + const existingResponse = await db + .select({ id: vendorResponses.id }) + .from(vendorResponses) + .where( + and( + eq(vendorResponses.rfqId, rfqId), + eq(vendorResponses.vendorId, vendorId) + ) + ) + .limit(1) + + if (existingResponse.length === 0) { + console.error(`협력업체 ID ${vendorId}에 대한 응답 레코드가 존재하지 않습니다.`) + failedVendors.push({ id: vendorId, reason: "협력업체 응답 레코드를 찾을 수 없습니다" }) + continue // 다음 벤더로 넘어감 + } + + // 1. CBE 평가 레코드 생성 + const [newCbeEvaluation] = await db + .insert(cbeEvaluations) + .values({ + rfqId, + vendorId, + evaluatedBy, + result: "PENDING", // 초기 상태는 PENDING으로 설정 + totalCost: 0, // 초기값은 0으로 설정 + currency: "USD", // 기본 통화 설정 + paymentTerms: validData.paymentTerms || null, + incoterms: validData.incoterms || null, + deliverySchedule: validData.deliverySchedule || null, + notes: validData.notes || null, + }) + .returning({ id: cbeEvaluations.id }) + + if (!newCbeEvaluation?.id) { + failedVendors.push({ id: vendorId, reason: "CBE 평가 생성 실패" }) + continue + } + + // 2. 상업 응답 레코드 생성 + const [newCbeResponse] = await db + .insert(vendorCommercialResponses) + .values({ + responseId: existingResponse[0].id, + responseStatus: "PENDING", + currency: "USD", + paymentTerms: validData.paymentTerms || null, + incoterms: validData.incoterms || null, + deliveryPeriod: validData.deliverySchedule || null, + }) + .returning({ id: vendorCommercialResponses.id }) + + if (!newCbeResponse?.id) { + failedVendors.push({ id: vendorId, reason: "상업 응답 생성 실패" }) + continue + } + + createdCbeIds.push(newCbeEvaluation.id) + + // 3. 첨부 파일이 있는 경우, 데이터베이스에 첨부 파일 레코드 생성 + if (hasFiles) { + for (let i = 0; i < attachments.length; i++) { + const attachment = attachments[i] + + await db.insert(rfqAttachments).values({ + rfqId, + vendorId, + fileName: attachment.filename, + filePath: `/${path.relative(path.join(process.cwd(), "public"), attachment.path)}`, // URL 경로를 위해 public 기준 상대 경로로 저장 + cbeId: newCbeEvaluation.id, + }) + } + } + + // 4. 협력업체 연락처 조회 + const contacts = await db + .select({ + contactName: vendorContacts.contactName, + contactEmail: vendorContacts.contactEmail, + isPrimary: vendorContacts.isPrimary, + }) + .from(vendorContacts) + .where(eq(vendorContacts.vendorId, vendorId)) + + // 5. 모든 이메일 주소 수집 및 중복 제거 + const allEmails = new Set<string>() + + // 연락처 이메일 추가 + contacts.forEach(contact => { + if (contact.contactEmail) { + allEmails.add(contact.contactEmail.trim().toLowerCase()) + } + }) + + // 협력업체 자체 이메일 추가 (있는 경우에만) + if (vendorInfo.email) { + allEmails.add(vendorInfo.email.trim().toLowerCase()) + } + + // 협력업체 대표자 이메일 추가 (있는 경우에만) + if (vendorInfo.representativeEmail) { + allEmails.add(vendorInfo.representativeEmail.trim().toLowerCase()) + } + + // 중복이 제거된 이메일 주소 배열로 변환 + const uniqueEmails = Array.from(allEmails) + + if (uniqueEmails.length === 0) { + console.warn(`협력업체 ID ${vendorId}에 등록된 이메일 주소가 없습니다.`) + } else { + console.log(`협력업체 ID ${vendorId}에 대해 ${uniqueEmails.length}개의 고유 이메일 주소로 알림을 전송합니다.`) + + // 이메일 발송에 필요한 공통 데이터 준비 + const emailData = { + rfqId, + cbeId: newCbeEvaluation.id, + vendorId, + rfqCode: rfqInfo.rfqCode, + projectCode: rfqInfo.projectCode, + projectName: rfqInfo.projectName, + dueDate: rfqInfo.dueDate, + description: rfqInfo.description, + vendorName: vendorInfo.name, + vendorCode: vendorInfo.vendorCode, + paymentTerms: validData.paymentTerms, + incoterms: validData.incoterms, + deliverySchedule: validData.deliverySchedule, + notes: validData.notes, + loginUrl: `http://${host}/en/partners/cbe` + } + + // 각 고유 이메일 주소로 이메일 발송 + for (const email of uniqueEmails) { + try { + // 연락처 이름 찾기 (이메일과 일치하는 연락처가 있으면 사용, 없으면 '벤더명 담당자'로 대체) + const contact = contacts.find(c => + c.contactEmail && c.contactEmail.toLowerCase() === email.toLowerCase() + ) + const contactName = contact?.contactName || `${vendorInfo.name} 담당자` + + await sendEmail({ + to: email, + subject: `[RFQ ${rfqInfo.rfqCode}] 상업 입찰 평가 (CBE) 알림`, + template: "cbe-invitation", + context: { + language: "ko", // 또는 다국어 처리를 위한 설정 + contactName, + ...emailData, + }, + attachments: attachments, + }) + console.log(`이메일 전송 성공: ${email}`) + } catch (emailErr) { + console.error(`이메일 전송 실패 (${email}):`, emailErr) + } + } + } + + } catch (err) { + console.error(`협력업체 ID ${vendorId}의 CBE 생성 실패:`, err) + failedVendors.push({ id: vendorId, reason: "예기치 않은 오류" }) + } + } + + // UI 업데이트를 위한 경로 재검증 + revalidatePath(`/rfq/${rfqId}`) + revalidateTag(`cbe-vendors-${rfqId}`) + + // 결과 반환 + if (createdCbeIds.length === 0) { + return { error: "어떤 벤더에 대해서도 CBE 평가를 생성하지 못했습니다." } + } + + return { + success: true, + cbeIds: createdCbeIds, + totalCreated: createdCbeIds.length, + totalFailed: failedVendors.length, + failedVendors: failedVendors.length > 0 ? failedVendors : undefined + } + + } catch (error) { + console.error("CBE 평가 생성 중 오류 발생:", error) + return { error: "예상치 못한 오류가 발생했습니다." } + } +} + +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; + + // [2] 고급 필터 + const advancedWhere = filterColumns({ + table: vendorResponseCBEView, + filters: input.filters ?? [], + joinOperator: input.joinOperator ?? "and", + }); + + // [3] 글로벌 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + sql`${vendorResponseCBEView.rfqCode} ILIKE ${s}`, + sql`${vendorResponseCBEView.projectCode} ILIKE ${s}`, + sql`${vendorResponseCBEView.projectName} ILIKE ${s}`, + sql`${vendorResponseCBEView.totalPrice}::text ILIKE ${s}` + ); + } + + // [4] DECLINED 상태 제외 (거절된 응답은 표시하지 않음) + // const notDeclined = ne(vendorResponseCBEView.responseStatus, "DECLINED"); + + // [5] 최종 where 조건 + const finalWhere = and( + eq(vendorResponseCBEView.vendorId, vendorId), // vendorId로 필터링 + isNotNull(vendorResponseCBEView.commercialCreatedAt), + // notDeclined, + advancedWhere ?? undefined, + globalWhere ?? undefined + ); + + // [6] 정렬 + const orderBy = input.sort?.length + ? input.sort.map((s) => { + // vendorResponseCBEView 컬럼 중 정렬 대상이 되는 것만 매핑 + const col = (vendorResponseCBEView as any)[s.id]; + return s.desc ? desc(col) : asc(col); + }) + : [desc(vendorResponseCBEView.rfqDueDate)]; // 기본 정렬은 RFQ 마감일 내림차순 + + // [7] 메인 SELECT + const [rows, total] = await db.transaction(async (tx) => { + const data = await tx + .select({ + // 기본 식별 정보 + responseId: vendorResponseCBEView.responseId, + vendorId: vendorResponseCBEView.vendorId, + rfqId: vendorResponseCBEView.rfqId, + + // 협력업체 정보 + vendorName: vendorResponseCBEView.vendorName, + vendorCode: vendorResponseCBEView.vendorCode, + vendorStatus: vendorResponseCBEView.vendorStatus, + + // RFQ 정보 + rfqCode: vendorResponseCBEView.rfqCode, + rfqDescription: vendorResponseCBEView.rfqDescription, + rfqDueDate: vendorResponseCBEView.rfqDueDate, + rfqStatus: vendorResponseCBEView.rfqStatus, + rfqType: vendorResponseCBEView.rfqType, + + // 프로젝트 정보 + projectId: vendorResponseCBEView.projectId, + projectCode: vendorResponseCBEView.projectCode, + projectName: vendorResponseCBEView.projectName, + + // 응답 상태 정보 + responseStatus: vendorResponseCBEView.responseStatus, + responseNotes: vendorResponseCBEView.notes, + respondedAt: vendorResponseCBEView.respondedAt, + respondedBy: vendorResponseCBEView.respondedBy, + + // 상업 응답 정보 + commercialResponseId: vendorResponseCBEView.commercialResponseId, + commercialResponseStatus: vendorResponseCBEView.commercialResponseStatus, + totalPrice: vendorResponseCBEView.totalPrice, + currency: vendorResponseCBEView.currency, + paymentTerms: vendorResponseCBEView.paymentTerms, + incoterms: vendorResponseCBEView.incoterms, + deliveryPeriod: vendorResponseCBEView.deliveryPeriod, + warrantyPeriod: vendorResponseCBEView.warrantyPeriod, + validityPeriod: vendorResponseCBEView.validityPeriod, + commercialNotes: vendorResponseCBEView.commercialNotes, + + // 첨부파일 카운트 + attachmentCount: vendorResponseCBEView.attachmentCount, + commercialAttachmentCount: vendorResponseCBEView.commercialAttachmentCount, + technicalAttachmentCount: vendorResponseCBEView.technicalAttachmentCount, + }) + .from(vendorResponseCBEView) + .where(finalWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(limit); + + const [{ count }] = await tx + .select({ count: sql<number>`count(*)`.as("count") }) + .from(vendorResponseCBEView) + .where(finalWhere); + + return [data, Number(count)]; + }); + + if (!rows.length) { + return { data: [], pageCount: 0, total: 0 }; + } + + // [8] RFQ ID 목록 추출 + const distinctRfqIds = [...new Set(rows.map((r) => r.rfqId))]; + const distinctResponseIds = [...new Set(rows.map((r) => r.responseId))]; + const distinctCommercialResponseIds = [...new Set(rows.filter(r => r.commercialResponseId).map((r) => r.commercialResponseId!))]; + + // [9] CBE 평가 관련 코멘트 조회 + const commentsAll = await db + .select({ + id: rfqComments.id, + commentText: rfqComments.commentText, + rfqId: rfqComments.rfqId, + cbeId: rfqComments.cbeId, + createdAt: rfqComments.createdAt, + commentedBy: rfqComments.commentedBy, + }) + .from(rfqComments) + .innerJoin( + vendorResponses, + eq(vendorResponses.id, rfqComments.cbeId) + ) + .where( + and( + isNotNull(rfqComments.cbeId), + eq(rfqComments.vendorId, vendorId), + inArray(rfqComments.rfqId, distinctRfqIds) + ) + ); + + // rfqId별 코멘트 그룹화 + const commentsByRfqId = new Map<number, any[]>(); + for (const comment of commentsAll) { + const rfqId = comment.rfqId!; + if (!commentsByRfqId.has(rfqId)) { + commentsByRfqId.set(rfqId, []); + } + commentsByRfqId.get(rfqId)!.push({ + id: comment.id, + commentText: comment.commentText, + rfqId: comment.rfqId, + cbeId: comment.cbeId, + createdAt: comment.createdAt, + commentedBy: comment.commentedBy, + }); + } + + // [10] 첨부 파일 조회 - 일반 응답 첨부파일 + const responseAttachments = await db + .select({ + id: vendorResponseAttachments.id, + fileName: vendorResponseAttachments.fileName, + filePath: vendorResponseAttachments.filePath, + responseId: vendorResponseAttachments.responseId, + fileType: vendorResponseAttachments.fileType, + attachmentType: vendorResponseAttachments.attachmentType, + description: vendorResponseAttachments.description, + uploadedAt: vendorResponseAttachments.uploadedAt, + uploadedBy: vendorResponseAttachments.uploadedBy, + }) + .from(vendorResponseAttachments) + .where( + and( + inArray(vendorResponseAttachments.responseId, distinctResponseIds), + isNotNull(vendorResponseAttachments.responseId) + ) + ); + + // [11] 첨부 파일 조회 - 상업 응답 첨부파일 + const commercialResponseAttachments = await db + .select({ + id: vendorResponseAttachments.id, + fileName: vendorResponseAttachments.fileName, + filePath: vendorResponseAttachments.filePath, + commercialResponseId: vendorResponseAttachments.commercialResponseId, + fileType: vendorResponseAttachments.fileType, + attachmentType: vendorResponseAttachments.attachmentType, + description: vendorResponseAttachments.description, + uploadedAt: vendorResponseAttachments.uploadedAt, + uploadedBy: vendorResponseAttachments.uploadedBy, + }) + .from(vendorResponseAttachments) + .where( + and( + inArray(vendorResponseAttachments.commercialResponseId, distinctCommercialResponseIds), + isNotNull(vendorResponseAttachments.commercialResponseId) + ) + ); + + // [12] 첨부파일 그룹화 + // responseId별 첨부파일 맵 생성 + const filesByResponseId = new Map<number, any[]>(); + for (const file of responseAttachments) { + const responseId = file.responseId!; + if (!filesByResponseId.has(responseId)) { + filesByResponseId.set(responseId, []); + } + filesByResponseId.get(responseId)!.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, + attachmentSource: 'response' + }); + } + + // commercialResponseId별 첨부파일 맵 생성 + const filesByCommercialResponseId = new Map<number, any[]>(); + for (const file of commercialResponseAttachments) { + const commercialResponseId = file.commercialResponseId!; + if (!filesByCommercialResponseId.has(commercialResponseId)) { + filesByCommercialResponseId.set(commercialResponseId, []); + } + filesByCommercialResponseId.get(commercialResponseId)!.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, + attachmentSource: 'commercial' + }); + } + + // [13] 최종 데이터 병합 + const final = rows.map((row) => { + // 해당 응답의 모든 첨부파일 가져오기 + const responseFiles = filesByResponseId.get(row.responseId) || []; + const commercialFiles = row.commercialResponseId + ? filesByCommercialResponseId.get(row.commercialResponseId) || [] + : []; + + // 모든 첨부파일 병합 + const allFiles = [...responseFiles, ...commercialFiles]; + + return { + ...row, + rfqDueDate: row.rfqDueDate ? new Date(row.rfqDueDate) : null, + respondedAt: row.respondedAt ? new Date(row.respondedAt) : null, + comments: commentsByRfqId.get(row.rfqId) || [], + files: allFiles, + }; + }); + + const pageCount = Math.ceil(total / limit); + return { + data: final, + pageCount, + total + }; + }, + // 캐싱 키 & 옵션 + [`cbe-vendor-${vendorId}-${JSON.stringify(input)}`], + { + revalidate: 3600, + tags: [`cbe-vendor-${vendorId}`], + } + )(); +} + +export async function fetchCbeFiles(vendorId: number, rfqId: number) { + try { + // 1. 먼저 해당 RFQ와 벤더에 해당하는 CBE 평가 레코드를 찾습니다. + const cbeEval = await db + .select({ id: cbeEvaluations.id }) + .from(cbeEvaluations) + .where( + and( + eq(cbeEvaluations.rfqId, rfqId), + eq(cbeEvaluations.vendorId, vendorId) + ) + ) + .limit(1) + + if (!cbeEval.length) { + return { + files: [], + error: "해당 RFQ와 벤더에 대한 CBE 평가를 찾을 수 없습니다." + } + } + + const cbeId = cbeEval[0].id + + // 2. 관련 첨부 파일을 조회합니다. + // - commentId와 evaluationId는 null이어야 함 + // - rfqId와 vendorId가 일치해야 함 + // - cbeId가 위에서 찾은 CBE 평가 ID와 일치해야 함 + const files = await db + .select({ + id: rfqAttachments.id, + fileName: rfqAttachments.fileName, + filePath: rfqAttachments.filePath, + createdAt: rfqAttachments.createdAt + }) + .from(rfqAttachments) + .where( + and( + eq(rfqAttachments.rfqId, rfqId), + eq(rfqAttachments.vendorId, vendorId), + eq(rfqAttachments.cbeId, cbeId), + isNull(rfqAttachments.commentId), + isNull(rfqAttachments.evaluationId) + ) + ) + .orderBy(rfqAttachments.createdAt) + + return { + files, + cbeId + } + } catch (error) { + console.error("CBE 파일 조회 중 오류 발생:", error) + return { + files: [], + error: "CBE 파일을 가져오는 중 오류가 발생했습니다." + } + } +} + +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; + + // [2] 고급 필터 + const advancedWhere = filterColumns({ + table: vendorResponseCBEView, + filters: input.filters ?? [], + joinOperator: input.joinOperator ?? "and", + }); + + // [3] 글로벌 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + sql`${vendorResponseCBEView.vendorName} ILIKE ${s}`, + sql`${vendorResponseCBEView.vendorCode} ILIKE ${s}`, + sql`${vendorResponseCBEView.rfqCode} ILIKE ${s}`, + sql`${vendorResponseCBEView.projectCode} ILIKE ${s}`, + sql`${vendorResponseCBEView.projectName} ILIKE ${s}`, + sql`${vendorResponseCBEView.totalPrice}::text ILIKE ${s}` + ); + } + + // [4] DECLINED 상태 제외 (거절된 업체는 표시하지 않음) + const notDeclined = ne(vendorResponseCBEView.responseStatus, "DECLINED"); + + // [5] rfqType 필터 추가 + const rfqTypeFilter = input.rfqType ? eq(vendorResponseCBEView.rfqType, input.rfqType) : undefined; + + // [6] 최종 where 조건 + const finalWhere = and( + notDeclined, + advancedWhere ?? undefined, + globalWhere ?? undefined, + rfqTypeFilter // 새로 추가된 rfqType 필터 + ); + + // [7] 정렬 + const orderBy = input.sort?.length + ? input.sort.map((s) => { + // vendorResponseCBEView 컬럼 중 정렬 대상이 되는 것만 매핑 + const col = (vendorResponseCBEView as any)[s.id]; + return s.desc ? desc(col) : asc(col); + }) + : [desc(vendorResponseCBEView.rfqId), asc(vendorResponseCBEView.vendorName)]; // 기본 정렬은 최신 RFQ 먼저, 그 다음 벤더명 + + // [8] 메인 SELECT + const [rows, total] = await db.transaction(async (tx) => { + const data = await tx + .select({ + // 기본 식별 정보 + responseId: vendorResponseCBEView.responseId, + vendorId: vendorResponseCBEView.vendorId, + rfqId: vendorResponseCBEView.rfqId, + + // 협력업체 정보 + vendorName: vendorResponseCBEView.vendorName, + vendorCode: vendorResponseCBEView.vendorCode, + vendorStatus: vendorResponseCBEView.vendorStatus, + + // RFQ 정보 + rfqCode: vendorResponseCBEView.rfqCode, + rfqDescription: vendorResponseCBEView.rfqDescription, + rfqDueDate: vendorResponseCBEView.rfqDueDate, + rfqStatus: vendorResponseCBEView.rfqStatus, + rfqType: vendorResponseCBEView.rfqType, + + // 프로젝트 정보 + projectId: vendorResponseCBEView.projectId, + projectCode: vendorResponseCBEView.projectCode, + projectName: vendorResponseCBEView.projectName, + + // 응답 상태 정보 + responseStatus: vendorResponseCBEView.responseStatus, + responseNotes: vendorResponseCBEView.notes, + respondedAt: vendorResponseCBEView.respondedAt, + respondedBy: vendorResponseCBEView.respondedBy, + + // 상업 응답 정보 + commercialResponseId: vendorResponseCBEView.commercialResponseId, + commercialResponseStatus: vendorResponseCBEView.commercialResponseStatus, + totalPrice: vendorResponseCBEView.totalPrice, + currency: vendorResponseCBEView.currency, + paymentTerms: vendorResponseCBEView.paymentTerms, + incoterms: vendorResponseCBEView.incoterms, + deliveryPeriod: vendorResponseCBEView.deliveryPeriod, + warrantyPeriod: vendorResponseCBEView.warrantyPeriod, + validityPeriod: vendorResponseCBEView.validityPeriod, + commercialNotes: vendorResponseCBEView.commercialNotes, + + // 첨부파일 카운트 + attachmentCount: vendorResponseCBEView.attachmentCount, + commercialAttachmentCount: vendorResponseCBEView.commercialAttachmentCount, + technicalAttachmentCount: vendorResponseCBEView.technicalAttachmentCount, + }) + .from(vendorResponseCBEView) + .where(finalWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(limit); + + const [{ count }] = await tx + .select({ count: sql<number>`count(*)`.as("count") }) + .from(vendorResponseCBEView) + .where(finalWhere); + + return [data, Number(count)]; + }); + + if (!rows.length) { + return { data: [], pageCount: 0, total: 0 }; + } + + // [9] 고유한 rfqIds와 vendorIds 추출 - null 필터링 + const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId).filter(Boolean))] as number[]; + const distinctRfqIds = [...new Set(rows.map((r) => r.rfqId).filter(Boolean))] as number[]; + const distinctResponseIds = [...new Set(rows.map((r) => r.responseId).filter(Boolean))] as number[]; + const distinctCommercialResponseIds = [...new Set(rows.filter(r => r.commercialResponseId).map((r) => r.commercialResponseId!))]; + + // [10] CBE 평가 관련 코멘트 조회 + const commentsConditions = [isNotNull(rfqComments.cbeId)]; + + // 배열이 비어있지 않을 때만 조건 추가 + if (distinctRfqIds.length > 0) { + commentsConditions.push(inArray(rfqComments.rfqId, distinctRfqIds)); + } + + if (distinctVendorIds.length > 0) { + commentsConditions.push(inArray(rfqComments.vendorId, distinctVendorIds)); + } + + const commentsAll = await db + .select({ + id: rfqComments.id, + commentText: rfqComments.commentText, + vendorId: rfqComments.vendorId, + rfqId: rfqComments.rfqId, + cbeId: rfqComments.cbeId, + createdAt: rfqComments.createdAt, + commentedBy: rfqComments.commentedBy, + }) + .from(rfqComments) + .innerJoin( + vendorResponses, + eq(vendorResponses.id, rfqComments.cbeId) + ) + .where(and(...commentsConditions)); + + // [11] 복합 키(rfqId-vendorId)별 코멘트 그룹화 + const commentsByCompositeKey = new Map<string, any[]>(); + for (const comment of commentsAll) { + if (!comment.rfqId || !comment.vendorId) continue; + + const compositeKey = `${comment.rfqId}-${comment.vendorId}`; + if (!commentsByCompositeKey.has(compositeKey)) { + commentsByCompositeKey.set(compositeKey, []); + } + commentsByCompositeKey.get(compositeKey)!.push({ + id: comment.id, + commentText: comment.commentText, + vendorId: comment.vendorId, + cbeId: comment.cbeId, + createdAt: comment.createdAt, + commentedBy: comment.commentedBy, + }); + } + + // [12] 첨부 파일 조회 - 일반 응답 첨부파일 + const responseAttachments = await db + .select({ + id: vendorResponseAttachments.id, + fileName: vendorResponseAttachments.fileName, + filePath: vendorResponseAttachments.filePath, + responseId: vendorResponseAttachments.responseId, + fileType: vendorResponseAttachments.fileType, + attachmentType: vendorResponseAttachments.attachmentType, + description: vendorResponseAttachments.description, + uploadedAt: vendorResponseAttachments.uploadedAt, + uploadedBy: vendorResponseAttachments.uploadedBy, + }) + .from(vendorResponseAttachments) + .where( + and( + inArray(vendorResponseAttachments.responseId, distinctResponseIds), + isNotNull(vendorResponseAttachments.responseId) + ) + ); + + // [13] 첨부 파일 조회 - 상업 응답 첨부파일 + const commercialResponseAttachments = await db + .select({ + id: vendorResponseAttachments.id, + fileName: vendorResponseAttachments.fileName, + filePath: vendorResponseAttachments.filePath, + commercialResponseId: vendorResponseAttachments.commercialResponseId, + fileType: vendorResponseAttachments.fileType, + attachmentType: vendorResponseAttachments.attachmentType, + description: vendorResponseAttachments.description, + uploadedAt: vendorResponseAttachments.uploadedAt, + uploadedBy: vendorResponseAttachments.uploadedBy, + }) + .from(vendorResponseAttachments) + .where( + and( + inArray(vendorResponseAttachments.commercialResponseId, distinctCommercialResponseIds), + isNotNull(vendorResponseAttachments.commercialResponseId) + ) + ); + + // [14] 첨부파일 그룹화 + // responseId별 첨부파일 맵 생성 + const filesByResponseId = new Map<number, any[]>(); + for (const file of responseAttachments) { + const responseId = file.responseId!; + if (!filesByResponseId.has(responseId)) { + filesByResponseId.set(responseId, []); + } + filesByResponseId.get(responseId)!.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, + attachmentSource: 'response' + }); + } + + // commercialResponseId별 첨부파일 맵 생성 + const filesByCommercialResponseId = new Map<number, any[]>(); + for (const file of commercialResponseAttachments) { + const commercialResponseId = file.commercialResponseId!; + if (!filesByCommercialResponseId.has(commercialResponseId)) { + filesByCommercialResponseId.set(commercialResponseId, []); + } + filesByCommercialResponseId.get(commercialResponseId)!.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, + attachmentSource: 'commercial' + }); + } + + // [15] 복합 키(rfqId-vendorId)별 첨부파일 맵 생성 + const filesByCompositeKey = new Map<string, any[]>(); + + // responseId -> rfqId-vendorId 매핑 생성 + const responseIdToCompositeKey = new Map<number, string>(); + for (const row of rows) { + if (row.responseId) { + responseIdToCompositeKey.set(row.responseId, `${row.rfqId}-${row.vendorId}`); + } + if (row.commercialResponseId) { + responseIdToCompositeKey.set(row.commercialResponseId, `${row.rfqId}-${row.vendorId}`); + } + } + + // responseId별 첨부파일을 복합 키별로 그룹화 + for (const [responseId, files] of filesByResponseId.entries()) { + const compositeKey = responseIdToCompositeKey.get(responseId); + if (compositeKey) { + if (!filesByCompositeKey.has(compositeKey)) { + filesByCompositeKey.set(compositeKey, []); + } + filesByCompositeKey.get(compositeKey)!.push(...files); + } + } + + // commercialResponseId별 첨부파일을 복합 키별로 그룹화 + for (const [commercialResponseId, files] of filesByCommercialResponseId.entries()) { + const compositeKey = responseIdToCompositeKey.get(commercialResponseId); + if (compositeKey) { + if (!filesByCompositeKey.has(compositeKey)) { + filesByCompositeKey.set(compositeKey, []); + } + filesByCompositeKey.get(compositeKey)!.push(...files); + } + } + + // [16] 최종 데이터 병합 + const final = rows.map((row) => { + const compositeKey = `${row.rfqId}-${row.vendorId}`; + + return { + ...row, + rfqDueDate: row.rfqDueDate ? new Date(row.rfqDueDate) : null, + respondedAt: row.respondedAt ? new Date(row.respondedAt) : null, + comments: commentsByCompositeKey.get(compositeKey) || [], + files: filesByCompositeKey.get(compositeKey) || [], + }; + }); + + const pageCount = Math.ceil(total / limit); + return { + data: final, + 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-ship/table/ItemsDialog.tsx b/lib/rfqs-ship/table/ItemsDialog.tsx new file mode 100644 index 00000000..3d822499 --- /dev/null +++ b/lib/rfqs-ship/table/ItemsDialog.tsx @@ -0,0 +1,752 @@ +"use client" + +import * as React from "react" +import { useForm, useFieldArray, useWatch } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@/components/ui/form" +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandItem, + CommandGroup, + CommandEmpty +} from "@/components/ui/command" +import { Check, ChevronsUpDown, Plus, Trash2, Save, X, AlertCircle, Eye } from "lucide-react" +import { toast } from "sonner" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { Badge } from "@/components/ui/badge" + +import { createRfqItem, deleteRfqItem } from "../service" +import { RfqWithItemCount } from "@/db/schema/rfq" +import { RfqType } from "../validations" + +// Zod 스키마 - 수량은 string으로 받아서 나중에 변환 +const itemSchema = z.object({ + id: z.number().optional(), + itemCode: z.string().nonempty({ message: "아이템 코드를 선택해주세요" }), + description: z.string().optional(), + quantity: z.coerce.number().min(1, { message: "최소 수량은 1입니다" }).default(1), + uom: z.string().default("each"), +}); + +const itemsFormSchema = z.object({ + rfqId: z.number().int(), + items: z.array(itemSchema).min(1, { message: "최소 1개 이상의 아이템을 추가해주세요" }), +}); + +type ItemsFormSchema = z.infer<typeof itemsFormSchema>; + +interface RfqsItemsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + rfq: RfqWithItemCount | null; + defaultItems?: { + id?: number; + itemCode: string; + quantity?: number | null; + description?: string | null; + uom?: string | null; + }[]; + itemsList: { code: string | null; name: string }[]; + rfqType?: RfqType; +} + +export function RfqsItemsDialog({ + open, + onOpenChange, + rfq, + defaultItems = [], + itemsList, + rfqType +}: RfqsItemsDialogProps) { + const rfqId = rfq?.rfqId ?? 0; + + // 편집 가능 여부 확인 - DRAFT 상태일 때만 편집 가능 + const isEditable = rfq?.status === "DRAFT"; + + // 초기 아이템 ID 목록을 추적하기 위한 상태 추가 + const [initialItemIds, setInitialItemIds] = React.useState<(number | undefined)[]>([]); + + // 삭제된 아이템 ID를 저장하는 상태 추가 + const [deletedItemIds, setDeletedItemIds] = React.useState<number[]>([]); + + // 1) form + const form = useForm<ItemsFormSchema>({ + resolver: zodResolver(itemsFormSchema), + defaultValues: { + rfqId, + items: defaultItems.length > 0 ? defaultItems.map((it) => ({ + id: it.id, + quantity: it.quantity ?? 1, + uom: it.uom ?? "each", + itemCode: it.itemCode ?? "", + description: it.description ?? "", + })) : [{ itemCode: "", description: "", quantity: 1, uom: "each" }], + }, + mode: "onChange", // 입력 필드가 변경될 때마다 유효성 검사 + }); + + // 다이얼로그가 열릴 때마다 폼 초기화 및 초기 아이템 ID 저장 + React.useEffect(() => { + if (open) { + const initialItems = defaultItems.length > 0 + ? defaultItems.map((it) => ({ + id: it.id, + quantity: it.quantity ?? 1, + uom: it.uom ?? "each", + itemCode: it.itemCode ?? "", + description: it.description ?? "", + })) + : [{ itemCode: "", description: "", quantity: 1, uom: "each" }]; + + form.reset({ + rfqId, + items: initialItems, + }); + + // 초기 아이템 ID 목록 저장 + setInitialItemIds(defaultItems.map(item => item.id)); + + // 삭제된 아이템 목록 초기화 + setDeletedItemIds([]); + setHasUnsavedChanges(false); + } + }, [open, defaultItems, rfqId, form]); + + // 새로운 요소에 대한 ref 배열 + const inputRefs = React.useRef<Array<HTMLButtonElement | null>>([]); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [hasUnsavedChanges, setHasUnsavedChanges] = React.useState(false); + const [isExitDialogOpen, setIsExitDialogOpen] = React.useState(false); + + // 폼 변경 감지 - 편집 가능한 경우에만 변경 감지 + React.useEffect(() => { + if (!isEditable) return; + + const subscription = form.watch(() => { + setHasUnsavedChanges(true); + }); + return () => subscription.unsubscribe(); + }, [form, isEditable]); + + // 2) field array + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "items", + }); + + // 3) watch items array + const watchItems = form.watch("items"); + + // 4) Add item row with auto-focus + function handleAddItem() { + if (!isEditable) return; + + // 명시적으로 숫자 타입으로 지정 + append({ + itemCode: "", + description: "", + quantity: 1, + uom: "each" + }); + setHasUnsavedChanges(true); + + // 다음 렌더링 사이클에서 새로 추가된 항목에 포커스 + setTimeout(() => { + const newIndex = fields.length; + const button = inputRefs.current[newIndex]; + if (button) { + button.click(); + } + }, 100); + } + + // 항목 직접 삭제 - 기존 ID가 있을 경우 삭제 목록에 추가 + const handleRemoveItem = (index: number) => { + if (!isEditable) return; + + const itemToRemove = form.getValues().items[index]; + + // 기존 ID가 있는 아이템이라면 삭제 목록에 추가 + if (itemToRemove.id !== undefined) { + setDeletedItemIds(prev => [...prev, itemToRemove.id as number]); + } + + remove(index); + setHasUnsavedChanges(true); + + // 포커스 처리: 다음 항목이 있으면 다음 항목으로, 없으면 마지막 항목으로 + setTimeout(() => { + const nextIndex = Math.min(index, fields.length - 1); + if (nextIndex >= 0 && inputRefs.current[nextIndex]) { + inputRefs.current[nextIndex]?.click(); + } + }, 50); + }; + + // 다이얼로그 닫기 전 확인 + const handleDialogClose = (open: boolean) => { + if (!open && hasUnsavedChanges && isEditable) { + setIsExitDialogOpen(true); + } else { + onOpenChange(open); + } + }; + + // 필드 포커스 유틸리티 함수 + const focusField = (selector: string) => { + if (!isEditable) return; + + setTimeout(() => { + const element = document.querySelector(selector) as HTMLInputElement | null; + if (element) { + element.focus(); + } + }, 10); + }; + + // 5) Submit - 업데이트된 제출 로직 (생성/수정 + 삭제 처리) + async function onSubmit(data: ItemsFormSchema) { + if (!isEditable) return; + + try { + setIsSubmitting(true); + + // 각 아이템이 유효한지 확인 + const anyInvalidItems = data.items.some(item => !item.itemCode || item.quantity < 1); + + if (anyInvalidItems) { + toast.error("유효하지 않은 아이템이 있습니다. 모든 필드를 확인해주세요."); + setIsSubmitting(false); + return; + } + + // 1. 삭제 처리 - 삭제된 아이템 ID가 있으면 삭제 요청 + const deletePromises = deletedItemIds.map(id => + deleteRfqItem({ + id: id, + rfqId: rfqId, + rfqType: rfqType ?? RfqType.PURCHASE + }) + ); + + // 2. 생성/수정 처리 - 폼에 남아있는 아이템들 + const upsertPromises = data.items.map((item) => + createRfqItem({ + rfqId: rfqId, + itemCode: item.itemCode, + description: item.description, + // 명시적으로 숫자로 변환 + quantity: Number(item.quantity), + uom: item.uom, + rfqType: rfqType ?? RfqType.PURCHASE, + id: item.id // 기존 ID가 있으면 업데이트, 없으면 생성 + }) + ); + + // 모든 요청 병렬 처리 + await Promise.all([...deletePromises, ...upsertPromises]); + + toast.success("RFQ 아이템이 성공적으로 저장되었습니다!"); + setHasUnsavedChanges(false); + onOpenChange(false); + } catch (err) { + toast.error(`오류가 발생했습니다: ${String(err)}`); + } finally { + setIsSubmitting(false); + } + } + + // 단축키 처리 - 편집 가능한 경우에만 단축키 활성화 + React.useEffect(() => { + if (!isEditable) return; + + const handleKeyDown = (e: KeyboardEvent) => { + // Alt+N: 새 항목 추가 + if (e.altKey && e.key === 'n') { + e.preventDefault(); + handleAddItem(); + } + // Ctrl+S: 저장 + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault(); + form.handleSubmit(onSubmit)(); + } + // Esc: 포커스된 팝오버 닫기 + if (e.key === 'Escape') { + document.querySelectorAll('[role="combobox"][aria-expanded="true"]').forEach( + (el) => (el as HTMLButtonElement).click() + ); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [form, isEditable]); + + return ( + <> + <Dialog open={open} onOpenChange={handleDialogClose}> + <DialogContent className="max-w-none w-[1200px]"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + {isEditable ? "RFQ 아이템 관리" : "RFQ 아이템 조회"} + <Badge variant="outline" className="ml-2"> + {rfq?.rfqCode || `RFQ #${rfqId}`} + </Badge> + {rfqType && ( + <Badge variant={rfqType === RfqType.PURCHASE ? "default" : "secondary"} className="ml-1"> + {rfqType === RfqType.PURCHASE ? "구매 RFQ" : "예산 RFQ"} + </Badge> + )} + {rfq?.status && ( + <Badge + variant={rfq.status === "DRAFT" ? "outline" : "secondary"} + className="ml-1" + > + {rfq.status} + </Badge> + )} + </DialogTitle> + <DialogDescription> + {isEditable + ? (rfq?.description || '아이템을 각 행에 하나씩 추가할 수 있습니다.') + : '드래프트 상태가 아닌 RFQ는 아이템을 편집할 수 없습니다.'} + </DialogDescription> + </DialogHeader> + <div className="overflow-x-auto w-full"> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="space-y-4"> + {/* 헤더 행 (라벨) */} + <div className="flex items-center gap-2 border-b pb-2 font-medium text-sm"> + <div className="w-[250px] pl-3">아이템</div> + <div className="w-[400px] pl-2">설명</div> + <div className="w-[80px] pl-2 text-center">수량</div> + <div className="w-[80px] pl-2 text-center">단위</div> + {isEditable && <div className="w-[42px]"></div>} + </div> + + {/* 아이템 행들 */} + <div className="max-h-[50vh] overflow-y-auto pr-1 space-y-3"> + {fields.map((field, index) => { + // 현재 row의 itemCode + const codeValue = watchItems[index]?.itemCode || ""; + // "이미" 사용된 코드를 모두 구함 + const usedCodes = watchItems + .map((it, i) => i === index ? null : it.itemCode) + .filter(Boolean) as string[]; + + // itemsList에서 "현재 선택한 code"만 예외적으로 허용하고, + // 다른 행에서 이미 사용한 code는 제거 + const filteredItems = (itemsList || []) + .filter((it) => { + if (!it.code) return false; + if (it.code === codeValue) return true; + return !usedCodes.includes(it.code); + }) + .map((it) => ({ + code: it.code ?? "", // fallback + name: it.name, + })); + + // 선택된 아이템 찾기 + const selected = filteredItems.find(it => it.code === codeValue); + + return ( + <div key={field.id} className="flex items-center gap-2 group hover:bg-gray-50 p-1 rounded-md transition-colors"> + {/* -- itemCode + Popover(Select) -- */} + {isEditable ? ( + // 전체 FormField 컴포넌트와 아이템 선택 로직 개선 + <FormField + control={form.control} + name={`items.${index}.itemCode`} + render={({ field }) => { + const [popoverOpen, setPopoverOpen] = React.useState(false); + const selected = filteredItems.find(it => it.code === field.value); + + return ( + <FormItem className="flex items-center gap-2 w-[250px]" style={{width:250}}> + <FormControl> + <Popover open={popoverOpen} onOpenChange={setPopoverOpen}> + <PopoverTrigger asChild> + <Button + // 컴포넌트에 ref 전달 + ref={el => { + inputRefs.current[index] = el; + }} + variant="outline" + role="combobox" + aria-expanded={popoverOpen} + className="flex items-center" + data-error={!!form.formState.errors.items?.[index]?.itemCode} + data-state={selected ? "filled" : "empty"} + style={{width:250}} + > + <div className="flex-1 overflow-hidden mr-2 text-left"> + <span className="block truncate" style={{width:200}}> + {selected ? `${selected.code} - ${selected.name}` : "아이템 선택..."} + </span> + </div> + <ChevronsUpDown className="h-4 w-4 flex-shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[400px] p-0"> + <Command> + <CommandInput placeholder="아이템 검색..." className="h-9" autoFocus /> + <CommandList> + <CommandEmpty>아이템을 찾을 수 없습니다.</CommandEmpty> + <CommandGroup> + {filteredItems.map((it) => { + const label = `${it.code} - ${it.name}`; + return ( + <CommandItem + key={it.code} + value={label} + onSelect={() => { + field.onChange(it.code); + setPopoverOpen(false); + // 자동으로 다음 필드로 포커스 이동 + focusField(`input[name="items.${index}.description"]`); + }} + > + <div className="flex-1 overflow-hidden"> + <span className="block truncate">{label}</span> + </div> + <Check + className={ + "ml-auto h-4 w-4" + + (it.code === field.value ? " opacity-100" : " opacity-0") + } + /> + </CommandItem> + ); + })} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </FormControl> + {form.formState.errors.items?.[index]?.itemCode && ( + <AlertCircle className="h-4 w-4 text-destructive" /> + )} + </FormItem> + ); + }} + /> + ) : ( + <div className="flex items-center w-[250px] pl-3"> + {selected ? `${selected.code} - ${selected.name}` : codeValue} + </div> + )} + + {/* ID 필드 추가 (숨김) */} + <FormField + control={form.control} + name={`items.${index}.id`} + render={({ field }) => ( + <input type="hidden" {...field} /> + )} + /> + + {/* description */} + {isEditable ? ( + <FormField + control={form.control} + name={`items.${index}.description`} + render={({ field }) => ( + <FormItem className="w-[400px]"> + <FormControl> + <Input + className="w-full" + placeholder="아이템 상세 정보" + {...field} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + focusField(`input[name="items.${index}.quantity"]`); + } + }} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + ) : ( + <div className="w-[400px] pl-2"> + {watchItems[index]?.description || ""} + </div> + )} + + {/* quantity */} + {isEditable ? ( + <FormField + control={form.control} + name={`items.${index}.quantity`} + render={({ field }) => ( + <FormItem className="w-[80px] relative"> + <FormControl> + <Input + type="number" + className="w-full text-center" + min="1" + {...field} + // 값 변경 핸들러 개선 + onChange={(e) => { + const value = e.target.value === '' ? 1 : parseInt(e.target.value, 10); + field.onChange(isNaN(value) ? 1 : value); + }} + // 최소값 보장 (빈 문자열 방지) + onBlur={(e) => { + if (e.target.value === '' || parseInt(e.target.value, 10) < 1) { + field.onChange(1); + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + focusField(`input[name="items.${index}.uom"]`); + } + }} + /> + </FormControl> + {form.formState.errors.items?.[index]?.quantity && ( + <AlertCircle className="h-4 w-4 text-destructive absolute right-2 top-2" /> + )} + </FormItem> + )} + /> + ) : ( + <div className="w-[80px] text-center"> + {watchItems[index]?.quantity} + </div> + )} + + {/* uom */} + {isEditable ? ( + <FormField + control={form.control} + name={`items.${index}.uom`} + render={({ field }) => ( + <FormItem className="w-[80px]"> + <FormControl> + <Input + placeholder="each" + className="w-full text-center" + {...field} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + // 마지막 행이면 새로운 행 추가 + if (index === fields.length - 1) { + handleAddItem(); + } else { + // 아니면 다음 행의 아이템 선택으로 이동 + const button = inputRefs.current[index + 1]; + if (button) { + setTimeout(() => button.click(), 10); + } + } + } + }} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + ) : ( + <div className="w-[80px] text-center"> + {watchItems[index]?.uom || "each"} + </div> + )} + + {/* remove row - 편집 모드에서만 표시 */} + {isEditable && ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + type="button" + variant="ghost" + size="icon" + onClick={() => handleRemoveItem(index)} + className="group-hover:opacity-100 transition-opacity" + aria-label="아이템 삭제" + > + <Trash2 className="h-4 w-4 text-destructive" /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p>아이템 삭제</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + )} + </div> + ); + })} + </div> + + <div className="flex justify-between items-center pt-2 border-t"> + <div className="flex items-center gap-2"> + {isEditable ? ( + <> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button type="button" variant="outline" onClick={handleAddItem} className="gap-1"> + <Plus className="h-4 w-4" /> + 아이템 추가 + </Button> + </TooltipTrigger> + <TooltipContent side="bottom"> + <p>단축키: Alt+N</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + <span className="text-sm text-muted-foreground"> + {fields.length}개 아이템 + </span> + {deletedItemIds.length > 0 && ( + <span className="text-sm text-destructive"> + ({deletedItemIds.length}개 아이템 삭제 예정) + </span> + )} + </> + ) : ( + <span className="text-sm text-muted-foreground"> + {fields.length}개 아이템 + </span> + )} + </div> + + {isEditable && ( + <div className="text-xs text-muted-foreground"> + <span className="inline-flex items-center gap-1 mr-2"> + <kbd className="px-1 py-0.5 bg-gray-100 rounded text-xs">Tab</kbd> + <span>필드 간 이동</span> + </span> + <span className="inline-flex items-center gap-1"> + <kbd className="px-1 py-0.5 bg-gray-100 rounded text-xs">Enter</kbd> + <span>다음 필드로 이동</span> + </span> + </div> + )} + </div> + </div> + + <DialogFooter className="mt-6 gap-2"> + {isEditable ? ( + <> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button type="button" variant="outline" onClick={() => handleDialogClose(false)}> + <X className="mr-2 h-4 w-4" /> + 취소 + </Button> + </TooltipTrigger> + <TooltipContent>변경사항을 저장하지 않고 나가기</TooltipContent> + </Tooltip> + </TooltipProvider> + + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + type="submit" + disabled={isSubmitting || (!form.formState.isDirty && deletedItemIds.length === 0) || !form.formState.isValid} + > + {isSubmitting ? ( + <>처리 중...</> + ) : ( + <> + <Save className="mr-2 h-4 w-4" /> + 저장 + </> + )} + </Button> + </TooltipTrigger> + <TooltipContent> + <p>단축키: Ctrl+S</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </> + ) : ( + <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> + <X className="mr-2 h-4 w-4" /> + 닫기 + </Button> + )} + </DialogFooter> + </form> + </Form> + </div> + </DialogContent> + </Dialog> + + {/* 저장하지 않고 나가기 확인 다이얼로그 - 편집 모드에서만 활성화 */} + {isEditable && ( + <AlertDialog open={isExitDialogOpen} onOpenChange={setIsExitDialogOpen}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>저장되지 않은 변경사항</AlertDialogTitle> + <AlertDialogDescription> + 저장되지 않은 변경사항이 있습니다. 그래도 나가시겠습니까? + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>취소</AlertDialogCancel> + <AlertDialogAction onClick={() => { + setIsExitDialogOpen(false); + onOpenChange(false); + }}> + 저장하지 않고 나가기 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + )} + </> + ); +}
\ No newline at end of file diff --git a/lib/rfqs-ship/table/ParentRfqSelector.tsx b/lib/rfqs-ship/table/ParentRfqSelector.tsx new file mode 100644 index 00000000..0edb1233 --- /dev/null +++ b/lib/rfqs-ship/table/ParentRfqSelector.tsx @@ -0,0 +1,307 @@ +"use client" + +import * as React from "react" +import { Check, ChevronsUpDown, Loader } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command" +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" +import { cn } from "@/lib/utils" +import { useDebounce } from "@/hooks/use-debounce" +import { getBudgetaryRfqs, type BudgetaryRfq } from "../service" +import { RfqType } from "../validations" + +// ParentRfq 타입 정의 (서비스의 BudgetaryRfq와 호환되어야 함) +interface ParentRfq { + id: number; + rfqCode: string; + description: string | null; + rfqType: RfqType; + projectId: number | null; + projectCode: string | null; + projectName: string | null; +} + +interface ParentRfqSelectorProps { + selectedRfqId?: number; + onRfqSelect: (rfq: ParentRfq | null) => void; + rfqType: RfqType; // 현재 생성 중인 RFQ 타입 + parentRfqTypes: RfqType[]; // 선택 가능한 부모 RFQ 타입 목록 + placeholder?: string; +} + +export function ParentRfqSelector({ + selectedRfqId, + onRfqSelect, + rfqType, + parentRfqTypes, + placeholder = "부모 RFQ 선택..." +}: ParentRfqSelectorProps) { + const [searchTerm, setSearchTerm] = React.useState(""); + const debouncedSearchTerm = useDebounce(searchTerm, 300); + + const [open, setOpen] = React.useState(false); + const [loading, setLoading] = React.useState(false); + const [parentRfqs, setParentRfqs] = React.useState<ParentRfq[]>([]); + const [selectedRfq, setSelectedRfq] = React.useState<ParentRfq | null>(null); + const [page, setPage] = React.useState(1); + const [hasMore, setHasMore] = React.useState(true); + const [totalCount, setTotalCount] = React.useState(0); + + const listRef = React.useRef<HTMLDivElement>(null); + + // 타입별로 적절한 검색 placeholder 생성 + const getSearchPlaceholder = () => { + if (rfqType === RfqType.PURCHASE) { + return "BUDGETARY/PURCHASE_BUDGETARY RFQ 검색..."; + } else if (rfqType === RfqType.PURCHASE_BUDGETARY) { + return "BUDGETARY RFQ 검색..."; + } + return "RFQ 코드/설명/프로젝트 검색..."; + }; + + // 초기 선택된 RFQ가 있을 경우 로드 + React.useEffect(() => { + if (selectedRfqId && open) { + const loadSelectedRfq = async () => { + try { + // 단일 RFQ를 id로 조회하는 API 호출 + const result = await getBudgetaryRfqs({ + limit: 1, + rfqId: selectedRfqId + }); + + if ('rfqs' in result && result.rfqs && result.rfqs.length > 0) { + setSelectedRfq(result.rfqs[0] as unknown as ParentRfq); + } + } catch (error) { + console.error("선택된 RFQ 로드 오류:", error); + } + }; + + if (!selectedRfq || selectedRfq.id !== selectedRfqId) { + loadSelectedRfq(); + } + } + }, [selectedRfqId, open, selectedRfq]); + + // 검색어 변경 시 데이터 리셋 및 재로드 + React.useEffect(() => { + if (open) { + setPage(1); + setHasMore(true); + setParentRfqs([]); + loadParentRfqs(1, true); + } + }, [debouncedSearchTerm, open, parentRfqTypes]); + + // 데이터 로드 함수 + const loadParentRfqs = async (pageToLoad: number, reset = false) => { + if (!open || parentRfqTypes.length === 0) return; + + setLoading(true); + try { + const limit = 20; // 한 번에 로드할 항목 수 + const result = await getBudgetaryRfqs({ + search: debouncedSearchTerm, + limit, + offset: (pageToLoad - 1) * limit, + rfqTypes: parentRfqTypes // 현재 RFQ 타입에 맞는 부모 RFQ 타입들로 필터링 + }); + + if ('rfqs' in result && result.rfqs) { + if (reset) { + setParentRfqs(result.rfqs as unknown as ParentRfq[]); + } else { + setParentRfqs(prev => [...prev, ...(result.rfqs as unknown as ParentRfq[])]); + } + + setTotalCount(result.totalCount); + setHasMore(result.rfqs.length === limit && (pageToLoad * limit) < result.totalCount); + setPage(pageToLoad); + } + } catch (error) { + console.error("부모 RFQ 로드 오류:", error); + } finally { + setLoading(false); + } + }; + + // 무한 스크롤 처리 + const handleScroll = () => { + if (listRef.current) { + const { scrollTop, scrollHeight, clientHeight } = listRef.current; + + // 스크롤이 90% 이상 내려갔을 때 다음 페이지 로드 + if (scrollTop + clientHeight >= scrollHeight * 0.9 && !loading && hasMore) { + loadParentRfqs(page + 1); + } + } + }; + + // RFQ를 프로젝트별로 그룹화하는 함수 + const groupRfqsByProject = (rfqs: ParentRfq[]) => { + const groups: Record<string, { + projectId: number | null; + projectCode: string | null; + projectName: string | null; + rfqs: ParentRfq[]; + }> = {}; + + // 'No Project' 그룹 기본 생성 + groups['no-project'] = { + projectId: null, + projectCode: null, + projectName: null, + rfqs: [] + }; + + // 프로젝트별로 RFQ 그룹화 + rfqs.forEach(rfq => { + const key = rfq.projectId ? `project-${rfq.projectId}` : 'no-project'; + + if (!groups[key] && rfq.projectId) { + groups[key] = { + projectId: rfq.projectId, + projectCode: rfq.projectCode, + projectName: rfq.projectName, + rfqs: [] + }; + } + + groups[key].rfqs.push(rfq); + }); + + // 필터링된 결과가 있는 그룹만 남기기 + return Object.values(groups).filter(group => group.rfqs.length > 0); + }; + + // 그룹화된 RFQ 목록 + const groupedRfqs = React.useMemo(() => { + return groupRfqsByProject(parentRfqs); + }, [parentRfqs]); + + // RFQ 선택 처리 + const handleRfqSelect = (rfq: ParentRfq | null) => { + setSelectedRfq(rfq); + onRfqSelect(rfq); + setOpen(false); + }; + + // RFQ 타입에 따른 표시 형식 + const getRfqTypeLabel = (type: RfqType) => { + switch(type) { + case RfqType.BUDGETARY: + return "BUDGETARY"; + case RfqType.PURCHASE_BUDGETARY: + return "PURCHASE_BUDGETARY"; + case RfqType.PURCHASE: + return "PURCHASE"; + default: + return type; + } + }; + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={open} + className="w-full justify-between" + > + {selectedRfq + ? `${selectedRfq.rfqCode || ""} - ${selectedRfq.description || ""}` + : placeholder} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[400px] p-0"> + <Command> + <CommandInput + placeholder={getSearchPlaceholder()} + value={searchTerm} + onValueChange={setSearchTerm} + /> + <CommandList + className="max-h-[300px]" + ref={listRef} + onScroll={handleScroll} + > + <CommandEmpty>검색 결과가 없습니다</CommandEmpty> + + <CommandGroup> + <CommandItem + value="none" + onSelect={() => handleRfqSelect(null)} + > + <Check + className={cn( + "mr-2 h-4 w-4", + !selectedRfq + ? "opacity-100" + : "opacity-0" + )} + /> + <span className="font-medium">선택 안함</span> + </CommandItem> + </CommandGroup> + + {groupedRfqs.map((group, index) => ( + <CommandGroup + key={`group-${group.projectId || index}`} + heading={ + group.projectId + ? `${group.projectCode || ""} - ${group.projectName || ""}` + : "프로젝트 없음" + } + > + {group.rfqs.map((rfq) => ( + <CommandItem + key={rfq.id} + value={`${rfq.rfqCode || ""} ${rfq.description || ""}`} + onSelect={() => handleRfqSelect(rfq)} + > + <Check + className={cn( + "mr-2 h-4 w-4", + selectedRfq?.id === rfq.id + ? "opacity-100" + : "opacity-0" + )} + /> + <div className="flex flex-col"> + <div className="flex items-center"> + <span className="font-medium">{rfq.rfqCode || ""}</span> + <span className="ml-2 text-xs px-1.5 py-0.5 rounded bg-slate-100 text-slate-700"> + {getRfqTypeLabel(rfq.rfqType)} + </span> + </div> + {rfq.description && ( + <span className="text-sm text-gray-500 truncate"> + {rfq.description} + </span> + )} + </div> + </CommandItem> + ))} + </CommandGroup> + ))} + + {loading && ( + <div className="py-2 text-center"> + <Loader className="h-4 w-4 animate-spin mx-auto" /> + </div> + )} + + {!loading && !hasMore && parentRfqs.length > 0 && ( + <div className="py-2 text-center text-sm text-muted-foreground"> + 총 {totalCount}개 중 {parentRfqs.length}개 표시됨 + </div> + )} + </CommandList> + </Command> + </PopoverContent> + </Popover> + ); +}
\ No newline at end of file diff --git a/lib/rfqs-ship/table/add-rfq-dialog.tsx b/lib/rfqs-ship/table/add-rfq-dialog.tsx new file mode 100644 index 00000000..67561b4f --- /dev/null +++ b/lib/rfqs-ship/table/add-rfq-dialog.tsx @@ -0,0 +1,468 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { toast } from "sonner" + +import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" + +import { useSession } from "next-auth/react" +import { createRfqSchema, type CreateRfqSchema, RfqType } from "../validations" +import { createRfq, generateNextRfqCode, getBudgetaryRfqs } from "../service" +import { ProjectSelector } from "@/components/ProjectSelector" +import { type Project } from "../service" +import { ParentRfqSelector } from "./ParentRfqSelector" +import { EstimateProjectSelector } from "@/components/BidProjectSelector" + +// 부모 RFQ 정보 타입 정의 +interface ParentRfq { + id: number; + rfqCode: string; + description: string | null; + rfqType: RfqType; + projectId: number | null; + projectCode: string | null; + projectName: string | null; +} + +interface AddRfqDialogProps { + rfqType?: RfqType; +} + +export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) { + const [open, setOpen] = React.useState(false) + const { data: session, status } = useSession() + const [parentRfqs, setParentRfqs] = React.useState<ParentRfq[]>([]) + const [isLoadingParents, setIsLoadingParents] = React.useState(false) + const [selectedParentRfq, setSelectedParentRfq] = React.useState<ParentRfq | null>(null) + const [isLoadingRfqCode, setIsLoadingRfqCode] = React.useState(false) + + // Get the user ID safely, ensuring it's a valid number + const userId = React.useMemo(() => { + const id = session?.user?.id ? Number(session.user.id) : null; + + return id; + }, [session, status]); + + // RfqType에 따른 타이틀 생성 + const getTitle = () => { + switch (rfqType) { + case RfqType.PURCHASE: + return "Purchase RFQ"; + case RfqType.BUDGETARY: + return "Budgetary RFQ"; + case RfqType.PURCHASE_BUDGETARY: + return "Purchase Budgetary RFQ"; + default: + return "RFQ"; + } + }; + + // RfqType 설명 가져오기 + const getTypeDescription = () => { + switch (rfqType) { + case RfqType.PURCHASE: + return "실제 구매 발주 전에 가격을 요청"; + case RfqType.BUDGETARY: + return "기술영업 단계에서 입찰가 산정을 위한 견적 요청"; + case RfqType.PURCHASE_BUDGETARY: + return "프로젝트 수주 후, 공식 입찰 전 예산 책정을 위한 가격 요청"; + default: + return ""; + } + }; + + // RHF + Zod + const form = useForm<CreateRfqSchema>({ + resolver: zodResolver(createRfqSchema), + defaultValues: { + rfqCode: "", + description: "", + projectId: undefined, + parentRfqId: undefined, + dueDate: new Date(), + status: "DRAFT", + rfqType: rfqType, + // Don't set createdBy yet - we'll set it when the form is submitted + createdBy: undefined, + }, + }); + + // Update form values when session loads + React.useEffect(() => { + if (status === "authenticated" && userId) { + form.setValue("createdBy", userId); + } + }, [status, userId, form]); + + // 다이얼로그가 열릴 때 자동으로 RFQ 코드 생성 + React.useEffect(() => { + if (open) { + const generateRfqCode = async () => { + setIsLoadingRfqCode(true); + try { + // 서버 액션 호출 + const result = await generateNextRfqCode(rfqType); + + if (result.error) { + toast.error(`RFQ 코드 생성 실패: ${result.error}`); + return; + } + + // 생성된 코드를 폼에 설정 + form.setValue("rfqCode", result.code); + } catch (error) { + console.error("RFQ 코드 생성 오류:", error); + toast.error("RFQ 코드 생성에 실패했습니다"); + } finally { + setIsLoadingRfqCode(false); + } + }; + + generateRfqCode(); + } + }, [open, rfqType, form]); + + // 현재 RFQ 타입에 따라 선택 가능한 부모 RFQ 타입들 결정 + const getParentRfqTypes = (): RfqType[] => { + switch (rfqType) { + case RfqType.PURCHASE: + // PURCHASE는 BUDGETARY와 PURCHASE_BUDGETARY를 부모로 가질 수 있음 + return [RfqType.BUDGETARY, RfqType.PURCHASE_BUDGETARY]; + case RfqType.PURCHASE_BUDGETARY: + // PURCHASE_BUDGETARY는 BUDGETARY만 부모로 가질 수 있음 + return [RfqType.BUDGETARY]; + default: + return []; + } + }; + + // 선택 가능한 부모 RFQ 목록 로드 + React.useEffect(() => { + if ((rfqType === RfqType.PURCHASE || rfqType === RfqType.PURCHASE_BUDGETARY) && open) { + const loadParentRfqs = async () => { + setIsLoadingParents(true); + try { + // 현재 RFQ 타입에 따라 선택 가능한, 부모가 될 수 있는 RFQ 타입들 가져오기 + const parentTypes = getParentRfqTypes(); + + // 부모 RFQ 타입이 있을 때만 API 호출 + if (parentTypes.length > 0) { + const result = await getBudgetaryRfqs({ + rfqTypes: parentTypes // 서비스에 rfqTypes 파라미터 추가 필요 + }); + + if ('rfqs' in result) { + setParentRfqs(result.rfqs as unknown as ParentRfq[]); + } else if ('error' in result) { + console.error("부모 RFQ 로드 오류:", result.error); + } + } + } catch (error) { + console.error("부모 RFQ 로드 오류:", error); + } finally { + setIsLoadingParents(false); + } + }; + + loadParentRfqs(); + } + }, [rfqType, open]); + + // 프로젝트 선택 처리 + const handleProjectSelect = (project: Project | null) => { + if (project === null) { + return; + } + + form.setValue("projectId", project.id); + }; + + const handleBidProjectSelect = (project: Project | null) => { + if (project === null) { + return; + } + + form.setValue("bidProjectId", project.id); + }; + + // 부모 RFQ 선택 처리 + const handleParentRfqSelect = (rfq: ParentRfq | null) => { + setSelectedParentRfq(rfq); + form.setValue("parentRfqId", rfq?.id); + }; + + async function onSubmit(data: CreateRfqSchema) { + // Check if user is authenticated before submitting + if (status !== "authenticated" || !userId) { + toast.error("사용자 인증이 필요합니다. 다시 로그인해주세요."); + return; + } + + // Make sure createdBy is set with the current user ID + const submitData = { + ...data, + createdBy: userId + }; + + console.log("Submitting form data:", submitData); + + const result = await createRfq(submitData); + if (result.error) { + toast.error(`에러: ${result.error}`); + return; + } + + toast.success("RFQ가 성공적으로 생성되었습니다."); + form.reset(); + setSelectedParentRfq(null); + setOpen(false); + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset(); + setSelectedParentRfq(null); + } + setOpen(nextOpen); + } + + // Return a message or disabled state if user is not authenticated + if (status === "loading") { + return <Button variant="outline" size="sm" disabled>Loading...</Button>; + } + + // 타입에 따라 부모 RFQ 선택 필드를 보여줄지 결정 + const shouldShowParentRfqSelector = rfqType === RfqType.PURCHASE || rfqType === RfqType.PURCHASE_BUDGETARY; + const shouldShowEstimateSelector = rfqType === RfqType.BUDGETARY; + + // 부모 RFQ 선택기 레이블 및 설명 가져오기 + const getParentRfqSelectorLabel = () => { + if (rfqType === RfqType.PURCHASE) { + return "부모 RFQ (BUDGETARY/PURCHASE_BUDGETARY)"; + } else if (rfqType === RfqType.PURCHASE_BUDGETARY) { + return "부모 RFQ (BUDGETARY)"; + } + return "부모 RFQ"; + }; + + const getParentRfqDescription = () => { + if (rfqType === RfqType.PURCHASE) { + return "BUDGETARY 또는 PURCHASE_BUDGETARY 타입의 RFQ를 부모로 선택할 수 있습니다."; + } else if (rfqType === RfqType.PURCHASE_BUDGETARY) { + return "BUDGETARY 타입의 RFQ만 부모로 선택할 수 있습니다."; + } + return ""; + }; + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + {/* 모달을 열기 위한 버튼 */} + <DialogTrigger asChild> + <Button variant="default" size="sm"> + Add {getTitle()} + </Button> + </DialogTrigger> + + <DialogContent> + <DialogHeader> + <DialogTitle>Create New {getTitle()}</DialogTitle> + <DialogDescription> + 새 {getTitle()} 정보를 입력하고 <b>Create</b> 버튼을 누르세요. + <div className="mt-1 text-xs text-muted-foreground"> + {getTypeDescription()} + </div> + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="space-y-4 py-4"> + {/* rfqType - hidden field */} + <FormField + control={form.control} + name="rfqType" + render={({ field }) => ( + <input type="hidden" {...field} /> + )} + /> + + {/* Project Selector */} + <FormField + control={form.control} + name="projectId" + render={({ field }) => ( + <FormItem> + <FormLabel>Project</FormLabel> + <FormControl> + + {shouldShowEstimateSelector ? + <EstimateProjectSelector + selectedProjectId={field.value} + onProjectSelect={handleBidProjectSelect} + placeholder="견적 프로젝트 선택..." + /> : + <ProjectSelector + selectedProjectId={field.value} + onProjectSelect={handleProjectSelect} + placeholder="프로젝트 선택..." + />} + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Parent RFQ Selector - PURCHASE 또는 PURCHASE_BUDGETARY 타입일 때만 표시 */} + {shouldShowParentRfqSelector && ( + <FormField + control={form.control} + name="parentRfqId" + render={({ field }) => ( + <FormItem> + <FormLabel>{getParentRfqSelectorLabel()}</FormLabel> + <FormControl> + <ParentRfqSelector + selectedRfqId={field.value as number | undefined} + onRfqSelect={handleParentRfqSelect} + rfqType={rfqType} + parentRfqTypes={getParentRfqTypes()} + placeholder={ + rfqType === RfqType.PURCHASE + ? "BUDGETARY 또는 PURCHASE_BUDGETARY RFQ 선택..." + : "BUDGETARY RFQ 선택..." + } + /> + </FormControl> + <div className="text-xs text-muted-foreground mt-1"> + {getParentRfqDescription()} + </div> + <FormMessage /> + </FormItem> + )} + /> + )} + + {/* rfqCode - 자동 생성되고 읽기 전용 */} + <FormField + control={form.control} + name="rfqCode" + render={({ field }) => ( + <FormItem> + <FormLabel>RFQ Code</FormLabel> + <FormControl> + <div className="flex"> + <Input + placeholder="자동으로 생성 중..." + {...field} + disabled={true} + className="bg-muted" + /> + {isLoadingRfqCode && ( + <div className="ml-2 flex items-center"> + <div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div> + </div> + )} + </div> + </FormControl> + <div className="text-xs text-muted-foreground mt-1"> + RFQ 타입과 현재 날짜를 기준으로 자동 생성됩니다 + </div> + <FormMessage /> + </FormItem> + )} + /> + + {/* description */} + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>RFQ Description</FormLabel> + <FormControl> + <Input placeholder="e.g. 설명을 입력하세요" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* dueDate */} + <FormField + control={form.control} + name="dueDate" + render={({ field }) => ( + <FormItem> + <FormLabel>Due Date</FormLabel> + <FormControl> + <Input + type="date" + value={field.value ? field.value.toISOString().slice(0, 10) : ""} + onChange={(e) => { + const val = e.target.value + if (val) { + const date = new Date(val); + // 날짜 1일씩 밀리는 문제로 우선 KTC로 입력 + // 추후 아래와 같이 수정 + // 1. 해당 유저 타임존 값으로 입력 + // 2. DB에는 UTC 타임존 값으로 저장 + // 3. 출력시 유저별 타임존 값으로 변환해 출력 + // 4. 어떤 타임존으로 나오는지도 함께 렌더링 + // field.onChange(new Date(val + "T00:00:00")) + field.onChange(date); + } + }} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* status (Read-only) */} + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>Status</FormLabel> + <FormControl> + <Input + disabled + className="capitalize" + {...field} + onChange={() => { }} // Prevent changes + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => setOpen(false)} + > + Cancel + </Button> + <Button + type="submit" + disabled={form.formState.isSubmitting || status !== "authenticated"} + > + Create + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/rfqs-ship/table/attachment-rfq-sheet.tsx b/lib/rfqs-ship/table/attachment-rfq-sheet.tsx new file mode 100644 index 00000000..57a170e1 --- /dev/null +++ b/lib/rfqs-ship/table/attachment-rfq-sheet.tsx @@ -0,0 +1,430 @@ +"use client" + +import * as React from "react" +import { z } from "zod" +import { useForm, useFieldArray } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" + +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, + SheetFooter, + SheetClose, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription +} from "@/components/ui/form" +import { Trash2, Plus, Loader, Download, X, Eye, AlertCircle } from "lucide-react" +import { useToast } from "@/hooks/use-toast" +import { Badge } from "@/components/ui/badge" + +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone" +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list" + +import prettyBytes from "pretty-bytes" +import { processRfqAttachments } from "../service" +import { format } from "path" +import { formatDate } from "@/lib/utils" +import { RfqType } from "../validations" +import { RfqWithItemCount } from "@/db/schema/rfq" + +const MAX_FILE_SIZE = 6e8 // 600MB + +/** 기존 첨부 파일 정보 */ +interface ExistingAttachment { + id: number + fileName: string + filePath: string + createdAt?: Date // or Date + vendorId?: number | null + size?: number +} + +/** 새로 업로드할 파일 */ +const newUploadSchema = z.object({ + fileObj: z.any().optional(), // 실제 File +}) + +/** 기존 첨부 (react-hook-form에서 관리) */ +const existingAttachSchema = z.object({ + id: z.number(), + fileName: z.string(), + filePath: z.string(), + vendorId: z.number().nullable().optional(), + createdAt: z.custom<Date>().optional(), // or use z.any().optional() + size: z.number().optional(), +}) + +/** RHF 폼 전체 스키마 */ +const attachmentsFormSchema = z.object({ + rfqId: z.number().int(), + existing: z.array(existingAttachSchema), + newUploads: z.array(newUploadSchema), +}) + +type AttachmentsFormValues = z.infer<typeof attachmentsFormSchema> + +interface RfqAttachmentsSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + defaultAttachments?: ExistingAttachment[] + rfqType?: RfqType + rfq: RfqWithItemCount | null + /** 업로드/삭제 후 상위 테이블에 itemCount 등을 업데이트하기 위한 콜백 */ + onAttachmentsUpdated?: (rfqId: number, newItemCount: number) => void +} + +/** + * RfqAttachmentsSheet: + * - 기존 첨부 목록 (다운로드 + 삭제) + * - 새 파일 Dropzone + * - Save 시 processRfqAttachments(server action) + */ +export function RfqAttachmentsSheet({ + defaultAttachments = [], + onAttachmentsUpdated, + rfq, + rfqType, + ...props +}: RfqAttachmentsSheetProps) { + const { toast } = useToast() + const [isPending, startUpdate] = React.useTransition() + const rfqId = rfq?.rfqId ?? 0; + + // 편집 가능 여부 확인 - DRAFT 상태일 때만 편집 가능 + const isEditable = rfq?.status === "DRAFT"; + + // React Hook Form + const form = useForm<AttachmentsFormValues>({ + resolver: zodResolver(attachmentsFormSchema), + defaultValues: { + rfqId, + existing: [], + newUploads: [], + }, + }) + + const { reset, control, handleSubmit } = form + + // defaultAttachments가 바뀔 때마다, RHF 상태를 reset + React.useEffect(() => { + reset({ + rfqId, + existing: defaultAttachments.map((att) => ({ + ...att, + vendorId: att.vendorId ?? null, + size: att.size ?? undefined, + })), + newUploads: [], + }) + }, [rfqId, defaultAttachments, reset]) + + // Field Arrays + const { + fields: existingFields, + remove: removeExisting, + } = useFieldArray({ control, name: "existing" }) + + const { + fields: newUploadFields, + append: appendNewUpload, + remove: removeNewUpload, + } = useFieldArray({ control, name: "newUploads" }) + + // 기존 첨부 항목 중 삭제된 것 찾기 + function findRemovedExistingIds(data: AttachmentsFormValues): number[] { + const finalIds = data.existing.map((att) => att.id) + const originalIds = defaultAttachments.map((att) => att.id) + return originalIds.filter((id) => !finalIds.includes(id)) + } + + async function onSubmit(data: AttachmentsFormValues) { + // 편집 불가능한 상태에서는 제출 방지 + if (!isEditable) return; + + startUpdate(async () => { + try { + const removedExistingIds = findRemovedExistingIds(data) + const newFiles = data.newUploads + .map((it) => it.fileObj) + .filter((f): f is File => !!f) + + // 서버 액션 + const res = await processRfqAttachments({ + rfqId, + removedExistingIds, + newFiles, + vendorId: null, // vendor ID if needed + rfqType + }) + + if (!res.ok) throw new Error(res.error ?? "Unknown error") + + const newCount = res.updatedItemCount ?? 0 + + toast({ + variant: "default", + title: "Success", + description: "File(s) updated", + }) + + // 상위 테이블 등에 itemCount 업데이트 + onAttachmentsUpdated?.(rfqId, newCount) + + // 모달 닫기 + props.onOpenChange?.(false) + } catch (err) { + toast({ + variant: "destructive", + title: "Error", + description: String(err), + }) + } + }) + } + + /** 기존 첨부 - X 버튼 */ + function handleRemoveExisting(idx: number) { + // 편집 불가능한 상태에서는 삭제 방지 + if (!isEditable) return; + removeExisting(idx) + } + + /** 드롭존에서 파일 받기 */ + function handleDropAccepted(acceptedFiles: File[]) { + // 편집 불가능한 상태에서는 파일 추가 방지 + if (!isEditable) return; + const mapped = acceptedFiles.map((file) => ({ fileObj: file })) + appendNewUpload(mapped) + } + + /** 드롭존에서 파일 거부(에러) */ + function handleDropRejected(fileRejections: any[]) { + // 편집 불가능한 상태에서는 무시 + if (!isEditable) return; + + fileRejections.forEach((rej) => { + toast({ + variant: "destructive", + title: "File Error", + description: rej.file.name + " not accepted", + }) + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-sm"> + <SheetHeader> + <SheetTitle className="flex items-center gap-2"> + {isEditable ? "Manage Attachments" : "View Attachments"} + {rfq?.status && ( + <Badge + variant={rfq.status === "DRAFT" ? "outline" : "secondary"} + className="ml-1" + > + {rfq.status} + </Badge> + )} + </SheetTitle> + <SheetDescription> + {`RFQ ${rfq?.rfqCode} - `} + {isEditable ? '파일 첨부/삭제' : '첨부 파일 보기'} + {!isEditable && ( + <div className="mt-1 text-xs flex items-center gap-1 text-amber-600"> + <AlertCircle className="h-3 w-3" /> + <span>드래프트 상태가 아닌 RFQ는 첨부파일을 수정할 수 없습니다.</span> + </div> + )} + </SheetDescription> + </SheetHeader> + + <Form {...form}> + <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4"> + {/* 1) 기존 첨부 목록 */} + <div className="space-y-2"> + <p className="font-semibold text-sm">Existing Attachments</p> + {existingFields.length === 0 && ( + <p className="text-sm text-muted-foreground">No existing attachments</p> + )} + {existingFields.map((field, index) => { + const vendorLabel = field.vendorId ? "(Vendor)" : "(Internal)" + return ( + <div + key={field.id} + className="flex items-center justify-between rounded border p-2" + > + <div className="flex flex-col text-sm"> + <span className="font-medium"> + {field.fileName} {vendorLabel} + </span> + {field.size && ( + <span className="text-xs text-muted-foreground"> + {Math.round(field.size / 1024)} KB + </span> + )} + {field.createdAt && ( + <span className="text-xs text-muted-foreground"> + Created at {formatDate(field.createdAt)} + </span> + )} + </div> + <div className="flex items-center gap-2"> + {/* 1) Download button (if filePath) */} + {field.filePath && ( + <a + href={`/api/rfq-download?path=${encodeURIComponent(field.filePath)}`} + download={field.fileName} + className="text-sm" + > + <Button variant="ghost" size="icon" type="button"> + <Download className="h-4 w-4" /> + </Button> + </a> + )} + {/* 2) Remove button - 편집 가능할 때만 표시 */} + {isEditable && ( + <Button + type="button" + variant="ghost" + size="icon" + onClick={() => handleRemoveExisting(index)} + > + <X className="h-4 w-4" /> + </Button> + )} + </div> + </div> + ) + })} + </div> + + {/* 2) Dropzone for new uploads - 편집 가능할 때만 표시 */} + {isEditable ? ( + <> + <Dropzone + maxSize={MAX_FILE_SIZE} + onDropAccepted={handleDropAccepted} + onDropRejected={handleDropRejected} + > + {({ maxSize }) => ( + <FormField + control={control} + name="newUploads" // not actually used for storing each file detail + render={() => ( + <FormItem> + <FormLabel>Drop Files Here</FormLabel> + <DropzoneZone className="flex justify-center"> + <FormControl> + <DropzoneInput /> + </FormControl> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>Drop to upload</DropzoneTitle> + <DropzoneDescription> + Max size: {maxSize ? prettyBytes(maxSize) : "??? MB"} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + <FormDescription>Alternatively, click browse.</FormDescription> + <FormMessage /> + </FormItem> + )} + /> + )} + </Dropzone> + + {/* newUpload fields -> FileList */} + {newUploadFields.length > 0 && ( + <div className="grid gap-4"> + <h6 className="font-semibold leading-none tracking-tight"> + {`Files (${newUploadFields.length})`} + </h6> + <FileList> + {newUploadFields.map((field, idx) => { + const fileObj = form.getValues(`newUploads.${idx}.fileObj`) + if (!fileObj) return null + + const fileName = fileObj.name + const fileSize = fileObj.size + return ( + <FileListItem key={field.id}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{fileName}</FileListName> + <FileListDescription> + {`${prettyBytes(fileSize)}`} + </FileListDescription> + </FileListInfo> + <FileListAction onClick={() => removeNewUpload(idx)}> + <X /> + <span className="sr-only">Remove</span> + </FileListAction> + </FileListHeader> + </FileListItem> + ) + })} + </FileList> + </div> + )} + </> + ) : ( + <div className="p-3 bg-muted rounded-md flex items-center justify-center"> + <div className="text-center text-sm text-muted-foreground"> + <Eye className="h-4 w-4 mx-auto mb-2" /> + <p>보기 모드에서는 파일 첨부를 할 수 없습니다.</p> + </div> + </div> + )} + + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + {isEditable ? "Cancel" : "Close"} + </Button> + </SheetClose> + {isEditable && ( + <Button + type="submit" + disabled={isPending || (form.getValues().newUploads.length === 0 && defaultAttachments.length === form.getValues().existing.length)} + > + {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} + Save + </Button> + )} + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/rfqs-ship/table/delete-rfqs-dialog.tsx b/lib/rfqs-ship/table/delete-rfqs-dialog.tsx new file mode 100644 index 00000000..09596bc7 --- /dev/null +++ b/lib/rfqs-ship/table/delete-rfqs-dialog.tsx @@ -0,0 +1,149 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash } 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 { Rfq, RfqWithItemCount } from "@/db/schema/rfq" +import { removeRfqs } from "../service" + +interface DeleteRfqsDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + rfqs: Row<RfqWithItemCount>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteRfqsDialog({ + rfqs, + showTrigger = true, + onSuccess, + ...props +}: DeleteRfqsDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + const { error } = await removeRfqs({ + ids: rfqs.map((rfq) => rfq.rfqId), + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("Tasks deleted") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({rfqs.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{rfqs.length}</span> + {rfqs.length === 1 ? " task" : " rfqs"} from our servers. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Delete + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({rfqs.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Are you absolutely sure?</DrawerTitle> + <DrawerDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{rfqs.length}</span> + {rfqs.length === 1 ? " task" : " rfqs"} from our servers. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Delete + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +} diff --git a/lib/rfqs-ship/table/feature-flags-provider.tsx b/lib/rfqs-ship/table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/rfqs-ship/table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/rfqs-ship/table/feature-flags.tsx b/lib/rfqs-ship/table/feature-flags.tsx new file mode 100644 index 00000000..aaae6af2 --- /dev/null +++ b/lib/rfqs-ship/table/feature-flags.tsx @@ -0,0 +1,96 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface TasksTableContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const TasksTableContext = React.createContext<TasksTableContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useTasksTable() { + const context = React.useContext(TasksTableContext) + if (!context) { + throw new Error("useTasksTable must be used within a TasksTableProvider") + } + return context +} + +export function TasksTableProvider({ children }: React.PropsWithChildren) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "featureFlags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + } + ) + + return ( + <TasksTableContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit" + > + {dataTableConfig.featureFlags.map((flag) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className="whitespace-nowrap px-3 text-xs" + asChild + > + <TooltipTrigger> + <flag.icon + className="mr-2 size-3.5 shrink-0" + aria-hidden="true" + /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </TasksTableContext.Provider> + ) +} diff --git a/lib/rfqs-ship/table/rfqs-table-columns.tsx b/lib/rfqs-ship/table/rfqs-table-columns.tsx new file mode 100644 index 00000000..fd900782 --- /dev/null +++ b/lib/rfqs-ship/table/rfqs-table-columns.tsx @@ -0,0 +1,315 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis, Paperclip, Package } from "lucide-react" +import { toast } from "sonner" + +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +import { getRFQStatusIcon } from "@/lib/tasks/utils" +import { rfqsColumnsConfig } from "@/config/rfqsColumnsConfig" +import { RfqWithItemCount } from "@/db/schema/rfq" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { useRouter } from "next/navigation" +import { RfqType } from "../validations" + +type NextRouter = ReturnType<typeof useRouter>; + +interface GetColumnsProps { + setRowAction: React.Dispatch< + React.SetStateAction<DataTableRowAction<RfqWithItemCount> | null> + > + openItemsModal: (rfqId: number) => void + openAttachmentsSheet: (rfqId: number) => void + router: NextRouter + rfqType?: RfqType +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ + setRowAction, + openItemsModal, + openAttachmentsSheet, + router, + rfqType, +}: GetColumnsProps): ColumnDef<RfqWithItemCount>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<RfqWithItemCount> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<RfqWithItemCount> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + // Proceed 버튼 클릭 시 호출되는 함수 + const handleProceed = () => { + const rfq = row.original + const itemCount = Number(rfq.itemCount || 0) + const attachCount = Number(rfq.attachCount || 0) + + // 아이템과 첨부파일이 모두 0보다 커야 진행 가능 + if (itemCount > 0 && attachCount > 0) { + router.push( + rfqType === RfqType.PURCHASE + ? `/evcp/rfq/${rfq.rfqId}` + : `/evcp/budgetary-ship/${rfq.rfqId}` + ) + } else { + // 조건을 충족하지 않는 경우 토스트 알림 표시 + if (itemCount === 0 && attachCount === 0) { + toast.error("아이템과 첨부파일을 먼저 추가해주세요.") + } else if (itemCount === 0) { + toast.error("아이템을 먼저 추가해주세요.") + } else { + toast.error("첨부파일을 먼저 추가해주세요.") + } + } + } + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + Edit + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem onSelect={handleProceed}> + {row.original.status ==="DRAFT"?"Proceed":"View Detail"} + <DropdownMenuShortcut>↵</DropdownMenuShortcut> + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 3) itemsColumn (아이템 개수 표시: 아이콘 + Badge) + // ---------------------------------------------------------------- + const itemsColumn: ColumnDef<RfqWithItemCount> = { + id: "items", + header: "Items", + cell: ({ row }) => { + const rfq = row.original + const itemCount = rfq.itemCount || 0 + + const handleClick = () => { + openItemsModal(rfq.rfqId) + } + + return ( + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label={ + itemCount > 0 ? `View ${itemCount} items` : "Add items" + } + > + <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + {itemCount > 0 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" + > + {itemCount} + </Badge> + )} + <span className="sr-only"> + {itemCount > 0 ? `${itemCount} Items` : "Add Items"} + </span> + </Button> + ) + }, + enableSorting: false, + size: 60, + } + + // ---------------------------------------------------------------- + // 4) attachmentsColumn (첨부파일 개수 표시: 아이콘 + Badge) + // ---------------------------------------------------------------- + const attachmentsColumn: ColumnDef<RfqWithItemCount> = { + id: "attachments", + header: "Attachments", + cell: ({ row }) => { + const fileCount = row.original.attachCount ?? 0 + + const handleClick = () => { + openAttachmentsSheet(row.original.rfqId) + } + + return ( + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label={ + fileCount > 0 ? `View ${fileCount} files` : "Add files" + } + > + <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + {fileCount > 0 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" + > + {fileCount} + </Badge> + )} + <span className="sr-only"> + {fileCount > 0 ? `${fileCount} Files` : "Add Files"} + </span> + </Button> + ) + }, + enableSorting: false, + size: 60, + } + + // ---------------------------------------------------------------- + // 5) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + const groupMap: Record<string, ColumnDef<RfqWithItemCount>[]> = {} + + rfqsColumnsConfig.forEach((cfg) => { + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<RfqWithItemCount> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + if (cfg.id === "status") { + const statusVal = row.original.status + if (!statusVal) return null + const Icon = getRFQStatusIcon( + statusVal as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED" + ) + 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> + ) + } + + if (cfg.id === "createdAt" || cfg.id === "updatedAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // groupMap -> nestedColumns + const nestedColumns: ColumnDef<RfqWithItemCount>[] = [] + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + nestedColumns.push(...colDefs) + } else { + nestedColumns.push({ + id: groupName, + header: groupName, + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 6) 최종 컬럼 배열 + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + attachmentsColumn, // 첨부파일 + actionsColumn, + itemsColumn, // 아이템 + ] +}
\ No newline at end of file diff --git a/lib/rfqs-ship/table/rfqs-table-floating-bar.tsx b/lib/rfqs-ship/table/rfqs-table-floating-bar.tsx new file mode 100644 index 00000000..daef7e0b --- /dev/null +++ b/lib/rfqs-ship/table/rfqs-table-floating-bar.tsx @@ -0,0 +1,338 @@ +"use client" + +import * as React from "react" +import { Table } from "@tanstack/react-table" +import { toast } from "sonner" +import { Calendar, type CalendarProps } from "@/components/ui/calendar" +import { Button } from "@/components/ui/button" +import { Portal } from "@/components/ui/portal" +import { + Select, + SelectTrigger, + SelectContent, + SelectGroup, + SelectItem, + SelectValue, +} from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" +import { + Tooltip, + TooltipTrigger, + TooltipContent, +} from "@/components/ui/tooltip" +import { Kbd } from "@/components/kbd" +import { ActionConfirmDialog } from "@/components/ui/action-dialog" + +import { ArrowUp, CheckCircle2, Download, Loader, Trash2, X, CalendarIcon } from "lucide-react" + +import { exportTableToExcel } from "@/lib/export" + +import { RfqWithItemCount, rfqs } from "@/db/schema/rfq" +import { modifyRfqs, removeRfqs } from "../service" + +interface RfqsTableFloatingBarProps { + table: Table<RfqWithItemCount> +} + +/** + * 추가된 로직: + * - 달력(캘린더) 아이콘 버튼 + * - 눌렀을 때 Popover로 Calendar 표시 + * - 날짜 선택 시 Confirm 다이얼로그 → modifyRfqs({ dueDate }) + */ +export function RfqsTableFloatingBar({ table }: RfqsTableFloatingBarProps) { + const rows = table.getFilteredSelectedRowModel().rows + const [isPending, startTransition] = React.useTransition() + const [action, setAction] = React.useState<"update-status" | "export" | "delete" | "update-dueDate">() + const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false) + + const [confirmProps, setConfirmProps] = React.useState<{ + title: string + description?: string + onConfirm: () => Promise<void> | void + }>({ + title: "", + description: "", + onConfirm: () => {}, + }) + + // 캘린더 Popover 열림 여부 + const [calendarOpen, setCalendarOpen] = React.useState(false) + const [selectedDate, setSelectedDate] = React.useState<Date | null>(null) + + // Clear selection on Escape key press + React.useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + table.toggleAllRowsSelected(false) + } + } + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [table]) + + function handleDeleteConfirm() { + setAction("delete") + setConfirmProps({ + title: `Delete ${rows.length} RFQ${rows.length > 1 ? "s" : ""}?`, + description: "This action cannot be undone.", + onConfirm: async () => { + startTransition(async () => { + const { error } = await removeRfqs({ + ids: rows.map((row) => row.original.rfqId), + }) + if (error) { + toast.error(error) + return + } + toast.success("RFQs deleted") + table.toggleAllRowsSelected(false) + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + function handleSelectStatus(newStatus: RfqWithItemCount["status"]) { + setAction("update-status") + setConfirmProps({ + title: `Update ${rows.length} RFQ${rows.length > 1 ? "s" : ""} with status: ${newStatus}?`, + description: "This action will override their current status.", + onConfirm: async () => { + startTransition(async () => { + const { error } = await modifyRfqs({ + ids: rows.map((row) => row.original.rfqId), + status: newStatus as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED", + }) + if (error) { + toast.error(error) + return + } + toast.success("RFQs updated") + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + // 1) 달력에서 날짜를 선택했을 때 → Confirm 다이얼로그 + function handleDueDateSelect(newDate: Date) { + setAction("update-dueDate") + + setConfirmProps({ + title: `Update ${rows.length} RFQ${rows.length > 1 ? "s" : ""} Due Date to ${newDate.toDateString()}?`, + description: "This action will override their current due date.", + onConfirm: async () => { + startTransition(async () => { + const { error } = await modifyRfqs({ + ids: rows.map((r) => r.original.rfqId), + dueDate: newDate, + }) + if (error) { + toast.error(error) + return + } + toast.success("Due date updated") + setConfirmDialogOpen(false) + setCalendarOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + // 2) Export + function handleExport() { + setAction("export") + startTransition(() => { + exportTableToExcel(table, { + excludeColumns: ["select", "actions"], + onlySelected: true, + }) + }) + } + + // Floating bar UI + return ( + <Portal> + <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5"> + <div className="w-full overflow-x-auto"> + <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow"> + {/* Selection Info + Clear */} + <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1"> + <span className="whitespace-nowrap text-xs"> + {rows.length} selected + </span> + <Separator orientation="vertical" className="ml-2 mr-1" /> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="size-5 hover:border" + onClick={() => table.toggleAllRowsSelected(false)} + > + <X className="size-3.5 shrink-0" aria-hidden="true" /> + </Button> + </TooltipTrigger> + <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900"> + <p className="mr-2">Clear selection</p> + <Kbd abbrTitle="Escape" variant="outline"> + Esc + </Kbd> + </TooltipContent> + </Tooltip> + </div> + + <Separator orientation="vertical" className="hidden h-5 sm:block" /> + + <div className="flex items-center gap-1.5"> + {/* 1) Status Update */} + <Select + onValueChange={(value: RfqWithItemCount["status"]) => handleSelectStatus(value)} + > + <Tooltip> + <SelectTrigger asChild> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground" + disabled={isPending} + > + {isPending && action === "update-status" ? ( + <Loader className="size-3.5 animate-spin" aria-hidden="true" /> + ) : ( + <CheckCircle2 className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + </SelectTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Update status</p> + </TooltipContent> + </Tooltip> + <SelectContent align="center"> + <SelectGroup> + {rfqs.status.enumValues.map((status) => ( + <SelectItem key={status} value={status} className="capitalize"> + {status} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + + {/* 2) Due Date Update: Calendar Popover */} + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border" + disabled={isPending} + onClick={() => setCalendarOpen((open) => !open)} + > + {isPending && action === "update-dueDate" ? ( + <Loader className="size-3.5 animate-spin" aria-hidden="true" /> + ) : ( + <CalendarIcon className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Update Due Date</p> + </TooltipContent> + </Tooltip> + + {/* Calendar Popover (간단 구현) */} + {calendarOpen && ( + <div className="absolute bottom-16 z-50 rounded-md border bg-background p-2 shadow"> + <Calendar + mode="single" + selected={selectedDate || new Date()} + onSelect={(date) => { + if (date) { + setSelectedDate(date) + handleDueDateSelect(date) + } + }} + initialFocus + /> + </div> + )} + + {/* 3) Export */} + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border" + onClick={handleExport} + disabled={isPending} + > + {isPending && action === "export" ? ( + <Loader className="size-3.5 animate-spin" aria-hidden="true" /> + ) : ( + <Download className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Export tasks</p> + </TooltipContent> + </Tooltip> + + {/* 4) Delete */} + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border" + onClick={handleDeleteConfirm} + disabled={isPending} + > + {isPending && action === "delete" ? ( + <Loader className="size-3.5 animate-spin" aria-hidden="true" /> + ) : ( + <Trash2 className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Delete tasks</p> + </TooltipContent> + </Tooltip> + </div> + </div> + </div> + </div> + + {/* 공용 Confirm Dialog */} + <ActionConfirmDialog + open={confirmDialogOpen} + onOpenChange={setConfirmDialogOpen} + title={confirmProps.title} + description={confirmProps.description} + onConfirm={confirmProps.onConfirm} + isLoading={ + isPending && (action === "delete" || action === "update-status" || action === "update-dueDate") + } + confirmLabel={ + action === "delete" + ? "Delete" + : action === "update-status" + ? "Update" + : action === "update-dueDate" + ? "Update" + : "Confirm" + } + confirmVariant={action === "delete" ? "destructive" : "default"} + /> + </Portal> + ) +}
\ No newline at end of file diff --git a/lib/rfqs-ship/table/rfqs-table-toolbar-actions.tsx b/lib/rfqs-ship/table/rfqs-table-toolbar-actions.tsx new file mode 100644 index 00000000..6402e625 --- /dev/null +++ b/lib/rfqs-ship/table/rfqs-table-toolbar-actions.tsx @@ -0,0 +1,55 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { RfqWithItemCount } from "@/db/schema/rfq" +import { DeleteRfqsDialog } from "./delete-rfqs-dialog" +import { AddRfqDialog } from "./add-rfq-dialog" +import { RfqType } from "../validations" + + +interface RfqsTableToolbarActionsProps { + table: Table<RfqWithItemCount> + rfqType?: RfqType; +} + +export function RfqsTableToolbarActions({ table , rfqType = RfqType.PURCHASE}: RfqsTableToolbarActionsProps) { + return ( + <div className="flex items-center gap-2"> + {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */} + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <DeleteRfqsDialog + rfqs={table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original)} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + ) : null} + + {/** 2) 새 Task 추가 다이얼로그 */} + <AddRfqDialog rfqType={rfqType} /> + + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "tasks", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/rfqs-ship/table/rfqs-table.tsx b/lib/rfqs-ship/table/rfqs-table.tsx new file mode 100644 index 00000000..287f1d53 --- /dev/null +++ b/lib/rfqs-ship/table/rfqs-table.tsx @@ -0,0 +1,263 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + 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 { getRFQStatusIcon } from "@/lib/tasks/utils" +import { useFeatureFlags } from "./feature-flags-provider" +import { getColumns } from "./rfqs-table-columns" +import { fetchRfqAttachments, fetchRfqItems, getRfqs, getRfqStatusCounts } from "../service" +import { RfqItem, RfqWithItemCount, rfqs } from "@/db/schema/rfq" +import { RfqsTableFloatingBar } from "./rfqs-table-floating-bar" +import { UpdateRfqSheet } from "./update-rfq-sheet" +import { DeleteRfqsDialog } from "./delete-rfqs-dialog" +import { RfqsTableToolbarActions } from "./rfqs-table-toolbar-actions" +import { RfqsItemsDialog } from "./ItemsDialog" +import { getAllItems } from "@/lib/items/service" +import { RfqAttachmentsSheet } from "./attachment-rfq-sheet" +import { useRouter } from "next/navigation" +import { RfqType } from "../validations" + +interface RfqsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getRfqs>>, + Awaited<ReturnType<typeof getRfqStatusCounts>>, + Awaited<ReturnType<typeof getAllItems>>, + ] + >; + rfqType?: RfqType; // rfqType props 추가 +} + +export interface ExistingAttachment { + id: number; + fileName: string; + filePath: string; + createdAt?: Date; + vendorId?: number | null; + size?: number; +} + +export interface ExistingItem { + id?: number; + itemCode: string; + description: string | null; + quantity: number | null; + uom: string | null; +} + +export function RfqsTable({ promises, rfqType = RfqType.PURCHASE }: RfqsTableProps) { + const { featureFlags } = useFeatureFlags() + + const [{ data, pageCount }, statusCounts, items] = React.use(promises) + const [attachmentsOpen, setAttachmentsOpen] = React.useState(false) + const [selectedRfqIdForAttachments, setSelectedRfqIdForAttachments] = React.useState<number | null>(null) + const [attachDefault, setAttachDefault] = React.useState<ExistingAttachment[]>([]) + const [itemsDefault, setItemsDefault] = React.useState<ExistingItem[]>([]) + + const router = useRouter() + + const itemsList = items?.map((v) => ({ + code: v.itemCode ?? "", + name: v.itemName ?? "", + })); + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<RfqWithItemCount> | null>(null) + + const [rowData, setRowData] = React.useState<RfqWithItemCount[]>(() => data) + + const [itemsModalOpen, setItemsModalOpen] = React.useState(false); + const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null); + + + const selectedRfq = React.useMemo(() => { + return rowData.find(row => row.rfqId === selectedRfqId) || null; + }, [rowData, selectedRfqId]); + + // rfqType에 따른 제목 계산 + const getRfqTypeTitle = () => { + return rfqType === RfqType.PURCHASE ? "Purchase RFQ" : "Budgetary RFQ"; + }; + + async function openItemsModal(rfqId: number) { + const itemList = await fetchRfqItems(rfqId) + setItemsDefault(itemList) + setSelectedRfqId(rfqId); + setItemsModalOpen(true); + } + + async function openAttachmentsSheet(rfqId: number) { + // 4.1) Fetch current attachments from server (server action) + const list = await fetchRfqAttachments(rfqId) // returns ExistingAttachment[] + setAttachDefault(list) + setSelectedRfqIdForAttachments(rfqId) + setAttachmentsOpen(true) + setSelectedRfqId(rfqId); + } + + function handleAttachmentsUpdated(rfqId: number, newCount: number, newList?: ExistingAttachment[]) { + // 5.1) update rowData itemCount + setRowData(prev => + prev.map(r => + r.rfqId === rfqId + ? { ...r, itemCount: newCount } + : r + ) + ) + // 5.2) if newList is provided, store it + if (newList) { + setAttachDefault(newList) + } + } + + const columns = React.useMemo(() => getColumns({ + setRowAction, router, + // we pass openItemsModal as a prop so the itemsColumn can call it + openItemsModal, + openAttachmentsSheet, + rfqType + }), [setRowAction, router, rfqType]); + + /** + * This component can render either a faceted filter or a search filter based on the `options` prop. + */ + const filterFields: DataTableFilterField<RfqWithItemCount>[] = [ + { + id: "rfqCode", + label: "RFQ Code", + placeholder: "Filter RFQ Code...", + }, + { + id: "status", + label: "Status", + options: rfqs.status.enumValues?.map((status) => { + // 명시적으로 status를 허용된 리터럴 타입으로 변환 + const s = status as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED"; + return { + label: toSentenceCase(s), + value: s, + icon: getRFQStatusIcon(s), + count: statusCounts[s], + }; + }), + + } + ] + + /** + * Advanced filter fields for the data table. + */ + const advancedFilterFields: DataTableAdvancedFilterField<RfqWithItemCount>[] = [ + { + id: "rfqCode", + label: "RFQ Code", + type: "text", + }, + { + id: "description", + label: "Description", + type: "text", + }, + { + id: "projectCode", + label: "Project Code", + type: "text", + }, + { + id: "dueDate", + label: "Due Date", + type: "date", + }, + { + id: "status", + label: "Status", + type: "multi-select", + options: rfqs.status.enumValues?.map((status) => { + // 명시적으로 status를 허용된 리터럴 타입으로 변환 + const s = status as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED"; + return { + label: toSentenceCase(s), + value: s, + icon: getRFQStatusIcon(s), + count: statusCounts[s], + }; + }), + + }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.rfqId), + shallow: false, + clearOnDefault: true, + }) + + return ( + <div style={{ maxWidth: '100vw' }}> + <DataTable + table={table} + // floatingBar={<RfqsTableFloatingBar table={table} />} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <RfqsTableToolbarActions table={table} rfqType={rfqType} /> + </DataTableAdvancedToolbar> + </DataTable> + + <UpdateRfqSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + rfq={rowAction?.row.original ?? null} + /> + + <DeleteRfqsDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + rfqs={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => rowAction?.row.toggleSelected(false)} + /> + + <RfqsItemsDialog + open={itemsModalOpen} + onOpenChange={setItemsModalOpen} + rfq={selectedRfq ?? null} + itemsList={itemsList} + defaultItems={itemsDefault} + rfqType={rfqType} + /> + + <RfqAttachmentsSheet + open={attachmentsOpen} + onOpenChange={setAttachmentsOpen} + defaultAttachments={attachDefault} + rfqType={rfqType} + rfq={selectedRfq ?? null} + onAttachmentsUpdated={handleAttachmentsUpdated} + /> + </div> + ) +}
\ No newline at end of file diff --git a/lib/rfqs-ship/table/update-rfq-sheet.tsx b/lib/rfqs-ship/table/update-rfq-sheet.tsx new file mode 100644 index 00000000..22ca2c37 --- /dev/null +++ b/lib/rfqs-ship/table/update-rfq-sheet.tsx @@ -0,0 +1,406 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { useSession } from "next-auth/react" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Input } from "@/components/ui/input" + +import { Rfq, RfqWithItemCount } from "@/db/schema/rfq" +import { RfqType, updateRfqSchema, type UpdateRfqSchema } from "../validations" +import { modifyRfq, getBudgetaryRfqs } from "../service" +import { ProjectSelector } from "@/components/ProjectSelector" +import { type Project } from "../service" +import { ParentRfqSelector } from "./ParentRfqSelector" + +interface UpdateRfqSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + rfq: RfqWithItemCount | null +} + +// 부모 RFQ 정보 타입 정의 +interface ParentRfq { + id: number; + rfqCode: string; + description: string | null; + rfqType: RfqType; + projectId: number | null; + projectCode: string | null; + projectName: string | null; +} + +export function UpdateRfqSheet({ rfq, ...props }: UpdateRfqSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const { data: session } = useSession() + const userId = Number(session?.user?.id || 1) + const [selectedParentRfq, setSelectedParentRfq] = React.useState<ParentRfq | null>(null) + + // RFQ의 타입 가져오기 + const rfqType = rfq?.rfqType || RfqType.PURCHASE; + + // 초기 부모 RFQ ID 가져오기 + const initialParentRfqId = rfq?.parentRfqId; + + // 현재 RFQ 타입에 따라 선택 가능한 부모 RFQ 타입들 결정 + const getParentRfqTypes = (): RfqType[] => { + switch(rfqType) { + case RfqType.PURCHASE: + // PURCHASE는 BUDGETARY와 PURCHASE_BUDGETARY를 부모로 가질 수 있음 + return [RfqType.BUDGETARY, RfqType.PURCHASE_BUDGETARY]; + case RfqType.PURCHASE_BUDGETARY: + // PURCHASE_BUDGETARY는 BUDGETARY만 부모로 가질 수 있음 + return [RfqType.BUDGETARY]; + default: + return []; + } + }; + + // 부모 RFQ 타입들 + const parentRfqTypes = getParentRfqTypes(); + + // 부모 RFQ를 보여줄지 결정 + const shouldShowParentRfqSelector = rfqType === RfqType.PURCHASE || rfqType === RfqType.PURCHASE_BUDGETARY; + + // 타입에 따른 타이틀 생성 + const getTypeTitle = () => { + switch(rfqType) { + case RfqType.PURCHASE: + return "Purchase RFQ"; + case RfqType.BUDGETARY: + return "Budgetary RFQ"; + case RfqType.PURCHASE_BUDGETARY: + return "Purchase Budgetary RFQ"; + default: + return "RFQ"; + } + }; + + // 타입 설명 가져오기 + const getTypeDescription = () => { + switch(rfqType) { + case RfqType.PURCHASE: + return "실제 구매 발주 전에 가격을 요청"; + case RfqType.BUDGETARY: + return "기술영업 단계에서 입찰가 산정을 위한 견적 요청"; + case RfqType.PURCHASE_BUDGETARY: + return "프로젝트 수주 후, 공식 입찰 전 예산 책정을 위한 가격 요청"; + default: + return ""; + } + }; + + // 부모 RFQ 선택기 레이블 및 설명 가져오기 + const getParentRfqSelectorLabel = () => { + if (rfqType === RfqType.PURCHASE) { + return "부모 RFQ (BUDGETARY/PURCHASE_BUDGETARY)"; + } else if (rfqType === RfqType.PURCHASE_BUDGETARY) { + return "부모 RFQ (BUDGETARY)"; + } + return "부모 RFQ"; + }; + + const getParentRfqDescription = () => { + if (rfqType === RfqType.PURCHASE) { + return "BUDGETARY 또는 PURCHASE_BUDGETARY 타입의 RFQ를 부모로 선택할 수 있습니다."; + } else if (rfqType === RfqType.PURCHASE_BUDGETARY) { + return "BUDGETARY 타입의 RFQ만 부모로 선택할 수 있습니다."; + } + return ""; + }; + + // 초기 부모 RFQ 로드 + React.useEffect(() => { + if (initialParentRfqId && shouldShowParentRfqSelector) { + const loadInitialParentRfq = async () => { + try { + const result = await getBudgetaryRfqs({ + rfqId: initialParentRfqId + }); + + if ('rfqs' in result && result.rfqs && result.rfqs.length > 0) { + setSelectedParentRfq(result.rfqs[0] as unknown as ParentRfq); + } + } catch (error) { + console.error("부모 RFQ 로드 오류:", error); + } + }; + + loadInitialParentRfq(); + } + }, [initialParentRfqId, shouldShowParentRfqSelector]); + + // RHF setup + const form = useForm<UpdateRfqSchema>({ + resolver: zodResolver(updateRfqSchema), + defaultValues: { + id: rfq?.rfqId ?? 0, // PK + rfqCode: rfq?.rfqCode ?? "", + description: rfq?.description ?? "", + projectId: rfq?.projectId, // 프로젝트 ID + parentRfqId: rfq?.parentRfqId, // 부모 RFQ ID + dueDate: rfq?.dueDate ?? undefined, // null을 undefined로 변환 + status: rfq?.status ?? "DRAFT", + createdBy: rfq?.createdBy ?? userId, + }, + }); + + // 프로젝트 선택 처리 + const handleProjectSelect = (project: Project | null) => { + if (project === null) { + return; + } + form.setValue("projectId", project.id); + }; + + // 부모 RFQ 선택 처리 + const handleParentRfqSelect = (rfq: ParentRfq | null) => { + setSelectedParentRfq(rfq); + form.setValue("parentRfqId", rfq?.id); + }; + + async function onSubmit(input: UpdateRfqSchema) { + startUpdateTransition(async () => { + if (!rfq) return + + const { error } = await modifyRfq({ + ...input, + rfqType: rfqType as RfqType, + + }) + + if (error) { + toast.error(error) + return + } + + form.reset() + props.onOpenChange?.(false) // close the sheet + toast.success("RFQ updated!") + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>Update {getTypeTitle()}</SheetTitle> + <SheetDescription> + Update the {getTypeTitle()} details and save the changes + <div className="mt-1 text-xs text-muted-foreground"> + {getTypeDescription()} + </div> + </SheetDescription> + </SheetHeader> + + {/* RHF Form */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + + {/* Hidden or code-based id field */} + <FormField + control={form.control} + name="id" + render={({ field }) => ( + <input type="hidden" {...field} /> + )} + /> + + {/* Hidden rfqType field */} + {/* <FormField + control={form.control} + name="rfqType" + render={({ field }) => ( + <input type="hidden" {...field} /> + )} + /> */} + + {/* Project Selector - 재사용 컴포넌트 사용 */} + <FormField + control={form.control} + name="projectId" + render={({ field }) => ( + <FormItem> + <FormLabel>Project</FormLabel> + <FormControl> + <ProjectSelector + selectedProjectId={field.value} + onProjectSelect={handleProjectSelect} + placeholder="프로젝트 선택..." + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Parent RFQ Selector - PURCHASE 또는 PURCHASE_BUDGETARY 타입일 때만 표시 */} + {shouldShowParentRfqSelector && ( + <FormField + control={form.control} + name="parentRfqId" + render={({ field }) => ( + <FormItem> + <FormLabel>{getParentRfqSelectorLabel()}</FormLabel> + <FormControl> + <ParentRfqSelector + selectedRfqId={field.value as number | undefined} + onRfqSelect={handleParentRfqSelect} + rfqType={rfqType} + parentRfqTypes={parentRfqTypes} + placeholder={ + rfqType === RfqType.PURCHASE + ? "BUDGETARY 또는 PURCHASE_BUDGETARY RFQ 선택..." + : "BUDGETARY RFQ 선택..." + } + /> + </FormControl> + <div className="text-xs text-muted-foreground mt-1"> + {getParentRfqDescription()} + </div> + <FormMessage /> + </FormItem> + )} + /> + )} + + {/* rfqCode */} + <FormField + control={form.control} + name="rfqCode" + render={({ field }) => ( + <FormItem> + <FormLabel>RFQ Code</FormLabel> + <FormControl> + <Input placeholder="e.g. RFQ-2025-001" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* description */} + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>Description</FormLabel> + <FormControl> + <Input placeholder="Description" {...field} value={field.value || ""} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* dueDate (type="date") */} + <FormField + control={form.control} + name="dueDate" + render={({ field }) => ( + <FormItem> + <FormLabel>Due Date</FormLabel> + <FormControl> + <Input + type="date" + // convert Date -> yyyy-mm-dd + value={field.value ? field.value.toISOString().slice(0, 10) : ""} + onChange={(e) => { + const val = e.target.value + field.onChange(val ? new Date(val + "T00:00:00") : undefined) + }} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* status (Select) */} + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>Status</FormLabel> + <FormControl> + <Select + onValueChange={field.onChange} + value={field.value ?? "DRAFT"} + > + <SelectTrigger className="capitalize"> + <SelectValue placeholder="Select status" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"].map((item) => ( + <SelectItem key={item} value={item} className="capitalize"> + {item} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* createdBy (hidden or read-only) */} + <FormField + control={form.control} + name="createdBy" + render={({ field }) => ( + <input type="hidden" {...field} /> + )} + /> + + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + Cancel + </Button> + </SheetClose> + <Button disabled={isUpdatePending}> + {isUpdatePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/rfqs-ship/validations.ts b/lib/rfqs-ship/validations.ts new file mode 100644 index 00000000..59e9e362 --- /dev/null +++ b/lib/rfqs-ship/validations.ts @@ -0,0 +1,297 @@ +import { createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum,parseAsBoolean +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { Rfq, rfqs, RfqsView, VendorCbeView, VendorResponseCBEView, VendorRfqViewBase, VendorTbeView } from "@/db/schema/rfq"; +import { Vendor, vendors } from "@/db/schema/vendors"; + +export const RfqType = { + PURCHASE_BUDGETARY: "PURCHASE_BUDGETARY", + PURCHASE: "PURCHASE", + BUDGETARY: "c" +} as const; + +export type RfqType = typeof RfqType[keyof typeof RfqType]; + +// ======================= +// 1) SearchParams (목록 필터링/정렬) +// ======================= +export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<RfqsView>().withDefault([ + { id: "createdAt", desc: true }, + ]), + + // 간단 검색 필드 + rfqCode: parseAsString.withDefault(""), + projectCode: parseAsString.withDefault(""), + projectName: parseAsString.withDefault(""), + dueDate: parseAsString.withDefault(""), + + // 상태 - 여러 개일 수 있다고 가정 + status: parseAsArrayOf(z.enum(rfqs.status.enumValues)).withDefault([]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY", "PURCHASE_BUDGETARY"]).withDefault("PURCHASE"), + +}); + +export type GetRfqsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>; + + +export const searchParamsMatchedVCache = createSearchParamsCache({ + // 1) 공통 플래그 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // 2) 페이지네이션 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 3) 정렬 (Rfq 테이블) + // getSortingStateParser<Rfq>() → Rfq 테이블의 컬럼명에 맞춘 유효성 검사 + sort: getSortingStateParser<VendorRfqViewBase>().withDefault([ + { id: "rfqVendorUpdated", desc: true }, + ]), + + // 4) 간단 검색 필드 + vendorName: parseAsString.withDefault(""), + vendorCode: parseAsString.withDefault(""), + country: parseAsString.withDefault(""), + email: parseAsString.withDefault(""), + website: parseAsString.withDefault(""), + + // 5) 상태 (배열) - Rfq["status"]는 "DRAFT"|"PUBLISHED"|"EVALUATION"|"AWARDED" + // rfqs.status.enumValues 로 가져온 문자열 배열을 z.enum([...])로 처리 + vendorStatus: parseAsArrayOf(z.enum(vendors.status.enumValues)).withDefault([]), + + // 6) 고급 필터 (nuqs - filterColumns) + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 7) 글로벌 검색어 + search: parseAsString.withDefault(""), +}) +export type GetMatchedVendorsSchema = Awaited<ReturnType<typeof searchParamsMatchedVCache.parse>>; + +export const searchParamsTBECache = createSearchParamsCache({ + // 1) 공통 플래그 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // 2) 페이지네이션 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 3) 정렬 (Rfq 테이블) + // getSortingStateParser<Rfq>() → Rfq 테이블의 컬럼명에 맞춘 유효성 검사 + sort: getSortingStateParser<VendorTbeView>().withDefault([ + { id: "tbeUpdated", desc: true }, + ]), + + // 4) 간단 검색 필드 + vendorName: parseAsString.withDefault(""), + vendorCode: parseAsString.withDefault(""), + country: parseAsString.withDefault(""), + email: parseAsString.withDefault(""), + website: parseAsString.withDefault(""), + + tbeResult: parseAsString.withDefault(""), + tbeNote: parseAsString.withDefault(""), + tbeUpdated: parseAsString.withDefault(""), + rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY", "PURCHASE_BUDGETARY"]).withDefault("PURCHASE"), + + // 5) 상태 (배열) - Rfq["status"]는 "DRAFT"|"PUBLISHED"|"EVALUATION"|"AWARDED" + // rfqs.status.enumValues 로 가져온 문자열 배열을 z.enum([...])로 처리 + vendorStatus: parseAsArrayOf(z.enum(vendors.status.enumValues)).withDefault([]), + + // 6) 고급 필터 (nuqs - filterColumns) + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 7) 글로벌 검색어 + search: parseAsString.withDefault(""), +}) +export type GetTBESchema = Awaited<ReturnType<typeof searchParamsTBECache.parse>>; + +// ======================= +// 2) Create RFQ Schema +// ======================= +export const createRfqSchema = z.object({ + rfqCode: z.string().min(3, "RFQ 코드는 최소 3글자 이상이어야 합니다"), + description: z.string().optional(), + projectId: z.number().nullable().optional(), // 프로젝트 ID (선택적) + bidProjectId: z.number().nullable().optional(), // 프로젝트 ID (선택적) + parentRfqId: z.number().nullable().optional(), // 부모 RFQ ID (선택적) + dueDate: z.date(), + status: z.enum(["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"]), + rfqType: z.enum([RfqType.PURCHASE, RfqType.BUDGETARY, RfqType.PURCHASE_BUDGETARY]).default(RfqType.PURCHASE), + createdBy: z.number(), +}); + +export type CreateRfqSchema = z.infer<typeof createRfqSchema>; + +export const createRfqItemSchema = z.object({ + rfqId: z.number().int().min(1, "Invalid RFQ ID"), + itemCode: z.string().min(1), + itemName: z.string().optional(), + description: z.string().optional(), + quantity: z.number().min(1).optional(), + uom: z.string().optional(), + rfqType: z.string().default("PURCHASE"), // rfqType 필드 추가 + +}); + +export type CreateRfqItemSchema = z.infer<typeof createRfqItemSchema>; + +// ======================= +// 3) Update RFQ Schema +// (현재 코드엔 updateTaskSchema라고 되어 있는데, +// RFQ 업데이트이므로 'updateRfqSchema'라 명명하는 게 자연스러움) +// ======================= +export const updateRfqSchema = z.object({ + // PK id -> 실제로는 URL params로 받을 수도 있지만, + // 여기서는 body에서 받는다고 가정 + id: z.number().int().min(1, "Invalid ID"), + + // 업데이트 시 대부분 optional + rfqCode: z.string().max(50).optional(), + projectId: z.number().nullable().optional(), // null 값도 허용 + description: z.string().optional(), + parentRfqId: z.number().nullable().optional(), // 부모 RFQ ID (선택적) + dueDate: z.preprocess( + // null이나 빈 문자열을 undefined로 변환 + (val) => (val === null || val === '') ? undefined : val, + z.date().optional() + ), + rfqType: z.enum(["PURCHASE", "BUDGETARY", "PURCHASE_BUDGETARY"]).optional(), + status: z.union([ + z.enum(["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"]), + z.string().refine( + (val) => ["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"].includes(val), + { message: "Invalid status value" } + ) + ]).optional(), + createdBy: z.number().int().min(1).optional(), +}); +export type UpdateRfqSchema = z.infer<typeof updateRfqSchema>; + +export const searchParamsRfqsForVendorsCache = createSearchParamsCache({ + // 1) 공통 플래그 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // 2) 페이지네이션 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 3) 정렬 (rfqs 테이블) + sort: getSortingStateParser<Rfq>().withDefault([ + { id: "createdAt", desc: true }, + ]), + + // 4) 간단 검색 필드 (예: rfqCode, projectName, projectCode 등) + rfqCode: parseAsString.withDefault(""), + projectCode: parseAsString.withDefault(""), + projectName: parseAsString.withDefault(""), + + // 5) 상태 배열 (rfqs.status.enumValues: "DRAFT" | "PUBLISHED" | ...) + status: parseAsArrayOf(z.enum(rfqs.status.enumValues)).withDefault([]), + + // 6) 고급 필터 (nuqs filterColumns) + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 7) 글로벌 검색어 + search: parseAsString.withDefault(""), +}) + +/** + * 최종 타입 + * `Awaited<ReturnType<...parse>>` 형태로 + * Next.js 13 서버 액션이나 클라이언트에서 사용 가능 + */ +export type GetRfqsForVendorsSchema = Awaited<ReturnType<typeof searchParamsRfqsForVendorsCache.parse>> + +export const updateRfqVendorSchema = z.object({ + id: z.number().int().min(1, "Invalid ID"), // rfq_vendors.id + status: z.enum(["INVITED","ACCEPTED","DECLINED","REVIEWING", "RESPONDED"]) +}) + +export type UpdateRfqVendorSchema = z.infer<typeof updateRfqVendorSchema> + + +export const searchParamsCBECache = createSearchParamsCache({ + // 1) 공통 플래그 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // 2) 페이지네이션 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 3) 정렬 (VendorResponseCBEView 테이블) + // getSortingStateParser<VendorResponseCBEView>() → CBE 테이블의 컬럼명에 맞춤 + sort: getSortingStateParser<VendorResponseCBEView>().withDefault([ + { id: "totalPrice", desc: true }, + ]), + + // 4) 간단 검색 필드 - 기본 정보 + vendorName: parseAsString.withDefault(""), + vendorCode: parseAsString.withDefault(""), + country: parseAsString.withDefault(""), + email: parseAsString.withDefault(""), + website: parseAsString.withDefault(""), + + // CBE 관련 필드 + commercialResponseId: parseAsString.withDefault(""), + totalPrice: parseAsString.withDefault(""), + currency: parseAsString.withDefault(""), + paymentTerms: parseAsString.withDefault(""), + incoterms: parseAsString.withDefault(""), + deliveryPeriod: parseAsString.withDefault(""), + warrantyPeriod: parseAsString.withDefault(""), + validityPeriod: parseAsString.withDefault(""), + + // RFQ 관련 필드 + rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY", "PURCHASE_BUDGETARY"]).withDefault("PURCHASE"), + + // 응답 상태 + responseStatus: parseAsStringEnum(["INVITED", "ACCEPTED", "DECLINED", "REVIEWING", "RESPONDED"]).withDefault("REVIEWING"), + + // 5) 상태 (배열) - vendor 상태 + vendorStatus: parseAsArrayOf(z.enum(vendors.status.enumValues)).withDefault([]), + + // 6) 고급 필터 (nuqs - filterColumns) + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 7) 글로벌 검색어 + search: parseAsString.withDefault(""), + + // 8) 첨부파일 관련 필터 + hasAttachments: parseAsBoolean.withDefault(false), + + // 9) 날짜 범위 필터 + respondedAtRange: parseAsString.withDefault(""), + commercialUpdatedAtRange: parseAsString.withDefault(""), +}) + +export type GetCBESchema = Awaited<ReturnType<typeof searchParamsCBECache.parse>>; + + +export const createCbeEvaluationSchema = z.object({ + paymentTerms: z.string().min(1, "결제 조건을 입력하세요"), + incoterms: z.string().min(1, "Incoterms를 입력하세요"), + deliverySchedule: z.string().min(1, "배송 일정을 입력하세요"), + notes: z.string().optional(), +}) + +// 타입 추출 +export type CreateCbeEvaluationSchema = z.infer<typeof createCbeEvaluationSchema>
\ No newline at end of file diff --git a/lib/rfqs-ship/vendor-table/add-vendor-dialog.tsx b/lib/rfqs-ship/vendor-table/add-vendor-dialog.tsx new file mode 100644 index 00000000..8ec5b9f4 --- /dev/null +++ b/lib/rfqs-ship/vendor-table/add-vendor-dialog.tsx @@ -0,0 +1,37 @@ +"use client" + +import * as React from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { VendorsListTable } from "./vendor-list/vendor-list-table" + +interface VendorsListTableProps { + rfqId: number // so we know which RFQ to insert into + } + + +/** + * A dialog that contains a client-side table or infinite scroll + * for "all vendors," allowing the user to select vendors and add them to the RFQ. + */ +export function AddVendorDialog({ rfqId }: VendorsListTableProps) { + const [open, setOpen] = React.useState(false) + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button size="sm"> + Add Vendor + </Button> + </DialogTrigger> + <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1600, height:680}}> + <DialogHeader> + <DialogTitle>Add Vendor to RFQ</DialogTitle> + </DialogHeader> + + <VendorsListTable rfqId={rfqId}/> + + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/rfqs-ship/vendor-table/comments-sheet.tsx b/lib/rfqs-ship/vendor-table/comments-sheet.tsx new file mode 100644 index 00000000..441fdcf1 --- /dev/null +++ b/lib/rfqs-ship/vendor-table/comments-sheet.tsx @@ -0,0 +1,318 @@ +"use client" + +import * as React from "react" +import { useForm, useFieldArray } from "react-hook-form" +import { z } from "zod" +import { zodResolver } from "@hookform/resolvers/zod" +import { Download, X, Loader2 } from "lucide-react" +import prettyBytes from "pretty-bytes" +import { toast } from "sonner" + +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Textarea } from "@/components/ui/textarea" +import { + Dropzone, + DropzoneZone, + DropzoneUploadIcon, + DropzoneTitle, + DropzoneDescription, + DropzoneInput, +} from "@/components/ui/dropzone" +import { + Table, + TableHeader, + TableRow, + TableHead, + TableBody, + TableCell, +} from "@/components/ui/table" + +import { createRfqCommentWithAttachments } from "../service" +import { formatDate } from "@/lib/utils" + + +export interface MatchedVendorComment { + id: number + commentText: string + commentedBy?: number + commentedByEmail?: string + createdAt?: Date + attachments?: { + id: number + fileName: string + filePath: string + }[] +} + +// 1) props 정의 +interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { + initialComments?: MatchedVendorComment[] + currentUserId: number + rfqId: number + vendorId: number + onCommentsUpdated?: (comments: MatchedVendorComment[]) => void + isLoading?: boolean // New prop +} + +// 2) 폼 스키마 +const commentFormSchema = z.object({ + commentText: z.string().min(1, "댓글을 입력하세요."), + newFiles: z.array(z.any()).optional(), // File[] +}) +type CommentFormValues = z.infer<typeof commentFormSchema> + +const MAX_FILE_SIZE = 30e6 // 30MB + +export function CommentSheet({ + rfqId, + vendorId, + initialComments = [], + currentUserId, + onCommentsUpdated, + isLoading = false, // Default to false + ...props +}: CommentSheetProps) { + + const [comments, setComments] = React.useState<MatchedVendorComment[]>(initialComments) + const [isPending, startTransition] = React.useTransition() + + React.useEffect(() => { + setComments(initialComments) + }, [initialComments]) + + const form = useForm<CommentFormValues>({ + resolver: zodResolver(commentFormSchema), + defaultValues: { + commentText: "", + newFiles: [], + }, + }) + + const { fields: newFileFields, append, remove } = useFieldArray({ + control: form.control, + name: "newFiles", + }) + + // (A) 기존 코멘트 렌더링 + function renderExistingComments() { + + if (isLoading) { + return ( + <div className="flex justify-center items-center h-32"> + <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> + <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span> + </div> + ) + } + + if (comments.length === 0) { + return <p className="text-sm text-muted-foreground">No comments yet</p> + } + return ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-1/2">Comment</TableHead> + <TableHead>Attachments</TableHead> + <TableHead>Created At</TableHead> + <TableHead>Created By</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {comments.map((c) => ( + <TableRow key={c.id}> + <TableCell>{c.commentText}</TableCell> + <TableCell> + {!c.attachments?.length && ( + <span className="text-sm text-muted-foreground">No files</span> + )} + {c.attachments?.length && ( + <div className="flex flex-col gap-1"> + {c.attachments.map((att) => ( + <div key={att.id} className="flex items-center gap-2"> + <a + href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`} + download + target="_blank" + rel="noreferrer" + className="inline-flex items-center gap-1 text-blue-600 underline" + > + <Download className="h-4 w-4" /> + {att.fileName} + </a> + </div> + ))} + </div> + )} + </TableCell> + <TableCell> {c.createdAt ? formatDate(c.createdAt) : "-"}</TableCell> + <TableCell>{c.commentedByEmail ?? "-"}</TableCell> + </TableRow> + ))} + </TableBody> + </Table> + ) + } + + // (B) 파일 드롭 + function handleDropAccepted(files: File[]) { + append(files) + } + + // (C) Submit + async function onSubmit(data: CommentFormValues) { + if (!rfqId) return + startTransition(async () => { + try { + const res = await createRfqCommentWithAttachments({ + rfqId, + vendorId, + commentText: data.commentText, + commentedBy: currentUserId, + evaluationId: null, + cbeId: null, + files: data.newFiles, + }) + + if (!res.ok) { + throw new Error("Failed to create comment") + } + + toast.success("Comment created") + + // 임시로 새 코멘트 추가 + const newComment: MatchedVendorComment = { + id: res.commentId, // 서버 응답 + commentText: data.commentText, + commentedBy: currentUserId, + createdAt: res.createdAt, + attachments: + data.newFiles?.map((f) => ({ + id: Math.floor(Math.random() * 1e6), + fileName: f.name, + filePath: "/uploads/" + f.name, + })) || [], + } + setComments((prev) => [...prev, newComment]) + onCommentsUpdated?.([...comments, newComment]) + + form.reset() + } catch (err: any) { + console.error(err) + toast.error("Error: " + err.message) + } + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-lg"> + <SheetHeader className="text-left"> + <SheetTitle>Comments</SheetTitle> + <SheetDescription> + 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다. + </SheetDescription> + </SheetHeader> + + <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + <FormField + control={form.control} + name="commentText" + render={({ field }) => ( + <FormItem> + <FormLabel>New Comment</FormLabel> + <FormControl> + <Textarea placeholder="Enter your comment..." {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <Dropzone + maxSize={MAX_FILE_SIZE} + onDropAccepted={handleDropAccepted} + onDropRejected={(rej) => { + toast.error("File rejected: " + (rej[0]?.file?.name || "")) + }} + > + {({ maxSize }) => ( + <DropzoneZone className="flex justify-center"> + <DropzoneInput /> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>Drop to attach files</DropzoneTitle> + <DropzoneDescription> + Max size: {prettyBytes(maxSize || 0)} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + )} + </Dropzone> + + {newFileFields.length > 0 && ( + <div className="flex flex-col gap-2"> + {newFileFields.map((field, idx) => { + const file = form.getValues(`newFiles.${idx}`) + if (!file) return null + return ( + <div + key={field.id} + className="flex items-center justify-between border rounded p-2" + > + <span className="text-sm"> + {file.name} ({prettyBytes(file.size)}) + </span> + <Button + variant="ghost" + size="icon" + type="button" + onClick={() => remove(idx)} + > + <X className="h-4 w-4" /> + </Button> + </div> + ) + })} + </div> + )} + + <SheetFooter className="gap-2 pt-4"> + <SheetClose asChild> + <Button type="button" variant="outline"> + Cancel + </Button> + </SheetClose> + <Button disabled={isPending}> + {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/rfqs-ship/vendor-table/feature-flags-provider.tsx b/lib/rfqs-ship/vendor-table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/rfqs-ship/vendor-table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/rfqs-ship/vendor-table/invite-vendors-dialog.tsx b/lib/rfqs-ship/vendor-table/invite-vendors-dialog.tsx new file mode 100644 index 00000000..cdbfaa0f --- /dev/null +++ b/lib/rfqs-ship/vendor-table/invite-vendors-dialog.tsx @@ -0,0 +1,497 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { type Row } from "@tanstack/react-table" +import { Loader, Send, AlertTriangle, User } from "lucide-react" +import { toast } from "sonner" +import { z } from "zod" + +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 { Alert, AlertDescription } from "@/components/ui/alert" + +import { MatchedVendorRow } from "@/config/vendorRfbColumnsConfig" +import { inviteVendors, createCbeEvaluation } from "../service" +import { RfqType } from "@/lib/rfqs-ship/validations" +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Badge } from "@/components/ui/badge" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" + +// CBE 폼 스키마 정의 +const formSchema = z.object({ + paymentTerms: z.string().min(1, "결제 조건을 입력하세요"), + incoterms: z.string().min(1, "Incoterms를 입력하세요"), + deliverySchedule: z.string().min(1, "배송 일정을 입력하세요"), + notes: z.string().optional(), +}) + +type FormValues = z.infer<typeof formSchema> + +interface InviteVendorsDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + vendors: Row<MatchedVendorRow>["original"][] | MatchedVendorRow[] + rfqId: number + rfqType: RfqType + showTrigger?: boolean + directCbe?: boolean + currentUser?: { + id: string + name?: string | null + email?: string | null + image?: string | null + companyId?: number | null + domain?: string | null + } + onSuccess?: () => void + children?: React.ReactNode +} + +export function InviteVendorsDialog({ + vendors, + rfqId, + rfqType, + showTrigger = true, + directCbe = false, + currentUser, + onSuccess, + children, + ...props +}: InviteVendorsDialogProps) { + const [isInvitePending, startInviteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + const [files, setFiles] = React.useState<FileList | null>(null) + + // CBE 모드일 때 폼 상태 관리 + const form = useForm<FormValues>({ + resolver: zodResolver(formSchema), + defaultValues: { + paymentTerms: "", + incoterms: "", + deliverySchedule: "", + notes: "", + }, + mode: "onChange", + }) + + // 폼 상태 감시 + const { formState } = form + const isValid = formState.isValid && + !!form.getValues("paymentTerms") && + !!form.getValues("incoterms") && + !!form.getValues("deliverySchedule") + + // 기존 초대 함수 + function onInvite() { + startInviteTransition(async () => { + const { error } = await inviteVendors({ + rfqId, + vendorIds: vendors.map((vendor) => Number(vendor.id)), + rfqType + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("Vendor invited") + onSuccess?.() + }) + } + + // CBE 요청 함수 + async function onCbeRequest(data: FormValues) { + try { + startInviteTransition(async () => { + // 기본 FormData 생성 + const formData = new FormData() + + // rfqId 추가 + formData.append("rfqId", String(rfqId)) + + // 폼 데이터 추가 + Object.entries(data).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + formData.append(key, String(value)) + } + }) + + // 현재 사용자 ID 추가 + if (currentUser?.id) { + formData.append("evaluatedBy", currentUser.id) + } + + // 협력업체 ID만 추가 + vendors.forEach((vendor) => { + formData.append("vendorIds[]", String(vendor.id)) + }) + + // 파일 추가 (있는 경우에만) + if (files && files.length > 0) { + for (let i = 0; i < files.length; i++) { + formData.append("files", files[i]) + } + } + + // 서버 액션 호출 + const response = await createCbeEvaluation(formData) + + if (response.error) { + toast.error(response.error) + return + } + + // 성공 처리 + toast.success(`${vendors.length}개 협력업체에 CBE 요청이 성공적으로 전송되었습니다!`) + form.reset() + setFiles(null) + props.onOpenChange?.(false) + onSuccess?.() + }) + } catch (error) { + console.error(error) + toast.error("CBE 요청 생성 중 오류가 발생했습니다.") + } + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + setFiles(null) + } + props.onOpenChange?.(nextOpen) + } + + // 필수 필드 라벨에 추가할 요소 + const RequiredLabel = ( + <span className="text-destructive ml-1 font-medium">*</span> + ) + + // CBE 모드일 때 폼 컨텐츠 + const cbeFormContent = ( + <Form {...form}> + <form onSubmit={form.handleSubmit(onCbeRequest)} className="space-y-4"> + {/* 선택된 협력업체 정보 표시 */} + <div className="space-y-2"> + <FormLabel>선택된 협력업체 ({vendors.length})</FormLabel> + <ScrollArea className="h-20 border rounded-md p-2"> + <div className="flex flex-wrap gap-2"> + {vendors.map((vendor, index) => ( + <Badge key={index} variant="secondary" className="py-1"> + {vendor.vendorName || `협력업체 #${vendor.vendorCode}`} + </Badge> + ))} + </div> + </ScrollArea> + <FormDescription> + 선택된 모든 협력업체의 등록된 연락처에게 CBE 요청이 전송됩니다. + </FormDescription> + </div> + + {/* 작성자 정보 (읽기 전용) */} + {currentUser && ( + <div className="border rounded-md p-3 space-y-2"> + <FormLabel>작성자</FormLabel> + <div className="flex items-center gap-3"> + {currentUser.image ? ( + <Avatar className="h-8 w-8"> + <AvatarImage src={currentUser.image} alt={currentUser.name || ""} /> + <AvatarFallback> + {currentUser.name?.charAt(0) || <User className="h-4 w-4" />} + </AvatarFallback> + </Avatar> + ) : ( + <Avatar className="h-8 w-8"> + <AvatarFallback> + {currentUser.name?.charAt(0) || <User className="h-4 w-4" />} + </AvatarFallback> + </Avatar> + )} + <div> + <p className="text-sm font-medium">{currentUser.name || "Unknown User"}</p> + <p className="text-xs text-muted-foreground">{currentUser.email || ""}</p> + </div> + </div> + </div> + )} + + {/* 결제 조건 - 필수 필드 */} + <FormField + control={form.control} + name="paymentTerms" + render={({ field }) => ( + <FormItem> + <FormLabel> + 결제 조건{RequiredLabel} + </FormLabel> + <FormControl> + <Input {...field} placeholder="예: Net 30" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Incoterms - 필수 필드 */} + <FormField + control={form.control} + name="incoterms" + render={({ field }) => ( + <FormItem> + <FormLabel> + Incoterms{RequiredLabel} + </FormLabel> + <FormControl> + <Input {...field} placeholder="예: FOB, CIF" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 배송 일정 - 필수 필드 */} + <FormField + control={form.control} + name="deliverySchedule" + render={({ field }) => ( + <FormItem> + <FormLabel> + 배송 일정{RequiredLabel} + </FormLabel> + <FormControl> + <Input {...field} placeholder="예: 계약 후 4주 이내" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 추가 참고사항 - 선택 필드 */} + <FormField + control={form.control} + name="notes" + render={({ field }) => ( + <FormItem> + <FormLabel>추가 참고사항</FormLabel> + <FormControl> + <Textarea + {...field} + placeholder="필요한 경우 추가 정보를 입력하세요" + className="min-h-[100px]" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 파일 첨부 */} + <div className="space-y-2"> + <FormLabel>첨부파일</FormLabel> + <Input + type="file" + multiple + onChange={(e) => setFiles(e.target.files)} + className="cursor-pointer" + /> + <FormDescription> + CBE 요청에 첨부할 문서가 있다면 선택하세요. + </FormDescription> + </div> + + <DialogFooter className="gap-2 sm:space-x-0 mt-6"> + <DialogClose asChild> + <Button variant="outline">취소</Button> + </DialogClose> + <Button + type="submit" + variant="default" + disabled={!isValid || isInvitePending} + > + {isInvitePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + CBE 요청 발송하기 + </Button> + </DialogFooter> + </form> + </Form> + ) + + // 기본 다이얼로그 컨텐츠 + const defaultContent = ( + <> + <DialogHeader> + <DialogTitle>벤더 초대 확인</DialogTitle> + <DialogDescription> + 선택한 <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? "개 벤더" : "개 벤더"}에게 초대를 발송합니다. + </DialogDescription> + </DialogHeader> + + {/* 편집 제한 경고 메시지 */} + <Alert variant="destructive" className="mt-4"> + <AlertTriangle className="h-4 w-4" /> + <AlertDescription className="font-medium"> + 한 업체라도 초대를 하고 나면 아이템 편집과 RFQ 문서 첨부 편집은 불가능합니다. + </AlertDescription> + </Alert> + + <DialogFooter className="gap-2 sm:space-x-0 mt-6"> + <DialogClose asChild> + <Button variant="outline">취소</Button> + </DialogClose> + <Button + aria-label="Invite selected rows" + variant="destructive" + onClick={onInvite} + disabled={isInvitePending} + > + {isInvitePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 초대하기 + </Button> + </DialogFooter> + </> + ) + + if (isDesktop) { + return ( + <Dialog {...props} onOpenChange={handleDialogOpenChange}> + {showTrigger && children ? ( + <DialogTrigger asChild> + {children} + </DialogTrigger> + ) : showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Send className="mr-2 size-4" aria-hidden="true" /> + {directCbe ? "CBE 요청" : "초대하기"} ({vendors.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent className={directCbe ? "sm:max-w-xl" : ""}> + {directCbe ? ( + // CBE 직접 요청 모드 + <> + <DialogHeader> + <DialogTitle>CBE 요청</DialogTitle> + <DialogDescription> + 선택한 협력업체({vendors.length}개)에게 CBE 요청을 발송합니다. + </DialogDescription> + </DialogHeader> + {cbeFormContent} + </> + ) : ( + // 기존 초대 모드 + defaultContent + )} + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props} onOpenChange={handleDialogOpenChange}> + {showTrigger && children ? ( + <DrawerTrigger asChild> + {children} + </DrawerTrigger> + ) : showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Send className="mr-2 size-4" aria-hidden="true" /> + {directCbe ? "CBE 요청" : "초대하기"} ({vendors.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + {directCbe ? ( + // CBE 직접 요청 모드 (모바일) + <> + <DrawerHeader> + <DrawerTitle>CBE 요청</DrawerTitle> + <DrawerDescription> + 선택한 협력업체({vendors.length}개)에게 CBE 요청을 발송합니다. + </DrawerDescription> + </DrawerHeader> + <div className="px-4 pb-4"> + {cbeFormContent} + </div> + </> + ) : ( + // 기존 초대 모드 (모바일) + <> + <DrawerHeader> + <DrawerTitle>벤더 초대 확인</DrawerTitle> + <DrawerDescription> + 선택한 <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? "개 벤더" : "개 벤더"}에게 초대를 발송합니다. + </DrawerDescription> + </DrawerHeader> + + {/* 편집 제한 경고 메시지 (모바일용) */} + <div className="px-4"> + <Alert variant="destructive"> + <AlertTriangle className="h-4 w-4" /> + <AlertDescription className="font-medium"> + 한 업체라도 초대를 하고 나면 아이템 편집과 RFQ 문서 첨부 편집은 불가능합니다. + </AlertDescription> + </Alert> + </div> + + <DrawerFooter className="gap-2 sm:space-x-0 mt-4"> + <DrawerClose asChild> + <Button variant="outline">취소</Button> + </DrawerClose> + <Button + aria-label="초대하기" + variant="destructive" + onClick={onInvite} + disabled={isInvitePending} + > + {isInvitePending && ( + <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/rfqs-ship/vendor-table/vendor-list/vendor-list-table-column.tsx b/lib/rfqs-ship/vendor-table/vendor-list/vendor-list-table-column.tsx new file mode 100644 index 00000000..bfcbe75b --- /dev/null +++ b/lib/rfqs-ship/vendor-table/vendor-list/vendor-list-table-column.tsx @@ -0,0 +1,154 @@ +"use client" +// Because columns rely on React state/hooks for row actions + +import * as React from "react" +import { ColumnDef, Row } from "@tanstack/react-table" +import { VendorData } from "./vendor-list-table" +import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header" +import { formatDate } from "@/lib/utils" +import { Checkbox } from "@/components/ui/checkbox" + +export interface DataTableRowAction<TData> { + row: Row<TData> + type: "open" | "update" | "delete" +} + +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 [ + // MULTIPLE SELECT COLUMN + { + id: "select", + enableSorting: false, + enableHiding: false, + size: 40, + // Add checkbox in header for select all functionality + header: ({ table }) => ( + <Checkbox + checked={ + table.getFilteredSelectedRowModel().rows.length > 0 && + table.getFilteredSelectedRowModel().rows.length === table.getFilteredRowModel().rows.length + } + onCheckedChange={(checked) => { + table.toggleAllRowsSelected(!!checked) + + // Update selectedVendorIds based on all rows selection + if (checked) { + const allIds = table.getFilteredRowModel().rows.map(row => row.original.id) + setSelectedVendorIds(allIds) + } else { + setSelectedVendorIds([]) + } + }} + aria-label="Select all" + /> + ), + cell: ({ row }) => { + const isSelected = row.getIsSelected() + + return ( + <Checkbox + checked={isSelected} + onCheckedChange={(checked) => { + row.toggleSelected(!!checked) + + // Update the selectedVendorIds state by adding or removing this ID + setSelectedVendorIds(prevIds => { + if (checked) { + // Add this ID if it doesn't exist + return prevIds.includes(row.original.id) + ? prevIds + : [...prevIds, row.original.id] + } else { + // Remove this ID + return prevIds.filter(id => id !== row.original.id) + } + }) + }} + aria-label="Select row" + /> + ) + }, + }, + + // Vendor Name + { + accessorKey: "vendorName", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Vendor Name" /> + ), + cell: ({ row }) => row.getValue("vendorName"), + }, + + // Vendor Code + { + accessorKey: "vendorCode", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Vendor Code" /> + ), + cell: ({ row }) => row.getValue("vendorCode"), + }, + + // Status + { + accessorKey: "status", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Status" /> + ), + cell: ({ row }) => row.getValue("status"), + }, + + // Country + { + accessorKey: "country", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Country" /> + ), + cell: ({ row }) => row.getValue("country"), + }, + + // Email + { + accessorKey: "email", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Email" /> + ), + cell: ({ row }) => row.getValue("email"), + }, + + // Phone + { + accessorKey: "phone", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Phone" /> + ), + cell: ({ row }) => row.getValue("phone"), + }, + + // Created At + { + accessorKey: "createdAt", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Created At" /> + ), + cell: ({ cell }) => formatDate(cell.getValue() as Date), + }, + + // Updated At + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Updated At" /> + ), + cell: ({ cell }) => formatDate(cell.getValue() as Date), + }, + ] +}
\ No newline at end of file diff --git a/lib/rfqs-ship/vendor-table/vendor-list/vendor-list-table.tsx b/lib/rfqs-ship/vendor-table/vendor-list/vendor-list-table.tsx new file mode 100644 index 00000000..e34a5052 --- /dev/null +++ b/lib/rfqs-ship/vendor-table/vendor-list/vendor-list-table.tsx @@ -0,0 +1,142 @@ +"use client" + +import * as React from "react" +import { ClientDataTable } from "@/components/client-data-table/data-table" +import { DataTableRowAction, getColumns } from "./vendor-list-table-column" +import { DataTableAdvancedFilterField } from "@/types/table" +import { addItemToVendors, getAllVendors } from "../../service" +import { Loader2, Plus } from "lucide-react" +import { Button } from "@/components/ui/button" +import { useToast } from "@/hooks/use-toast" + +export 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 + createdAt: Date + updatedAt: Date +} + +interface VendorsListTableProps { + rfqId: number +} + +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) + + React.useEffect(() => { + async function loadAllVendors() { + setIsLoading(true) + try { + const allVendors = await getAllVendors() + setVendors(allVendors) + } catch (error) { + console.error("협력업체 목록 로드 오류:", error) + toast({ + title: "Error", + description: "Failed to load vendors", + variant: "destructive", + }) + } finally { + setIsLoading(false) + } + } + loadAllVendors() + }, [toast]) + + const advancedFilterFields: DataTableAdvancedFilterField<VendorData>[] = [] + + async function handleAddVendors() { + if (selectedVendorIds.length === 0) return // Safety check + + setIsSubmitting(true) + try { + // Update to use the multiple vendor service + const result = await addItemToVendors(rfqId, selectedVendorIds) + + if (result.success) { + toast({ + title: "Success", + description: `Added items to ${selectedVendorIds.length} vendors`, + }) + // Reset selection after successful addition + setSelectedVendorIds([]) + } else { + toast({ + title: "Error", + description: result.error || "Failed to add items to vendors", + variant: "destructive", + }) + } + } catch (err) { + console.error("Failed to add vendors:", err) + toast({ + title: "Error", + description: "An unexpected error occurred", + variant: "destructive", + }) + } finally { + setIsSubmitting(false) + } + } + + // If loading, show a flex container that fills the parent and centers the spinner + if (isLoading) { + return ( + <div className="flex h-full w-full items-center justify-center"> + <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> + </div> + ) + } + + // Otherwise, show the table + return ( + <ClientDataTable + data={vendors} + columns={columns} + advancedFilterFields={advancedFilterFields} + > + <div className="flex items-center gap-2"> + <Button + variant="default" + size="sm" + onClick={handleAddVendors} + disabled={selectedVendorIds.length === 0 || isSubmitting} + > + {isSubmitting ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + Adding... + </> + ) : ( + <> + <Plus className="mr-2 h-4 w-4" /> + Add Vendors ({selectedVendorIds.length}) + </> + )} + </Button> + </div> + </ClientDataTable> + ) +}
\ No newline at end of file diff --git a/lib/rfqs-ship/vendor-table/vendors-table-columns.tsx b/lib/rfqs-ship/vendor-table/vendors-table-columns.tsx new file mode 100644 index 00000000..8b2e01cc --- /dev/null +++ b/lib/rfqs-ship/vendor-table/vendors-table-columns.tsx @@ -0,0 +1,255 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis, MessageSquare } from "lucide-react" + +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { useRouter } from "next/navigation" + +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { RfqShipVendorRow, rfqShipColumnsConfig } from "@/config/vendorRfqShipColumnsConfig" + +type NextRouter = ReturnType<typeof useRouter>; + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<RfqShipVendorRow> | null>>; + router: NextRouter; + openCommentSheet: (rfqId: number) => void; +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction, router, openCommentSheet }: GetColumnsProps): ColumnDef<RfqShipVendorRow>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<RfqShipVendorRow> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) 그룹별로 컬럼 구성 + // ---------------------------------------------------------------- + const groupMap: Record<string, ColumnDef<RfqShipVendorRow>[]> = {} + + rfqShipColumnsConfig.forEach((cfg) => { + // 그룹이 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // 컬럼 정의 + const column: ColumnDef<RfqShipVendorRow> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + // commercialResponseStatus는 배지로 표시 + if (cfg.id === "commercialResponseStatus") { + const status = row.getValue(cfg.id) as string | null + if (!status) return null + + // 상태에 따라 배지 색상 지정 + const variant = + status === "SUBMITTED" ? "secondary" : + status === "DRAFT" ? "outline" : + status === "REJECTED" ? "destructive" : "default" + + return ( + <Badge variant={variant}> + {status} + </Badge> + ) + } + + // totalPrice는 통화와 함께 표시 + if (cfg.id === "totalPrice") { + const price = row.getValue(cfg.id) as number | null + const currency = row.getValue("currency") as string | null + + if (!price) return null + + return `${currency || ''} ${price.toLocaleString()}` + } + + // 날짜 포맷팅 + if (cfg.id === "respondedAt") { + const dateVal = cell.getValue() as Date | null + if (!dateVal) return null + return formatDate(dateVal) + } + + // 기본 데이터 표시 + return row.getValue(cfg.id) ?? "" + }, + } + + // 그룹에 추가 + groupMap[groupName].push(column) + }) + + // ---------------------------------------------------------------- + // 3) 코멘트 컬럼 + // ---------------------------------------------------------------- + const commentsColumn: ColumnDef<RfqShipVendorRow> = { + id: "comments", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Comments" /> + ), + cell: ({ row }) => { + const vendor = row.original + const commCount = vendor.comments?.length ?? 0 + + function handleClick() { + // rowAction + openCommentSheet + setRowAction({ row, type: "comments" }) + openCommentSheet(Number(vendor.id) ?? 0) + } + + return ( + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label={ + commCount > 0 ? `View ${commCount} comments` : "No comments" + } + > + <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + {commCount > 0 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" + > + {commCount} + </Badge> + )} + <span className="sr-only"> + {commCount > 0 ? `${commCount} Comments` : "No Comments"} + </span> + </Button> + ) + }, + enableSorting: false, + maxSize: 80 + } + + // ---------------------------------------------------------------- + // 4) 액션 컬럼 + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<RfqShipVendorRow> = { + id: "actions", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Actions" /> + ), + cell: ({ row }) => { + const vendor = row.original + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="ghost" + className="flex h-8 w-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="h-4 w-4" /> + <span className="sr-only">Open menu</span> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-[160px]"> + <DropdownMenuItem + onClick={() => { + // 정보 보기 + router.push(`/vendors/${vendor.vendorId}`) + }} + > + 정보 보기 + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => { + // CBE 요청 + setRowAction({ row, type: "invite" }) + }} + > + CBE 요청 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + } + } + + // ---------------------------------------------------------------- + // 5) 그룹별 컬럼 생성 + // ---------------------------------------------------------------- + const groupedColumns: ColumnDef<RfqShipVendorRow>[] = [] + + // 그룹별로 컬럼 생성 + Object.entries(groupMap).forEach(([groupName, columns]) => { + if (groupName === "_noGroup") { + // 그룹 없는 컬럼은 바로 추가 + groupedColumns.push(...columns) + } else { + // 그룹 있는 컬럼은 그룹으로 묶기 + groupedColumns.push({ + id: groupName, + header: groupName, + columns: columns, + }) + } + }) + + // ---------------------------------------------------------------- + // 6) 최종 컬럼 배열 + // ---------------------------------------------------------------- + return [ + selectColumn, + ...groupedColumns, + commentsColumn, + actionsColumn + ] +}
\ No newline at end of file diff --git a/lib/rfqs-ship/vendor-table/vendors-table-floating-bar.tsx b/lib/rfqs-ship/vendor-table/vendors-table-floating-bar.tsx new file mode 100644 index 00000000..dc3e4cb0 --- /dev/null +++ b/lib/rfqs-ship/vendor-table/vendors-table-floating-bar.tsx @@ -0,0 +1,112 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { + X, +} from "lucide-react" + +import { Button } from "@/components/ui/button" +import { Portal } from "@/components/ui/portal" +import { Separator } from "@/components/ui/separator" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { Kbd } from "@/components/kbd" + +import { ActionConfirmDialog } from "@/components/ui/action-dialog" +import { MatchedVendorRow } from "@/config/vendorRfbColumnsConfig" + +interface VendorsTableFloatingBarProps { + table: Table<MatchedVendorRow> +} + + +export function VendorsTableFloatingBar({ table }: VendorsTableFloatingBarProps) { + const rows = table.getFilteredSelectedRowModel().rows + + // Clear selection on Escape key press + React.useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + table.toggleAllRowsSelected(false) + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [table]) + + + + // 공용 confirm dialog state + const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false) + const [confirmProps, setConfirmProps] = React.useState<{ + title: string + description?: string + onConfirm: () => Promise<void> | void + }>({ + title: "", + description: "", + onConfirm: () => { }, + }) + + + + + + return ( + <Portal > + <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5" style={{ bottom: '1.5rem' }}> + <div className="w-full overflow-x-auto"> + <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow"> + <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1"> + <span className="whitespace-nowrap text-xs"> + {rows.length} selected + </span> + <Separator orientation="vertical" className="ml-2 mr-1" /> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="size-5 hover:border" + onClick={() => table.toggleAllRowsSelected(false)} + > + <X className="size-3.5 shrink-0" aria-hidden="true" /> + </Button> + </TooltipTrigger> + <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900"> + <p className="mr-2">Clear selection</p> + <Kbd abbrTitle="Escape" variant="outline"> + Esc + </Kbd> + </TooltipContent> + </Tooltip> + </div> + + </div> + </div> + </div> + + + {/* 공용 Confirm Dialog */} + <ActionConfirmDialog + open={confirmDialogOpen} + onOpenChange={setConfirmDialogOpen} + title={confirmProps.title} + description={confirmProps.description} + onConfirm={confirmProps.onConfirm} + isLoading={false} + confirmLabel={ + "Confirm" + } + confirmVariant={ + "default" + } + /> + </Portal> + ) +} diff --git a/lib/rfqs-ship/vendor-table/vendors-table-toolbar-actions.tsx b/lib/rfqs-ship/vendor-table/vendors-table-toolbar-actions.tsx new file mode 100644 index 00000000..a8825cc7 --- /dev/null +++ b/lib/rfqs-ship/vendor-table/vendors-table-toolbar-actions.tsx @@ -0,0 +1,62 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" + +import { MatchedVendorRow } from "@/config/vendorRfbColumnsConfig" +import { InviteVendorsDialog } from "./invite-vendors-dialog" +import { AddVendorDialog } from "./add-vendor-dialog" +import { Button } from "@/components/ui/button" +import { RfqType } from "@/lib/rfqs-ship/validations" + +interface VendorsTableToolbarActionsProps { + table: Table<MatchedVendorRow> + rfqId: number + rfqType: RfqType +} + +export function VendorsTableToolbarActions({ + table, + rfqId, + rfqType, +}: VendorsTableToolbarActionsProps) { + // 선택된 행이 있는지 확인 + const rowSelection = table.getState().rowSelection + const selectedRows = Object.keys(rowSelection).length + const hasSelectedRows = selectedRows > 0 + + // 선택된 벤더 목록 + const selectedVendors = React.useMemo(() => { + return Object.keys(rowSelection).map((id) => + table.getRow(id).original + ) + }, [rowSelection, table]) + + return ( + <div className="flex items-center justify-between gap-2"> + <div className="flex items-center gap-2"> + <AddVendorDialog rfqId={rfqId} /> + </div> + <div className="flex items-center gap-2"> + {hasSelectedRows && ( + <InviteVendorsDialog + rfqId={rfqId} + rfqType={rfqType} + vendors={selectedVendors} + directCbe={true} + onSuccess={() => table.toggleAllRowsSelected(false)} + > + <Button + variant="default" + size="sm" + className="h-8" + disabled={!hasSelectedRows} + > + CBE 요청 ({selectedRows}) + </Button> + </InviteVendorsDialog> + )} + </div> + </div> + ) +}
\ No newline at end of file diff --git a/lib/rfqs-ship/vendor-table/vendors-table.tsx b/lib/rfqs-ship/vendor-table/vendors-table.tsx new file mode 100644 index 00000000..4e6b3e81 --- /dev/null +++ b/lib/rfqs-ship/vendor-table/vendors-table.tsx @@ -0,0 +1,225 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { useSession } from "next-auth/react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + 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 { getColumns } from "./vendors-table-columns" +import { vendors } from "@/db/schema/vendors" +import { VendorsTableToolbarActions } from "./vendors-table-toolbar-actions" +import { fetchRfqAttachmentsbyCommentId, getMatchedVendors } from "../service" +import { InviteVendorsDialog } from "./invite-vendors-dialog" +import { CommentSheet } from "./comments-sheet" +import { RfqShipVendorRow } from "@/config/vendorRfqShipColumnsConfig" +import { RfqType } from "../validations" +import { toast } from "sonner" + +// CommentSheet와 호환되는 코멘트 타입 정의 +interface RfqShipComment { + id: number; + commentText: string; + vendorId?: number; + createdAt: Date; + commentedBy?: number; + attachments?: { + id: number; + fileName: string; + filePath: string; + }[]; +} + +interface VendorsTableProps { + promises: Promise<[Awaited<ReturnType<typeof getMatchedVendors>>]> + rfqId: number + rfqType: RfqType +} + +export function MatchedVendorsTable({ promises, rfqId, rfqType }: VendorsTableProps) { + const { data: session } = useSession() + + // 1) Suspense로 받아온 데이터 + const [{ data, pageCount }] = React.use(promises) + + // 2) Row 액션 상태 + const [rowAction, setRowAction] = React.useState< + DataTableRowAction<RfqShipVendorRow> | null + >(null) + + // router 획득 + const router = useRouter() + + // 3) CommentSheet 에 넣을 상태 + const [initialComments, setInitialComments] = React.useState< + RfqShipComment[] + >([]) + + const [isLoadingComments, setIsLoadingComments] = React.useState(false) + + const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) + const [selectedVendorIdForComments, setSelectedVendorIdForComments] = + React.useState<number | null>(null) + + // 4) rowAction이 바뀌면, type이 "comments"인지 확인 후 open + React.useEffect(() => { + if (rowAction?.type === "comments") { + openCommentSheet(rowAction.row.original.id) + } + }, [rowAction]) + + // 5) 댓글 시트 오픈 함수 + async function openCommentSheet(vendorId: number) { + // Clear previous comments + setInitialComments([]) + + // Start loading + setIsLoadingComments(true) + + // Open the sheet immediately with loading state + setSelectedVendorIdForComments(vendorId) + setCommentSheetOpen(true) + + // (a) 현재 Row의 comments 불러옴 + const comments = rowAction?.row.original.comments + + try { + if (comments && comments.length > 0) { + // (b) 각 comment마다 첨부파일 fetch + const commentWithAttachments: RfqShipComment[] = await Promise.all( + comments.map(async (c) => { + const attachments = await fetchRfqAttachmentsbyCommentId(c.id) + return { + ...c, + createdAt: c.createdAt || new Date(), + attachments, + } + }) + ) + setInitialComments(commentWithAttachments) + } + } catch (error) { + console.error("Error loading comments:", error) + toast.error("Failed to load comments") + } finally { + // End loading regardless of success/failure + setIsLoadingComments(false) + } + } + + // 6) 컬럼 정의 (memo) + const columns = React.useMemo( + () => + getColumns({ + setRowAction, + router, + openCommentSheet, + }), + [router] + ) + + // 7) 필터 정의 + const filterFields: DataTableFilterField<RfqShipVendorRow>[] = [] + + const advancedFilterFields: DataTableAdvancedFilterField<RfqShipVendorRow>[] = [ + { id: "vendorName", label: "Vendor Name", type: "text" }, + { id: "vendorCode", label: "Vendor Code", type: "text" }, + { id: "projectCode", label: "Project Code", type: "text" }, + { id: "projectName", label: "Project Name", type: "text" }, + { + id: "vendorStatus", + label: "Vendor Status", + type: "multi-select", + options: vendors.status.enumValues.map((status) => ({ + label: toSentenceCase(status), + value: status, + })), + }, + { + id: "commercialResponseStatus", + label: "Response Status", + type: "multi-select", + options: ["DRAFT", "SUBMITTED", "REJECTED", "APPROVED"].map((s) => ({ + label: s, + value: s, + })), + }, + { id: "respondedAt", label: "Response Date", type: "date" }, + ] + + // 8) 테이블 생성 + const { table } = useDataTable({ + data: data as unknown as RfqShipVendorRow[], // 타입 변환 + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "respondedAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + // 행의 고유 ID + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + // 세션에서 userId 추출하고 숫자로 변환 + const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0 + + return ( + <> + <DataTable + table={table} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <VendorsTableToolbarActions + table={table as any} + rfqId={rfqId} + rfqType={rfqType} + /> + </DataTableAdvancedToolbar> + </DataTable> + + {/* CBE 요청 다이얼로그 */} + <InviteVendorsDialog + vendors={rowAction?.row.original ? [rowAction?.row.original as any] : []} + onOpenChange={() => setRowAction(null)} + rfqId={rfqId} + rfqType={rfqType} + open={rowAction?.type === "invite"} + showTrigger={false} + currentUser={session?.user} + directCbe={true} + /> + + {/* 댓글 시트 */} + <CommentSheet + open={commentSheetOpen} + onOpenChange={setCommentSheetOpen} + initialComments={initialComments as any} + rfqId={rfqId} + vendorId={selectedVendorIdForComments ?? 0} + currentUserId={currentUserId} + isLoading={isLoadingComments} + onCommentsUpdated={(updatedComments: any) => { + // Row 의 comments 필드도 업데이트 + if (!rowAction?.row) return + rowAction.row.original.comments = updatedComments + }} + /> + </> + ) +}
\ No newline at end of file |
