diff options
| author | joonhoekim <26rote@gmail.com> | 2025-07-09 12:45:15 +0000 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-07-09 12:45:15 +0000 |
| commit | e78dec9c4d51f2f2741edc6da6c2afa18e418f15 (patch) | |
| tree | 50c26bc95e734a7a174454579a023831c0e131b7 | |
| parent | ad4855620f4aa80841c4d7b1aa39d19ab2205f0e (diff) | |
(최겸) 미사용 파일 제거 (기술영업)
24 files changed, 0 insertions, 5184 deletions
diff --git a/app/[lng]/partners/(partners)/cbe-tech/page.tsx b/app/[lng]/partners/(partners)/cbe-tech/page.tsx deleted file mode 100644 index b8afd1df..00000000 --- a/app/[lng]/partners/(partners)/cbe-tech/page.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getCBEbyVendorId, } from "@/lib/rfqs-tech/service" -import { searchParamsCBECache } from "@/lib/rfqs-tech/validations" -import { getServerSession } from "next-auth" -import { authOptions } from "@/app/api/auth/[...nextauth]/route" -import { TbeVendorTable } from "@/lib/tech-vendor-rfq-response/vendor-tbe-table/tbe-table" -import * as React from "react" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { CbeVendorTable } from "@/lib/tech-vendor-rfq-response/vendor-cbe-table/cbe-table" -import { InformationButton } from "@/components/information/information-button" -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function CBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsCBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const session = await getServerSession(authOptions) - const vendorId = session?.user.companyId - // const vendorId = "17" - - const idAsNumber = Number(vendorId) - - const promises = Promise.all([ - getCBEbyVendorId({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <div className="flex items-center gap-2"> - <h2 className="text-2xl font-bold tracking-tight"> - CBE 관리 - </h2> - <InformationButton pagePath="partners/cbe-tech" /> - </div> - {/* <p className="text-sm text-muted-foreground"> - CBE에 응답하고 커뮤니케이션을 할 수 있습니다.{" "} - </p> */} - </div> - </div> - </div> - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* <DateRangePicker - triggerSize="sm" - triggerClassName="ml-auto w-56 sm:w-60" - align="end" - shallow={false} - /> */} - </React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <CbeVendorTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/partners/(partners)/rfq-tech/page.tsx b/app/[lng]/partners/(partners)/rfq-tech/page.tsx deleted file mode 100644 index a41a7f0c..00000000 --- a/app/[lng]/partners/(partners)/rfq-tech/page.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { searchParamsRfqsForVendorsCache } from "@/lib/rfqs-tech/validations" -import { RfqsVendorTable } from "@/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table" -import { getServerSession } from "next-auth" -import { authOptions } from "@/app/api/auth/[...nextauth]/route" -import Link from "next/link" -import { Button } from "@/components/ui/button" -import { LogIn } from "lucide-react" -import { getRfqResponsesForVendor } from "@/lib/tech-vendor-rfq-response/service" -import { InformationButton } from "@/components/information/information-button" -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsRfqsForVendorsCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - // Get session - const session = await getServerSession(authOptions) - - // Check if user is logged in - if (!session || !session.user) { - // Return login required UI instead of redirecting - return ( - <Shell className="gap-6"> - <div className="flex items-center justify-between"> - <div> - <div className="flex items-center gap-2"> - <h2 className="text-2xl font-bold tracking-tight"> - RFQ - </h2> - <InformationButton pagePath="partners/rfq-tech" /> - </div> - {/* <p className="text-muted-foreground"> - RFQ를 응답하고 커뮤니케이션을 할 수 있습니다. - </p> */} - </div> - </div> - - <div className="flex flex-col items-center justify-center py-12 text-center"> - <div className="rounded-lg border border-dashed p-10 shadow-sm"> - <h3 className="mb-2 text-xl font-semibold">로그인이 필요합니다</h3> - <p className="mb-6 text-muted-foreground"> - RFQ를 확인하려면 먼저 로그인하세요. - </p> - <Button size="lg" asChild> - <Link href="/partners"> - <LogIn className="mr-2 h-4 w-4" /> - 로그인하기 - </Link> - </Button> - </div> - </div> - </Shell> - ) - } - - // User is logged in, proceed with vendor ID - const vendorId = session.user.companyId - - // Validate vendorId (should be a number) - const idAsNumber = Number(vendorId) - - if (isNaN(idAsNumber)) { - // Handle invalid vendor ID (this shouldn't happen if authentication is working properly) - return ( - <Shell className="gap-6"> - <div className="flex items-center justify-between"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - RFQ - </h2> - </div> - </div> - <div className="flex flex-col items-center justify-center py-12 text-center"> - <div className="rounded-lg border border-dashed p-10 shadow-sm"> - <h3 className="mb-2 text-xl font-semibold">계정 오류</h3> - <p className="mb-6 text-muted-foreground"> - 업체 정보가 올바르게 설정되지 않았습니다. 관리자에게 문의하세요. - </p> - </div> - </div> - </Shell> - ) - } - - // If we got here, we have a valid vendor ID - const promises = Promise.all([ - getRfqResponsesForVendor({ - ...search, - filters: validFilters, - }, idAsNumber) - ]) - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - RFQ - </h2> - <p className="text-muted-foreground"> - RFQ를 응답하고 커뮤니케이션을 할 수 있습니다. - </p> - </div> - </div> - </div> - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* DateRangePicker can go here */} - </React.Suspense> - - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <RfqsVendorTable promises={promises} /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/tbe-tech/page.tsx b/app/[lng]/partners/(partners)/tbe-tech/page.tsx deleted file mode 100644 index 2085ca36..00000000 --- a/app/[lng]/partners/(partners)/tbe-tech/page.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getTBEforVendor } from "@/lib/rfqs-tech/service" -import { searchParamsTBECache } from "@/lib/rfqs-tech/validations" -import { getServerSession } from "next-auth" -import { authOptions } from "@/app/api/auth/[...nextauth]/route" -import { TbeVendorTable } from "@/lib/tech-vendor-rfq-response/vendor-tbe-table/tbe-table" -import * as React from "react" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { InformationButton } from "@/components/information/information-button" -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqTBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsTBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const session = await getServerSession(authOptions) - const vendorId = session?.user.companyId - // const vendorId = "17" - - const idAsNumber = Number(vendorId) - - const promises = Promise.all([ - getTBEforVendor({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <div className="flex items-center gap-2"> - <h2 className="text-2xl font-bold tracking-tight"> - Technical Bid Evaluation - </h2> - <InformationButton pagePath="partners/tbe-tech" /> - </div> - <p className="text-sm text-muted-foreground"> - TBE에 응답하고 커뮤니케이션을 할 수 있습니다.{" "} - </p> - </div> - </div> - </div> - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* <DateRangePicker - triggerSize="sm" - triggerClassName="ml-auto w-56 sm:w-60" - align="end" - shallow={false} - /> */} - </React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <TbeVendorTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/lib/tech-vendor-rfq-response/service.ts b/lib/tech-vendor-rfq-response/service.ts deleted file mode 100644 index e6b67406..00000000 --- a/lib/tech-vendor-rfq-response/service.ts +++ /dev/null @@ -1,458 +0,0 @@ -'use server' - -import { revalidateTag } from "next/cache"; -import db from "@/db/db"; -import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm"; -import { rfqAttachments, rfqComments, rfqItems, vendorResponses } from "@/db/schema/rfq"; -import { vendorResponsesView, vendorTechnicalResponses, vendorCommercialResponses, vendorResponseAttachments } from "@/db/schema/rfq"; -import { items, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items"; -import { GetRfqsForVendorsSchema } from "../rfqs-tech/validations"; -import { ItemData } from "./vendor-cbe-table/rfq-items-table/rfq-items-table"; -import * as z from "zod" - -export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, vendorId: number) { - try { - const offset = (input.page - 1) * input.perPage; - const limit = input.perPage; - - // 1) 메인 쿼리: vendorResponsesView 사용 - const { rows, total } = await db.transaction(async (tx) => { - // 검색 조건 - let globalWhere; - if (input.search) { - const s = `%${input.search}%`; - globalWhere = or( - sql`${vendorResponsesView.rfqCode} ILIKE ${s}`, - sql`${vendorResponsesView.projectName} ILIKE ${s}`, - sql`${vendorResponsesView.rfqDescription} ILIKE ${s}` - ); - } - - // 협력업체 ID 필터링 - const mainWhere = and(eq(vendorResponsesView.vendorId, vendorId), globalWhere); - - // 정렬: 응답 시간순 - const orderBy = [desc(vendorResponsesView.respondedAt)]; - - // (A) 데이터 조회 - const data = await tx - .select() - .from(vendorResponsesView) - .where(mainWhere) - .orderBy(...orderBy) - .offset(offset) - .limit(limit); - - // (B) 전체 개수 카운트 - const [{ count }] = await tx - .select({ - count: sql<number>`count(*)`.as("count"), - }) - .from(vendorResponsesView) - .where(mainWhere); - - return { rows: data, total: Number(count) }; - }); - - // 2) rfqId 고유 목록 추출 - const distinctRfqs = [...new Set(rows.map((r) => r.rfqId))]; - if (distinctRfqs.length === 0) { - return { data: [], pageCount: 0 }; - } - - // 3) 추가 데이터 조회 - // 3-A) RFQ 아이템 - const itemsAll = await db - .select({ - id: rfqItems.id, - rfqId: rfqItems.rfqId, - itemCode: rfqItems.itemCode, - itemList: sql<string>`COALESCE(${itemOffshoreTop.itemList}, ${itemOffshoreHull.itemList})`.as('itemList'), - subItemList: sql<string>`COALESCE(${itemOffshoreTop.subItemList}, ${itemOffshoreHull.subItemList})`.as('subItemList'), - quantity: rfqItems.quantity, - description: rfqItems.description, - uom: rfqItems.uom, - }) - .from(rfqItems) - .leftJoin(itemOffshoreTop, eq(rfqItems.itemCode, itemOffshoreTop.itemCode)) - .leftJoin(itemOffshoreHull, eq(rfqItems.itemCode, itemOffshoreHull.itemCode)) - .where(inArray(rfqItems.rfqId, distinctRfqs)); - - // 3-B) RFQ 첨부 파일 (협력업체용) - const attachAll = await db - .select() - .from(rfqAttachments) - .where( - and( - inArray(rfqAttachments.rfqId, distinctRfqs), - isNull(rfqAttachments.vendorId) - ) - ); - - // 3-C) RFQ 코멘트 - const commAll = await db - .select() - .from(rfqComments) - .where( - and( - inArray(rfqComments.rfqId, distinctRfqs), - or( - isNull(rfqComments.vendorId), - eq(rfqComments.vendorId, vendorId) - ) - ) - ); - - - // 3-E) 협력업체 응답 상세 - 기술 - const technicalResponsesAll = await db - .select() - .from(vendorTechnicalResponses) - .where( - inArray( - vendorTechnicalResponses.responseId, - rows.map((r) => r.responseId) - ) - ); - - // 3-F) 협력업체 응답 상세 - 상업 - const commercialResponsesAll = await db - .select() - .from(vendorCommercialResponses) - .where( - inArray( - vendorCommercialResponses.responseId, - rows.map((r) => r.responseId) - ) - ); - - // 3-G) 협력업체 응답 첨부 파일 - const responseAttachmentsAll = await db - .select() - .from(vendorResponseAttachments) - .where( - inArray( - vendorResponseAttachments.responseId, - rows.map((r) => r.responseId) - ) - ); - - // 4) 데이터 그룹화 - // RFQ 아이템 그룹화 - const itemsByRfqId = new Map<number, any[]>(); - for (const it of itemsAll) { - if (!itemsByRfqId.has(it.rfqId)) { - itemsByRfqId.set(it.rfqId, []); - } - itemsByRfqId.get(it.rfqId)!.push({ - id: it.id, - itemCode: it.itemCode, - itemList: it.itemList, - subItemList: it.subItemList, - quantity: it.quantity, - description: it.description, - uom: it.uom, - }); - } - - // RFQ 첨부 파일 그룹화 - const attachByRfqId = new Map<number, any[]>(); - for (const att of attachAll) { - const rid = att.rfqId!; - if (!attachByRfqId.has(rid)) { - attachByRfqId.set(rid, []); - } - attachByRfqId.get(rid)!.push({ - id: att.id, - fileName: att.fileName, - filePath: att.filePath, - vendorId: att.vendorId, - evaluationId: att.evaluationId, - }); - } - - // RFQ 코멘트 그룹화 - const commByRfqId = new Map<number, any[]>(); - for (const c of commAll) { - const rid = c.rfqId!; - if (!commByRfqId.has(rid)) { - commByRfqId.set(rid, []); - } - commByRfqId.get(rid)!.push({ - id: c.id, - commentText: c.commentText, - vendorId: c.vendorId, - evaluationId: c.evaluationId, - createdAt: c.createdAt, - }); - } - - - // 기술 응답 그룹화 - const techResponseByResponseId = new Map<number, any>(); - for (const tr of technicalResponsesAll) { - techResponseByResponseId.set(tr.responseId, { - id: tr.id, - summary: tr.summary, - notes: tr.notes, - createdAt: tr.createdAt, - updatedAt: tr.updatedAt, - }); - } - - // 상업 응답 그룹화 - const commResponseByResponseId = new Map<number, any>(); - for (const cr of commercialResponsesAll) { - commResponseByResponseId.set(cr.responseId, { - id: cr.id, - totalPrice: cr.totalPrice, - currency: cr.currency, - paymentTerms: cr.paymentTerms, - incoterms: cr.incoterms, - deliveryPeriod: cr.deliveryPeriod, - warrantyPeriod: cr.warrantyPeriod, - validityPeriod: cr.validityPeriod, - priceBreakdown: cr.priceBreakdown, - commercialNotes: cr.commercialNotes, - createdAt: cr.createdAt, - updatedAt: cr.updatedAt, - }); - } - - // 응답 첨부 파일 그룹화 - const respAttachByResponseId = new Map<number, any[]>(); - for (const ra of responseAttachmentsAll) { - const rid = ra.responseId!; - if (!respAttachByResponseId.has(rid)) { - respAttachByResponseId.set(rid, []); - } - respAttachByResponseId.get(rid)!.push({ - id: ra.id, - fileName: ra.fileName, - filePath: ra.filePath, - attachmentType: ra.attachmentType, - description: ra.description, - uploadedAt: ra.uploadedAt, - uploadedBy: ra.uploadedBy, - }); - } - - // 5) 최종 데이터 결합 - const final = rows.map((row) => { - return { - // 응답 정보 - responseId: row.responseId, - responseStatus: row.responseStatus, - respondedAt: row.respondedAt, - - // RFQ 기본 정보 - rfqId: row.rfqId, - rfqCode: row.rfqCode, - rfqDescription: row.rfqDescription, - rfqDueDate: row.rfqDueDate, - rfqStatus: row.rfqStatus, - - rfqCreatedAt: row.rfqCreatedAt, - rfqUpdatedAt: row.rfqUpdatedAt, - rfqCreatedBy: row.rfqCreatedBy, - - // 프로젝트 정보 - projectId: row.projectId, - projectCode: row.projectCode, - projectName: row.projectName, - - // 협력업체 정보 - vendorId: row.vendorId, - vendorName: row.vendorName, - vendorCode: row.vendorCode, - - // RFQ 관련 데이터 - items: itemsByRfqId.get(row.rfqId) || [], - attachments: attachByRfqId.get(row.rfqId) || [], - comments: commByRfqId.get(row.rfqId) || [], - - // 평가 정보 - tbeEvaluation: row.tbeId ? { - id: row.tbeId, - result: row.tbeResult, - } : null, - cbeEvaluation: row.cbeId ? { - id: row.cbeId, - result: row.cbeResult, - } : null, - - // 협력업체 응답 상세 - technicalResponse: techResponseByResponseId.get(row.responseId) || null, - commercialResponse: commResponseByResponseId.get(row.responseId) || null, - responseAttachments: respAttachByResponseId.get(row.responseId) || [], - - // 응답 상태 표시 - hasTechnicalResponse: row.hasTechnicalResponse, - hasCommercialResponse: row.hasCommercialResponse, - attachmentCount: row.attachmentCount || 0, - }; - }); - - const pageCount = Math.ceil(total / input.perPage); - return { data: final, pageCount }; - } catch (err) { - return { data: null, error: err instanceof Error ? err.message : "Unknown error" }; - } -} - - -export async function getItemsByRfqId(rfqId: number): Promise<ResponseType> { - try { - if (!rfqId || isNaN(Number(rfqId))) { - return { - success: false, - error: "Invalid RFQ ID provided", - } - } - - // Query the database to get all items for the given RFQ ID - const items = await db - .select() - .from(rfqItems) - .where(eq(rfqItems.rfqId, rfqId)) - .orderBy(rfqItems.itemCode) - - - return { - success: true, - data: items as ItemData[], - } - } catch (error) { - console.error("Error fetching RFQ items:", error) - - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error occurred when fetching RFQ items", - } - } -} - - -// Define the schema for validation -const commercialResponseSchema = z.object({ - responseId: z.number(), - vendorId: z.number(), // Added vendorId field - responseStatus: z.enum(["PENDING", "IN_PROGRESS", "SUBMITTED", "REJECTED", "ACCEPTED"]), - totalPrice: z.number().optional(), - currency: z.string().default("USD"), - paymentTerms: z.string().optional(), - incoterms: z.string().optional(), - deliveryPeriod: z.string().optional(), - warrantyPeriod: z.string().optional(), - validityPeriod: z.string().optional(), - priceBreakdown: z.string().optional(), - commercialNotes: z.string().optional(), -}) - -type CommercialResponseInput = z.infer<typeof commercialResponseSchema> - -interface ResponseType { - success: boolean - error?: string - data?: any -} - -export async function updateCommercialResponse(input: CommercialResponseInput): Promise<ResponseType> { - try { - // Validate input data - const validated = commercialResponseSchema.parse(input) - - // Check if a commercial response already exists for this responseId - const existingResponse = await db - .select() - .from(vendorCommercialResponses) - .where(eq(vendorCommercialResponses.responseId, validated.responseId)) - .limit(1) - - const now = new Date() - - if (existingResponse.length > 0) { - // Update existing record - await db - .update(vendorCommercialResponses) - .set({ - responseStatus: validated.responseStatus, - totalPrice: validated.totalPrice, - currency: validated.currency, - paymentTerms: validated.paymentTerms, - incoterms: validated.incoterms, - deliveryPeriod: validated.deliveryPeriod, - warrantyPeriod: validated.warrantyPeriod, - validityPeriod: validated.validityPeriod, - priceBreakdown: validated.priceBreakdown, - commercialNotes: validated.commercialNotes, - updatedAt: now, - }) - .where(eq(vendorCommercialResponses.responseId, validated.responseId)) - - } else { - // Return error instead of creating a new record - return { - success: false, - error: "해당 응답 ID에 대한 상업 응답 정보를 찾을 수 없습니다." - } - } - - // Also update the main vendor response status if submitted - if (validated.responseStatus === "SUBMITTED") { - // Get the vendor response - const vendorResponseResult = await db - .select() - .from(vendorResponses) - .where(eq(vendorResponses.id, validated.responseId)) - .limit(1) - - if (vendorResponseResult.length > 0) { - // Update the main response status to RESPONDED - await db - .update(vendorResponses) - .set({ - responseStatus: "RESPONDED", - updatedAt: now, - }) - .where(eq(vendorResponses.id, validated.responseId)) - } - } - - - return { - success: true, - data: { responseId: validated.responseId } - } - - } catch (error) { - console.error("Error updating commercial response:", error) - - if (error instanceof z.ZodError) { - return { - success: false, - error: "유효하지 않은 데이터가 제공되었습니다." - } - } - - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error occurred" - } - } -} -// Helper function to get responseId from rfqId and vendorId -export async function getCommercialResponseByResponseId(responseId: number): Promise<any | null> { - try { - const response = await db - .select() - .from(vendorCommercialResponses) - .where(eq(vendorCommercialResponses.responseId, responseId)) - .limit(1) - - return response.length > 0 ? response[0] : null - } catch (error) { - console.error("Error getting commercial response:", error) - return null - } -}
\ No newline at end of file diff --git a/lib/tech-vendor-rfq-response/types.ts b/lib/tech-vendor-rfq-response/types.ts deleted file mode 100644 index f8ae1fcf..00000000 --- a/lib/tech-vendor-rfq-response/types.ts +++ /dev/null @@ -1,76 +0,0 @@ -// RFQ 아이템 타입 -export interface RfqResponseItem { - id: number; - itemCode: string; - itemList?: string | null; - subItemList?: string | null; - quantity?: number; - uom?: string; - description?: string | null; -} - -// RFQ 첨부 파일 타입 -export interface RfqResponseAttachment { - id: number; - fileName: string; - filePath: string; - vendorId?: number | null; - evaluationId?: number | null; -} - -// RFQ 코멘트 타입 -export interface RfqResponseComment { - id: number; - commentText: string; - vendorId?: number | null; - evaluationId?: number | null; - createdAt: Date; - commentedBy?: number; -} - -// 최종 RfqResponse 타입 - RFQ 참여 응답만 포함하도록 간소화 -export interface RfqResponse { - // 응답 정보 - responseId: number; - responseStatus: "INVITED" | "ACCEPTED" | "DECLINED" | "REVIEWING" | "RESPONDED"; - respondedAt: Date; - - // RFQ 기본 정보 - rfqId: number; - rfqCode: string; - rfqDescription?: string | null; - rfqDueDate?: Date | null; - rfqStatus: string; - rfqCreatedAt: Date; - rfqUpdatedAt: Date; - rfqCreatedBy?: number | null; - - // 프로젝트 정보 - projectId?: number | null; - projectCode?: string | null; - projectName?: string | null; - - // 협력업체 정보 - vendorId: number; - vendorName: string; - vendorCode?: string | null; - - // RFQ 관련 데이터 - items: RfqResponseItem[]; - attachments: RfqResponseAttachment[]; - comments: RfqResponseComment[]; -} - -// DataTable 등에서 사용할 수 있도록 id 필드를 추가한 확장 타입 -export interface RfqResponseWithId extends RfqResponse { - id: number; // rfqId와 동일하게 사용 -} - -// 페이지네이션 결과 타입 -export interface RfqResponsesResult { - data: RfqResponseWithId[]; - pageCount: number; -} - -// 이전 버전과의 호환성을 위한 RfqWithAll 타입 (이름만 유지) -export type RfqWithAll = RfqResponseWithId;
\ No newline at end of file diff --git a/lib/tech-vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx b/lib/tech-vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx deleted file mode 100644 index c7be0bf4..00000000 --- a/lib/tech-vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx +++ /dev/null @@ -1,365 +0,0 @@ -"use client" - -import * as React from "react" -import { type DataTableRowAction } from "@/types/table" -import { type ColumnDef } from "@tanstack/react-table" -import { Download, Loader2, MessageSquare, FileEdit } 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 { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { useRouter } from "next/navigation" -import { VendorWithCbeFields, vendorResponseCbeColumnsConfig } from "@/config/vendorCbeColumnsConfig" -import { toast } from "sonner" - - -type NextRouter = ReturnType<typeof useRouter> - -interface GetColumnsProps { - setRowAction: React.Dispatch< - React.SetStateAction<DataTableRowAction<VendorWithCbeFields> | null> - > - router: NextRouter - openCommentSheet: (vendorId: number) => void - handleDownloadCbeFiles: (vendorId: number, rfqId: number) => void - loadingVendors: Record<string, boolean> - openVendorContactsDialog: (rfqId: number, rfq: VendorWithCbeFields) => void - // New prop for handling commercial response - openCommercialResponseSheet: (responseId: number, rfq: VendorWithCbeFields) => void -} - -/** - * tanstack table 컬럼 정의 (중첩 헤더 버전) - */ -export function getColumns({ - setRowAction, - router, - openCommentSheet, - handleDownloadCbeFiles, - loadingVendors, - openVendorContactsDialog, - openCommercialResponseSheet -}: GetColumnsProps): ColumnDef<VendorWithCbeFields>[] { - // ---------------------------------------------------------------- - // 1) Select 컬럼 (체크박스) - // ---------------------------------------------------------------- - const selectColumn: ColumnDef<VendorWithCbeFields> = { - 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) 그룹화(Nested) 컬럼 구성 - // ---------------------------------------------------------------- - const groupMap: Record<string, ColumnDef<VendorWithCbeFields>[]> = {} - - vendorResponseCbeColumnsConfig.forEach((cfg) => { - const groupName = cfg.group || "_noGroup" - if (!groupMap[groupName]) { - groupMap[groupName] = [] - } - - // childCol: ColumnDef<VendorWithCbeFields> - const childCol: ColumnDef<VendorWithCbeFields> = { - accessorKey: cfg.id, - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={cfg.label} /> - ), - meta: { - excelHeader: cfg.excelHeader, - group: cfg.group, - type: cfg.type, - }, - maxSize: 120, - // 셀 렌더링 - cell: ({ row, getValue }) => { - // 1) 필드값 가져오기 - const val = getValue() - - - if (cfg.id === "rfqCode") { - const rfq = row.original; - const rfqId = rfq.rfqId; - - // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링 - const handleVendorNameClick = () => { - if (rfqId) { - openVendorContactsDialog(rfqId, rfq); // vendor 전체 객체 전달 - } else { - toast.error("협력업체 ID를 찾을 수 없습니다."); - } - }; - - return ( - <Button - variant="link" - className="p-0 h-auto text-left font-normal justify-start hover:underline" - onClick={handleVendorNameClick} - > - {val as string} - </Button> - ); - } - - // Commercial Response Status에 배지 적용 - if (cfg.id === "commercialResponseStatus") { - const status = val as string; - - if (!status) return <span className="text-muted-foreground">-</span>; - - let variant: "default" | "outline" | "secondary" | "destructive" = "outline"; - - switch (status) { - case "SUBMITTED": - variant = "default"; // Green - break; - case "IN_PROGRESS": - variant = "secondary"; // Orange/Yellow - break; - case "PENDING": - variant = "outline"; // Gray - break; - default: - variant = "outline"; - } - - return ( - <Badge variant={variant} className="capitalize"> - {status.toLowerCase().replace("_", " ")} - </Badge> - ); - } - - // 예) TBE Updated (날짜) - if (cfg.id === "respondedAt" || cfg.id === "rfqDueDate" ) { - const dateVal = val as Date | undefined - if (!dateVal) return null - return formatDate(dateVal) - } - - // 그 외 필드는 기본 값 표시 - return val ?? "" - }, - } - - groupMap[groupName].push(childCol) - }) - - // groupMap → nestedColumns - const nestedColumns: ColumnDef<VendorWithCbeFields>[] = [] - Object.entries(groupMap).forEach(([groupName, colDefs]) => { - if (groupName === "_noGroup") { - nestedColumns.push(...colDefs) - } else { - nestedColumns.push({ - id: groupName, - header: groupName, - columns: colDefs, - }) - } - }) - - // ---------------------------------------------------------------- - // 3) Respond 컬럼 (새로 추가) - // ---------------------------------------------------------------- - const respondColumn: ColumnDef<VendorWithCbeFields> = { - id: "respond", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Response" /> - ), - cell: ({ row }) => { - const vendor = row.original - const responseId = vendor.responseId - - if (!responseId) { - return <div className="text-center text-muted-foreground">-</div> - } - - const handleClick = () => { - openCommercialResponseSheet(responseId, vendor) - } - - // Status에 따라 버튼 variant 변경 - let variant: "default" | "outline" | "ghost" | "secondary" = "default" - let buttonText = "Respond" - - if (vendor.commercialResponseStatus === "SUBMITTED") { - variant = "outline" - buttonText = "Update" - } else if (vendor.commercialResponseStatus === "IN_PROGRESS") { - variant = "secondary" - buttonText = "Continue" - } - - return ( - <Button - variant={variant} - size="sm" - // className="w-20" - onClick={handleClick} - > - <FileEdit className="h-3.5 w-3.5 mr-1" /> - {buttonText} - </Button> - ) - }, - enableSorting: false, - maxSize: 200, - minSize: 115, - } - - // ---------------------------------------------------------------- - // 4) Comments 컬럼 - // ---------------------------------------------------------------- - const commentsColumn: ColumnDef<VendorWithCbeFields> = { - 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(vendor.responseId ?? 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 - } - - // ---------------------------------------------------------------- - // 5) 파일 다운로드 컬럼 (개별 로딩 상태 적용) - // ---------------------------------------------------------------- - const downloadColumn: ColumnDef<VendorWithCbeFields> = { - id: "attachDownload", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Attach Download" /> - ), - cell: ({ row }) => { - const vendor = row.original - const vendorId = vendor.vendorId - const rfqId = vendor.rfqId - const files = vendor.files?.length || 0 - - if (!vendorId || !rfqId) { - return <div className="text-center text-muted-foreground">-</div> - } - - // 각 행별로 로딩 상태 확인 (vendorId_rfqId 형식의 키 사용) - const rowKey = `${vendorId}_${rfqId}` - const isRowLoading = loadingVendors[rowKey] === true - - // 템플릿 파일이 없으면 다운로드 버튼 비활성화 - const isDisabled = files <= 0 || isRowLoading - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={ - isDisabled - ? undefined - : () => handleDownloadCbeFiles(vendorId, rfqId) - } - aria-label={ - isRowLoading - ? "다운로드 중..." - : files > 0 - ? `CBE 첨부 다운로드 (${files}개)` - : "다운로드할 파일 없음" - } - disabled={isDisabled} - > - {isRowLoading ? ( - <Loader2 className="h-4 w-4 animate-spin" /> - ) : ( - <Download className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - )} - - {/* 파일이 1개 이상인 경우 뱃지로 개수 표시 (로딩 중이 아닐 때만) */} - {!isRowLoading && files > 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" - > - {files} - </Badge> - )} - - <span className="sr-only"> - {isRowLoading - ? "다운로드 중..." - : files > 0 - ? `CBE 첨부 다운로드 (${files}개)` - : "다운로드할 파일 없음"} - </span> - </Button> - ) - }, - enableSorting: false, - maxSize: 80, - } - - // ---------------------------------------------------------------- - // 6) 최종 컬럼 배열 (respondColumn 추가) - // ---------------------------------------------------------------- - return [ - selectColumn, - ...nestedColumns, - respondColumn, // 응답 컬럼 추가 - downloadColumn, - commentsColumn, - ] -}
\ No newline at end of file diff --git a/lib/tech-vendor-rfq-response/vendor-cbe-table/cbe-table.tsx b/lib/tech-vendor-rfq-response/vendor-cbe-table/cbe-table.tsx deleted file mode 100644 index 94e29a95..00000000 --- a/lib/tech-vendor-rfq-response/vendor-cbe-table/cbe-table.tsx +++ /dev/null @@ -1,272 +0,0 @@ -"use client" - -import * as React from "react" -import { useRouter } from "next/navigation" -import type { - DataTableAdvancedFilterField, - DataTableFilterField, - DataTableRowAction, -} from "@/types/table" - -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 "./cbe-table-columns" -import { - fetchRfqAttachmentsbyCommentId, - getCBEbyVendorId, - getFileFromRfqAttachmentsbyid, - fetchCbeFiles -} from "../../rfqs-tech/service" -import { useSession } from "next-auth/react" -import { CbeComment, CommentSheet } from "./comments-sheet" -import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" -import { toast } from "sonner" -import { RfqDeailDialog } from "./rfq-detail-dialog" -import { CommercialResponseSheet } from "./respond-cbe-sheet" - -interface VendorsTableProps { - promises: Promise< - [ - Awaited<ReturnType<typeof getCBEbyVendorId>>, - ] - > -} - -export function CbeVendorTable({ promises }: VendorsTableProps) { - const { data: session } = useSession() - const userVendorId = session?.user?.companyId - const userId = Number(session?.user?.id) - // Suspense로 받아온 데이터 - const [{ data, pageCount }] = React.use(promises) - const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithCbeFields> | null>(null) - const [selectedCbeId, setSelectedCbeId] = React.useState<number | null>(null) - - // 개별 협력업체별 로딩 상태를 관리하는 맵 - const [loadingVendors, setLoadingVendors] = React.useState<Record<string, boolean>>({}) - - const router = useRouter() - - // 코멘트 관련 상태 - const [initialComments, setInitialComments] = React.useState<CbeComment[]>([]) - const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) - const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) - - // 상업 응답 관련 상태 - const [commercialResponseSheetOpen, setCommercialResponseSheetOpen] = React.useState(false) - const [selectedResponseId, setSelectedResponseId] = React.useState<number | null>(null) - const [selectedRfq, setSelectedRfq] = React.useState<VendorWithCbeFields | null>(null) - - // RFQ 상세 관련 상태 - const [rfqDetailDialogOpen, setRfqDetailDialogOpen] = React.useState(false) - const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null) - const [selectedRfqDetail, setSelectedRfqDetail] = React.useState<VendorWithCbeFields | null>(null) - - React.useEffect(() => { - if (rowAction?.type === "comments") { - // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행 - openCommentSheet(Number(rowAction.row.original.responseId)) - } - }, [rowAction]) - - async function openCommentSheet(responseId: number) { - setInitialComments([]) - - const comments = rowAction?.row.original.comments - const rfqId = rowAction?.row.original.rfqId - - if (comments && comments.length > 0) { - const commentWithAttachments: CbeComment[] = await Promise.all( - comments.map(async (c) => { - // 서버 액션을 사용하여 코멘트 첨부 파일 가져오기 - const attachments = await fetchRfqAttachmentsbyCommentId(c.id) - - return { - ...c, - commentedBy: userId, // DB나 API 응답에 있다고 가정 - attachments, - } - }) - ) - - setInitialComments(commentWithAttachments) - } - - if(rfqId) { - setSelectedRfqIdForComments(rfqId) - } - setSelectedCbeId(responseId) - setCommentSheetOpen(true) - } - - // 상업 응답 시트 열기 - function openCommercialResponseSheet(responseId: number, rfq: VendorWithCbeFields) { - setSelectedResponseId(responseId) - setSelectedRfq(rfq) - setCommercialResponseSheetOpen(true) - } - - // RFQ 상세 대화상자 열기 - function openRfqDetailDialog(rfqId: number, rfq: VendorWithCbeFields) { - setSelectedRfqId(rfqId) - setSelectedRfqDetail(rfq) - setRfqDetailDialogOpen(true) - } - - const handleDownloadCbeFiles = React.useCallback( - async (vendorId: number, rfqId: number) => { - // 고유 키 생성: vendorId_rfqId - const rowKey = `${vendorId}_${rfqId}` - - // 해당 협력업체의 로딩 상태만 true로 설정 - setLoadingVendors(prev => ({ - ...prev, - [rowKey]: true - })) - - try { - const { files, error } = await fetchCbeFiles(vendorId, rfqId); - if (error) { - toast.error(error); - return; - } - if (files.length === 0) { - toast.warning("다운로드할 CBE 파일이 없습니다"); - return; - } - // 순차적으로 파일 다운로드 - for (const file of files) { - await downloadFile(file.id); - } - toast.success(`${files.length}개의 CBE 파일이 다운로드되었습니다`); - } catch (error) { - toast.error("CBE 파일을 다운로드하는 데 실패했습니다"); - console.error(error); - } finally { - // 해당 협력업체의 로딩 상태만 false로 되돌림 - setLoadingVendors(prev => ({ - ...prev, - [rowKey]: false - })) - } - }, - [] - ); - - const downloadFile = React.useCallback(async (fileId: number) => { - try { - const { file, error } = await getFileFromRfqAttachmentsbyid(fileId); - if (error || !file) { - throw new Error(error || "파일 정보를 가져오는 데 실패했습니다"); - } - - const link = document.createElement("a"); - link.href = `/api/rfq-download?path=${encodeURIComponent(file.filePath)}`; - link.download = file.fileName; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - return true; - } catch (error) { - console.error(error); - return false; - } - }, []); - - // 응답 성공 후 데이터 갱신 - const handleResponseSuccess = React.useCallback(() => { - // 필요한 경우 데이터 다시 가져오기 - router.refresh() - }, [router]); - - // getColumns() 호출 시 필요한 핸들러들 주입 - const columns = React.useMemo( - () => getColumns({ - setRowAction, - router, - openCommentSheet, - handleDownloadCbeFiles, - loadingVendors, - openVendorContactsDialog: openRfqDetailDialog, - openCommercialResponseSheet, - }), - [ - setRowAction, - router, - openCommentSheet, - handleDownloadCbeFiles, - loadingVendors, - openRfqDetailDialog, - openCommercialResponseSheet - ] - ); - - // 필터 필드 정의 - const filterFields: DataTableFilterField<VendorWithCbeFields>[] = [] - const advancedFilterFields: DataTableAdvancedFilterField<VendorWithCbeFields>[] = [ - - ] - - const { table } = useDataTable({ - data, - columns, - pageCount, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: "respondedAt", desc: true }], - columnPinning: { right: ["respond", "comments"] }, // respond 컬럼을 오른쪽에 고정 - }, - getRowId: (originalRow) => String(originalRow.responseId), - shallow: false, - clearOnDefault: true, - }) - - return ( - <> - <DataTable table={table}> - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - /> - </DataTable> - - {/* 코멘트 시트 */} - {commentSheetOpen && selectedRfqIdForComments && selectedCbeId !== null && ( - <CommentSheet - open={commentSheetOpen} - onOpenChange={setCommentSheetOpen} - rfqId={selectedRfqIdForComments} - initialComments={initialComments} - vendorId={userVendorId || 0} - currentUserId={userId || 0} - cbeId={selectedCbeId} - /> - )} - - {/* 상업 응답 시트 */} - {commercialResponseSheetOpen && selectedResponseId !== null && selectedRfq && ( - <CommercialResponseSheet - open={commercialResponseSheetOpen} - onOpenChange={setCommercialResponseSheetOpen} - responseId={selectedResponseId} - rfq={selectedRfq} - onSuccess={handleResponseSuccess} - /> - )} - - {/* RFQ 상세 대화상자 */} - {rfqDetailDialogOpen && selectedRfqId !== null && ( - <RfqDeailDialog - isOpen={rfqDetailDialogOpen} - onOpenChange={setRfqDetailDialogOpen} - rfqId={selectedRfqId} - rfq={selectedRfqDetail} - /> - )} - </> - ) -}
\ No newline at end of file diff --git a/lib/tech-vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx b/lib/tech-vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx deleted file mode 100644 index 6a92f4d9..00000000 --- a/lib/tech-vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx +++ /dev/null @@ -1,323 +0,0 @@ -"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 { formatDate } from "@/lib/utils" -import { createRfqCommentWithAttachments } from "@/lib/rfqs-tech/service" - - -export interface CbeComment { - 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?: CbeComment[] - currentUserId: number - rfqId: number - tbeId?: number - cbeId?: number - vendorId: number - onCommentsUpdated?: (comments: CbeComment[]) => 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, - tbeId, - cbeId, - onCommentsUpdated, - isLoading = false, // Default to false - ...props -}: CommentSheetProps) { - - - const [comments, setComments] = React.useState<CbeComment[]>(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: cbeId, - files: data.newFiles, - }) - - if (!res.ok) { - throw new Error("Failed to create comment") - } - - toast.success("Comment created") - - // 임시로 새 코멘트 추가 - const newComment: CbeComment = { - 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/tech-vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx b/lib/tech-vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx deleted file mode 100644 index 8cc4fa6f..00000000 --- a/lib/tech-vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx +++ /dev/null @@ -1,427 +0,0 @@ -"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 { z } from "zod" - -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 { Input } from "@/components/ui/input" -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { Textarea } from "@/components/ui/textarea" -import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" -import { getCommercialResponseByResponseId, updateCommercialResponse } from "../service" - -// Define schema for form validation (client-side) -const commercialResponseFormSchema = z.object({ - responseStatus: z.enum(["PENDING", "IN_PROGRESS", "SUBMITTED", "REJECTED", "ACCEPTED"]), - totalPrice: z.coerce.number().optional(), - currency: z.string().default("USD"), - paymentTerms: z.string().optional(), - incoterms: z.string().optional(), - deliveryPeriod: z.string().optional(), - warrantyPeriod: z.string().optional(), - validityPeriod: z.string().optional(), - priceBreakdown: z.string().optional(), - commercialNotes: z.string().optional(), -}) - -type CommercialResponseFormInput = z.infer<typeof commercialResponseFormSchema> - -interface CommercialResponseSheetProps - extends React.ComponentPropsWithRef<typeof Sheet> { - rfq: VendorWithCbeFields | null - responseId: number | null // This is the vendor_responses.id - onSuccess?: () => void -} - -export function CommercialResponseSheet({ - rfq, - responseId, - onSuccess, - ...props -}: CommercialResponseSheetProps) { - const [isSubmitting, startSubmitTransition] = React.useTransition() - const [isLoading, setIsLoading] = React.useState(true) - - const form = useForm<CommercialResponseFormInput>({ - resolver: zodResolver(commercialResponseFormSchema), - defaultValues: { - responseStatus: "PENDING", - totalPrice: undefined, - currency: "USD", - paymentTerms: "", - incoterms: "", - deliveryPeriod: "", - warrantyPeriod: "", - validityPeriod: "", - priceBreakdown: "", - commercialNotes: "", - }, - }) - - // Load existing commercial response data when sheet opens - React.useEffect(() => { - async function loadCommercialResponse() { - if (!responseId) return - - setIsLoading(true) - try { - // Use the helper function to get existing data - const existingResponse = await getCommercialResponseByResponseId(responseId) - - if (existingResponse) { - // If we found existing data, populate the form - form.reset({ - responseStatus: existingResponse.responseStatus, - totalPrice: existingResponse.totalPrice, - currency: existingResponse.currency || "USD", - paymentTerms: existingResponse.paymentTerms || "", - incoterms: existingResponse.incoterms || "", - deliveryPeriod: existingResponse.deliveryPeriod || "", - warrantyPeriod: existingResponse.warrantyPeriod || "", - validityPeriod: existingResponse.validityPeriod || "", - priceBreakdown: existingResponse.priceBreakdown || "", - commercialNotes: existingResponse.commercialNotes || "", - }) - } else if (rfq) { - // If no existing data but we have rfq data with some values already - form.reset({ - responseStatus: rfq.commercialResponseStatus as any || "PENDING", - totalPrice: rfq.totalPrice || undefined, - currency: rfq.currency || "USD", - paymentTerms: rfq.paymentTerms || "", - incoterms: rfq.incoterms || "", - deliveryPeriod: rfq.deliveryPeriod || "", - warrantyPeriod: rfq.warrantyPeriod || "", - validityPeriod: rfq.validityPeriod || "", - priceBreakdown: "", - commercialNotes: "", - }) - } - } catch (error) { - console.error("Failed to load commercial response data:", error) - toast.error("상업 응답 데이터를 불러오는데 실패했습니다") - } finally { - setIsLoading(false) - } - } - - loadCommercialResponse() - }, [responseId, rfq, form]) - - function onSubmit(formData: CommercialResponseFormInput) { - if (!responseId) { - toast.error("응답 ID를 찾을 수 없습니다") - return - } - - if (!rfq?.vendorId) { - toast.error("협력업체 ID를 찾을 수 없습니다") - return - } - - startSubmitTransition(async () => { - try { - // Pass both responseId and vendorId to the server action - const result = await updateCommercialResponse({ - responseId, - vendorId: rfq.vendorId, // Include vendorId for revalidateTag - ...formData, - }) - - if (!result.success) { - toast.error(result.error || "응답 제출 중 오류가 발생했습니다") - return - } - - toast.success("Commercial response successfully submitted") - props.onOpenChange?.(false) - - if (onSuccess) { - onSuccess() - } - } catch (error) { - console.error("Error submitting response:", error) - toast.error("응답 제출 중 오류가 발생했습니다") - } - }) - } - - return ( - <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-md"> - <SheetHeader className="text-left"> - <SheetTitle>Commercial Response</SheetTitle> - <SheetDescription> - {rfq?.rfqCode && <span className="font-medium">{rfq.rfqCode}</span>} - <div className="mt-1">Please provide your commercial response for this RFQ</div> - </SheetDescription> - </SheetHeader> - - {isLoading ? ( - <div className="flex items-center justify-center py-8"> - <Loader className="h-8 w-8 animate-spin text-muted-foreground" /> - </div> - ) : ( - <Form {...form}> - <form - onSubmit={form.handleSubmit(onSubmit)} - className="flex flex-col gap-4 overflow-y-auto max-h-[calc(100vh-200px)] pr-2" - > - <FormField - control={form.control} - name="responseStatus" - render={({ field }) => ( - <FormItem> - <FormLabel>Response Status</FormLabel> - <Select - onValueChange={field.onChange} - defaultValue={field.value} - > - <FormControl> - <SelectTrigger className="capitalize"> - <SelectValue placeholder="Select response status" /> - </SelectTrigger> - </FormControl> - <SelectContent> - <SelectGroup> - <SelectItem value="PENDING">Pending</SelectItem> - <SelectItem value="IN_PROGRESS">In Progress</SelectItem> - <SelectItem value="SUBMITTED">Submitted</SelectItem> - </SelectGroup> - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="totalPrice" - render={({ field }) => ( - <FormItem> - <FormLabel>Total Price</FormLabel> - <FormControl> - <Input - type="number" - placeholder="0.00" - {...field} - value={field.value || ''} - onChange={(e) => { - const value = e.target.value === '' ? undefined : parseFloat(e.target.value); - field.onChange(value); - }} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="currency" - render={({ field }) => ( - <FormItem> - <FormLabel>Currency</FormLabel> - <Select - onValueChange={field.onChange} - defaultValue={field.value} - > - <FormControl> - <SelectTrigger> - <SelectValue placeholder="Select currency" /> - </SelectTrigger> - </FormControl> - <SelectContent> - <SelectGroup> - <SelectItem value="USD">USD</SelectItem> - <SelectItem value="EUR">EUR</SelectItem> - <SelectItem value="GBP">GBP</SelectItem> - <SelectItem value="KRW">KRW</SelectItem> - <SelectItem value="JPY">JPY</SelectItem> - </SelectGroup> - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - </div> - - {/* Other form fields remain the same */} - <FormField - control={form.control} - name="paymentTerms" - render={({ field }) => ( - <FormItem> - <FormLabel>Payment Terms</FormLabel> - <FormControl> - <Input placeholder="e.g. Net 30" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="incoterms" - render={({ field }) => ( - <FormItem> - <FormLabel>Incoterms</FormLabel> - <Select - onValueChange={field.onChange} - defaultValue={field.value || ''} - > - <FormControl> - <SelectTrigger> - <SelectValue placeholder="Select incoterms" /> - </SelectTrigger> - </FormControl> - <SelectContent> - <SelectGroup> - <SelectItem value="EXW">EXW (Ex Works)</SelectItem> - <SelectItem value="FCA">FCA (Free Carrier)</SelectItem> - <SelectItem value="FOB">FOB (Free On Board)</SelectItem> - <SelectItem value="CIF">CIF (Cost, Insurance & Freight)</SelectItem> - <SelectItem value="DAP">DAP (Delivered At Place)</SelectItem> - <SelectItem value="DDP">DDP (Delivered Duty Paid)</SelectItem> - </SelectGroup> - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="deliveryPeriod" - render={({ field }) => ( - <FormItem> - <FormLabel>Delivery Period</FormLabel> - <FormControl> - <Input placeholder="e.g. 4-6 weeks" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="warrantyPeriod" - render={({ field }) => ( - <FormItem> - <FormLabel>Warranty Period</FormLabel> - <FormControl> - <Input placeholder="e.g. 12 months" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="validityPeriod" - render={({ field }) => ( - <FormItem> - <FormLabel>Validity Period</FormLabel> - <FormControl> - <Input placeholder="e.g. 30 days" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="priceBreakdown" - render={({ field }) => ( - <FormItem> - <FormLabel>Price Breakdown (Optional)</FormLabel> - <FormControl> - <Textarea - placeholder="Enter price breakdown details here" - className="min-h-[100px]" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="commercialNotes" - render={({ field }) => ( - <FormItem> - <FormLabel>Additional Notes (Optional)</FormLabel> - <FormControl> - <Textarea - placeholder="Any additional comments or notes" - className="min-h-[100px]" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <SheetFooter className="gap-2 pt-4 sm:space-x-0"> - <SheetClose asChild> - <Button type="button" variant="outline"> - Cancel - </Button> - </SheetClose> - <Button disabled={isSubmitting} type="submit"> - {isSubmitting && ( - <Loader - className="mr-2 size-4 animate-spin" - aria-hidden="true" - /> - )} - Submit Response - </Button> - </SheetFooter> - </form> - </Form> - )} - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/tech-vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx b/lib/tech-vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx deleted file mode 100644 index a6ec6072..00000000 --- a/lib/tech-vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx +++ /dev/null @@ -1,79 +0,0 @@ -"use client" - -import * as React from "react" -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { Badge } from "@/components/ui/badge" -import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" -import { RfqItemsTable } from "./rfq-items-table/rfq-items-table" -import { formatDateTime } from "@/lib/utils" -import { CalendarClock } from "lucide-react" - -interface RfqDeailDialogProps { - isOpen: boolean - onOpenChange: (open: boolean) => void - rfqId: number | null - rfq: VendorWithCbeFields | null -} - -export function RfqDeailDialog({ - isOpen, - onOpenChange, - rfqId, - rfq, -}: RfqDeailDialogProps) { - return ( - <Dialog open={isOpen} onOpenChange={onOpenChange}> - <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{ maxWidth: 1000, height: 480 }}> - <DialogHeader> - <div className="flex flex-col space-y-2"> - <DialogTitle>프로젝트: {rfq && rfq.projectName}({rfq && rfq.projectCode}) / RFQ: {rfq && rfq.rfqCode} Detail</DialogTitle> - {rfq && ( - <div className="flex flex-col space-y-3 mt-2"> - <div className="text-sm text-muted-foreground"> - <span className="font-medium text-foreground">{rfq.rfqDescription && rfq.rfqDescription}</span> - </div> - - {/* 정보를 두 행으로 나누어 표시 */} - <div className="flex flex-col space-y-2 sm:space-y-0 sm:flex-row sm:justify-between sm:items-center"> - {/* 첫 번째 행: 상태 배지 */} - <div className="flex items-center flex-wrap gap-2"> - - - - {rfq.vendorStatus && ( - <Badge variant="outline"> - RFQ 상태: {rfq.rfqStatus} - </Badge> - )} - - </div> - - {/* 두 번째 행: Due Date를 강조 표시 */} - {rfq.rfqDueDate && ( - <div className="flex items-center"> - <Badge variant="secondary" className="flex gap-1 text-xs py-1 px-3"> - <CalendarClock className="h-3.5 w-3.5" /> - <span className="font-semibold">Due Date:</span> - <span>{formatDateTime(rfq.rfqDueDate)}</span> - </Badge> - </div> - )} - </div> - </div> - )} - </div> - </DialogHeader> - {rfqId && ( - <div className="py-4"> - <RfqItemsTable rfqId={rfqId} /> - </div> - )} - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/tech-vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx b/lib/tech-vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx deleted file mode 100644 index bf4ae709..00000000 --- a/lib/tech-vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"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 { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header" -import { formatDate } from "@/lib/utils" -import { Checkbox } from "@/components/ui/checkbox" -import { ItemData } from "./rfq-items-table" - - -/** getColumns: return array of ColumnDef for 'vendors' data */ -export function getColumns(): ColumnDef<ItemData>[] { - return [ - - // Vendor Name - { - accessorKey: "itemCode", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Item Code" /> - ), - cell: ({ row }) => row.getValue("itemCode"), - }, - - // Vendor Code - { - accessorKey: "description", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Description" /> - ), - cell: ({ row }) => row.getValue("description"), - }, - - // Status - { - accessorKey: "quantity", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Quantity" /> - ), - cell: ({ row }) => row.getValue("quantity"), - }, - - - // 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/tech-vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx b/lib/tech-vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx deleted file mode 100644 index c5c67e54..00000000 --- a/lib/tech-vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx +++ /dev/null @@ -1,86 +0,0 @@ -'use client' - -import * as React from "react" -import { ClientDataTable } from "@/components/client-data-table/data-table" -import { getColumns } from "./rfq-items-table-column" -import { DataTableAdvancedFilterField } from "@/types/table" -import { Loader2 } from "lucide-react" -import { useToast } from "@/hooks/use-toast" -import { getItemsByRfqId } from "../../service" - -export interface ItemData { - id: number - itemCode: string - description: string | null - quantity: number - uom: string | null - createdAt: Date - updatedAt: Date -} - -interface RFQItemsTableProps { - rfqId: number -} - -export function RfqItemsTable({ rfqId }: RFQItemsTableProps) { - const { toast } = useToast() - - const columns = React.useMemo( - () => getColumns(), - [] - ) - - const [rfqItems, setRfqItems] = React.useState<ItemData[]>([]) - const [isLoading, setIsLoading] = React.useState(false) - - React.useEffect(() => { - async function loadItems() { - setIsLoading(true) - try { - // Use the correct function name (camelCase) - const result = await getItemsByRfqId(rfqId) - if (result.success && result.data) { - setRfqItems(result.data as ItemData[]) - } else { - throw new Error(result.error || "Unknown error occurred") - } - } catch (error) { - console.error("RFQ 아이템 로드 오류:", error) - toast({ - title: "Error", - description: "Failed to load RFQ items", - variant: "destructive", - }) - } finally { - setIsLoading(false) - } - } - loadItems() - }, [toast, rfqId]) - - const advancedFilterFields: DataTableAdvancedFilterField<ItemData>[] = [ - { id: "itemCode", label: "Item Code", type: "text" }, - { id: "description", label: "Description", type: "text" }, - { id: "quantity", label: "Quantity", type: "number" }, - { id: "uom", label: "UoM", type: "text" }, - ] - - // 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={rfqItems} - columns={columns} - advancedFilterFields={advancedFilterFields} - > - </ClientDataTable> - ) -}
\ No newline at end of file diff --git a/lib/tech-vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx b/lib/tech-vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx deleted file mode 100644 index da656356..00000000 --- a/lib/tech-vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx +++ /dev/null @@ -1,127 +0,0 @@ -"use client" - -import * as React from "react" -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" -import { RfqWithAll } from "../types" -/** - * 아이템 구조 예시 - * - API 응답에서 quantity가 "string" 형태이므로, - * 숫자로 사용하실 거라면 parse 과정이 필요할 수 있습니다. - */ -export interface RfqItem { - id: number - itemCode: string - itemName: string - itemList: string | null - subItemList: string | null - quantity: string - description: string - uom: string -} - -/** - * 첨부파일 구조 예시 - */ -export interface RfqAttachment { - id: number - fileName: string - filePath: string - vendorId: number | null - evaluationId: number | null -} - - -/** - * 다이얼로그 내에서만 사용할 단순 아이템 구조 (예: 임시/기본값 표출용) - */ -export interface DefaultItem { - id?: number - itemCode: string - description?: string | null - quantity?: number | null - uom?: string | null -} - -/** - * RfqsItemsDialog 컴포넌트 Prop 타입 - */ -export interface RfqsItemsDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - rfq: RfqWithAll - defaultItems?: DefaultItem[] -} - -export function RfqsItemsDialog({ - open, - onOpenChange, - rfq, -}: RfqsItemsDialogProps) { - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-none w-[1200px]"> - <DialogHeader> - <DialogTitle>Items for RFQ {rfq?.rfqCode}</DialogTitle> - <DialogDescription> - Below is the list of items for this RFQ. - </DialogDescription> - </DialogHeader> - - <div className="overflow-x-auto w-full space-y-4"> - {rfq && rfq.items.length === 0 && ( - <p className="text-sm text-muted-foreground">No items found.</p> - )} - {rfq && rfq.items.length > 0 && ( - <Table> - {/* 필요에 따라 TableCaption 등을 추가해도 좋습니다. */} - <TableHeader> - <TableRow> - <TableHead>Item Code</TableHead> - <TableHead>Item List</TableHead> - <TableHead>Sub Item List</TableHead> - <TableHead>Description</TableHead> - <TableHead>Qty</TableHead> - <TableHead>UoM</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {rfq.items.map((it, idx) => ( - <TableRow key={it.id ?? idx}> - <TableCell>{it.itemCode || "No Code"}</TableCell> - <TableCell>{it.itemList || "-"}</TableCell> - <TableCell>{it.subItemList || "-"}</TableCell> - <TableCell>{it.description || "-"}</TableCell> - <TableCell>{it.quantity ?? 1}</TableCell> - <TableCell>{it.uom ?? "each"}</TableCell> - </TableRow> - ))} - </TableBody> - </Table> - )} - </div> - - <DialogFooter className="mt-4"> - <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> - Close - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/tech-vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx b/lib/tech-vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx deleted file mode 100644 index 6c51c12c..00000000 --- a/lib/tech-vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx +++ /dev/null @@ -1,106 +0,0 @@ -"use client" - -import * as React from "react" -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetDescription, - SheetFooter, - SheetClose, -} from "@/components/ui/sheet" -import { Button } from "@/components/ui/button" -import { Download } from "lucide-react" -import { formatDate } from "@/lib/utils" - -// 첨부파일 구조 -interface RfqAttachment { - id: number - fileName: string - filePath: string - createdAt?: Date // or Date - vendorId?: number | null - size?: number -} - -// 컴포넌트 Prop -interface RfqAttachmentsSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { - rfqId: number - attachments?: RfqAttachment[] -} - -/** - * RfqAttachmentsSheet: - * - 단순히 첨부파일 리스트 + 다운로드 버튼만 - */ -export function RfqAttachmentsSheet({ - rfqId, - attachments = [], - ...props -}: RfqAttachmentsSheetProps) { - return ( - <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-sm"> - <SheetHeader> - <SheetTitle>Attachments</SheetTitle> - <SheetDescription>RFQ #{rfqId}에 대한 첨부파일 목록</SheetDescription> - </SheetHeader> - - <div className="space-y-2"> - {/* 첨부파일이 없을 경우 */} - {attachments.length === 0 && ( - <p className="text-sm text-muted-foreground"> - No attachments - </p> - )} - - {/* 첨부파일 목록 */} - {attachments.map((att) => ( - <div - key={att.id} - className="flex items-center justify-between rounded border p-2" - > - <div className="flex flex-col text-sm"> - <span className="font-medium">{att.fileName}</span> - {att.size && ( - <span className="text-xs text-muted-foreground"> - {Math.round(att.size / 1024)} KB - </span> - )} - {att.createdAt && ( - <span className="text-xs text-muted-foreground"> - Created at {formatDate(att.createdAt)} - </span> - )} - </div> - {/* 파일 다운로드 버튼 */} - {att.filePath && ( - <a - href={att.filePath} - download - target="_blank" - rel="noreferrer" - className="text-sm" - > - <Button variant="ghost" size="icon" type="button"> - <Download className="h-4 w-4" /> - </Button> - </a> - )} - </div> - ))} - </div> - - <SheetFooter className="gap-2 pt-2"> - {/* 닫기 버튼 */} - <SheetClose asChild> - <Button type="button" variant="outline"> - Close - </Button> - </SheetClose> - </SheetFooter> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/tech-vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx b/lib/tech-vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx deleted file mode 100644 index 8904fcff..00000000 --- a/lib/tech-vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx +++ /dev/null @@ -1,320 +0,0 @@ -"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 { formatDate } from "@/lib/utils" -import { createRfqCommentWithAttachments } from "@/lib/rfqs-tech/service" - - -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) { - - console.log(initialComments) - - 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/tech-vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx b/lib/tech-vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx deleted file mode 100644 index 81131894..00000000 --- a/lib/tech-vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"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/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx b/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx deleted file mode 100644 index 69a5e7e7..00000000 --- a/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx +++ /dev/null @@ -1,424 +0,0 @@ -"use client" - -import * as React from "react" -import { useRouter } from "next/navigation" -import { ColumnDef } from "@tanstack/react-table" -import { - Ellipsis, - MessageSquare, - Package, - Paperclip, -} from "lucide-react" -import { toast } from "sonner" - -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger -} from "@/components/ui/dropdown-menu" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { Badge } from "@/components/ui/badge" - -import { getErrorMessage } from "@/lib/handle-error" -import { formatDate, formatDateTime } from "@/lib/utils" -import { modifyRfqVendor } from "../../rfqs-tech/service" -import type { RfqWithAll } from "../types" -import type { DataTableRowAction } from "@/types/table" - -type NextRouter = ReturnType<typeof useRouter> - -interface GetColumnsProps { - setRowAction: React.Dispatch< - React.SetStateAction<DataTableRowAction<RfqWithAll> | null> - > - router: NextRouter - openAttachmentsSheet: (rfqId: number) => void - openCommentSheet: (rfqId: number) => void -} - -/** - * tanstack table 컬럼 정의 (Nested Header) - */ -export function getColumns({ - setRowAction, - router, - openAttachmentsSheet, - openCommentSheet, -}: GetColumnsProps): ColumnDef<RfqWithAll>[] { - // 1) 체크박스(Select) 컬럼 - const selectColumn: ColumnDef<RfqWithAll> = { - id: "select", - header: ({ table }) => ( - <Checkbox - checked={ - table.getIsAllPageRowsSelected() || - (table.getIsSomePageRowsSelected() && "indeterminate") - } - onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - /> - ), - cell: ({ row }) => ( - <Checkbox - checked={row.getIsSelected()} - onCheckedChange={(value) => row.toggleSelected(!!value)} - aria-label="Select row" - /> - ), - size: 40, - enableSorting: false, - enableHiding: false, - } - - // 2) Actions (Dropdown) - const actionsColumn: ColumnDef<RfqWithAll> = { - id: "actions", - enableHiding: false, - cell: ({ row }) => { - const [isUpdatePending, startUpdateTransition] = React.useTransition() - - return ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="ghost" size="icon"> - <Ellipsis className="h-4 w-4" aria-hidden="true" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-56"> - <DropdownMenuSub> - <DropdownMenuSubTrigger>RFQ Response</DropdownMenuSubTrigger> - <DropdownMenuSubContent> - <DropdownMenuRadioGroup - value={row.original.responseStatus} - onValueChange={(value) => { - startUpdateTransition(async () => { - let newStatus: - | "ACCEPTED" - | "DECLINED" - | "REVIEWING" - - switch (value) { - case "ACCEPTED": - newStatus = "ACCEPTED" - break - case "DECLINED": - newStatus = "DECLINED" - break - default: - newStatus = "REVIEWING" - } - - await toast.promise( - modifyRfqVendor({ - id: row.original.responseId, - status: newStatus, - }), - { - loading: "Updating response status...", - success: "Response status updated", - error: (err) => getErrorMessage(err), - } - ) - }) - }} - > - {[ - { value: "ACCEPTED", label: "Accept RFQ" }, - { value: "DECLINED", label: "Decline RFQ" }, - ].map((rep) => ( - <DropdownMenuRadioItem - key={rep.value} - value={rep.value} - className="capitalize" - disabled={isUpdatePending} - > - {rep.label} - </DropdownMenuRadioItem> - ))} - </DropdownMenuRadioGroup> - </DropdownMenuSubContent> - </DropdownMenuSub> - {/* <DropdownMenuItem - onClick={() => { - router.push(`/vendor/rfqs/${row.original.rfqId}`) - }} - > - View Details - </DropdownMenuItem> */} - {/* <DropdownMenuItem onClick={() => openAttachmentsSheet(row.original.rfqId)}> - View Attachments - </DropdownMenuItem> - <DropdownMenuItem onClick={() => openCommentSheet(row.original.rfqId)}> - View Comments - </DropdownMenuItem> - <DropdownMenuItem onClick={() => setRowAction({ row, type: "items" })}> - View Items - </DropdownMenuItem> */} - </DropdownMenuContent> - </DropdownMenu> - ) - }, - size: 40, - } - - // 3) RFQ Code 컬럼 - const rfqCodeColumn: ColumnDef<RfqWithAll> = { - id: "rfqCode", - accessorKey: "rfqCode", - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ Code" /> - ), - // cell: ({ row }) => { - // return ( - // <Button - // variant="link" - // className="p-0 h-auto font-medium" - // onClick={() => router.push(`/vendor/rfqs/${row.original.rfqId}`)} - // > - // {row.original.rfqCode} - // </Button> - // ) - // }, - cell: ({ row }) => row.original.rfqCode || "-", - size: 150, - } - - - - // 4) 응답 상태 컬럼 - const responseStatusColumn: ColumnDef<RfqWithAll> = { - id: "responseStatus", - accessorKey: "responseStatus", - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Response Status" /> - ), - cell: ({ row }) => { - const status = row.original.responseStatus; - let variant: "default" | "secondary" | "destructive" | "outline"; - - switch (status) { - case "REVIEWING": - variant = "default"; - break; - case "ACCEPTED": - variant = "secondary"; - break; - case "DECLINED": - variant = "destructive"; - break; - default: - variant = "outline"; - } - - return <Badge variant={variant}>{status}</Badge>; - }, - size: 150, - } - - // 5) 프로젝트 이름 컬럼 - const projectNameColumn: ColumnDef<RfqWithAll> = { - id: "projectName", - accessorKey: "projectName", - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Project" /> - ), - cell: ({ row }) => row.original.projectName || "-", - size: 150, - } - - // 6) RFQ Description 컬럼 - const descriptionColumn: ColumnDef<RfqWithAll> = { - id: "rfqDescription", - accessorKey: "rfqDescription", - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Description" /> - ), - cell: ({ row }) => row.original.rfqDescription || "-", - size: 200, - } - - // 7) Due Date 컬럼 - const dueDateColumn: ColumnDef<RfqWithAll> = { - id: "rfqDueDate", - accessorKey: "rfqDueDate", - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Due Date" /> - ), - cell: ({ row }) => { - const date = row.original.rfqDueDate; - return date ? formatDate(date) : "-"; - }, - size: 120, - } - - // 8) Last Updated 컬럼 - const updatedAtColumn: ColumnDef<RfqWithAll> = { - id: "respondedAt", - accessorKey: "respondedAt", - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Last Updated" /> - ), - cell: ({ row }) => { - const date = row.original.respondedAt; - return date ? formatDateTime(date) : "-"; - }, - size: 150, - } - - // 9) Items 컬럼 - 뱃지로 아이템 개수 표시 - const itemsColumn: ColumnDef<RfqWithAll> = { - id: "items", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Items" /> - ), - cell: ({ row }) => { - const rfq = row.original - const count = rfq.items?.length ?? 0 - - function handleClick() { - setRowAction({ row, type: "items" }) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={count > 0 ? `View ${count} items` : "No items"} - > - <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {count > 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" - > - {count} - </Badge> - )} - - <span className="sr-only"> - {count > 0 ? `${count} Items` : "No Items"} - </span> - </Button> - ) - }, - enableSorting: false, - maxSize: 80, - } - - // 10) Attachments 컬럼 - 뱃지로 파일 개수 표시 - const attachmentsColumn: ColumnDef<RfqWithAll> = { - id: "attachments", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Attachments" /> - ), - cell: ({ row }) => { - const attachCount = row.original.attachments?.length ?? 0 - - function handleClick(e: React.MouseEvent<HTMLButtonElement>) { - e.preventDefault() - openAttachmentsSheet(row.original.rfqId) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={ - attachCount > 0 ? `View ${attachCount} files` : "No files" - } - > - <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {attachCount > 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" - > - {attachCount} - </Badge> - )} - <span className="sr-only"> - {attachCount > 0 ? `${attachCount} Files` : "No Files"} - </span> - </Button> - ) - }, - enableSorting: false, - maxSize: 80, - } - - // 11) Comments 컬럼 - 뱃지로 댓글 개수 표시 - const commentsColumn: ColumnDef<RfqWithAll> = { - id: "comments", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Comments" /> - ), - cell: ({ row }) => { - const commCount = row.original.comments?.length ?? 0 - - function handleClick() { - setRowAction({ row, type: "comments" }) - openCommentSheet(row.original.rfqId) - } - - 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, - } - - // 최종 컬럼 구성 - TBE/CBE 관련 컬럼 제외 - return [ - selectColumn, - rfqCodeColumn, - responseStatusColumn, - projectNameColumn, - descriptionColumn, - dueDateColumn, - itemsColumn, - attachmentsColumn, - commentsColumn, - updatedAtColumn, - actionsColumn, - ] -}
\ No newline at end of file diff --git a/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx b/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx deleted file mode 100644 index 1bae99ef..00000000 --- a/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx +++ /dev/null @@ -1,40 +0,0 @@ -"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 { RfqWithAll } from "../types" - - -interface RfqsTableToolbarActionsProps { - table: Table<RfqWithAll> -} - -export function RfqsVendorTableToolbarActions({ table }: RfqsTableToolbarActionsProps) { - - - return ( - <div className="flex items-center gap-2"> - - {/** 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/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx b/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx deleted file mode 100644 index 2e5ae5dc..00000000 --- a/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx +++ /dev/null @@ -1,280 +0,0 @@ -"use client" - -import * as React from "react" -import type { - DataTableAdvancedFilterField, - DataTableFilterField, - DataTableRowAction, -} from "@/types/table" -import { useRouter } from "next/navigation" - -import { useDataTable } from "@/hooks/use-data-table" -import { DataTable } from "@/components/data-table/data-table" -import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" - -import { useFeatureFlags } from "./feature-flags-provider" -import { getColumns } from "./rfqs-table-columns" -import { RfqWithAll } from "../types" - -import { - fetchRfqAttachments, - fetchRfqAttachmentsbyCommentId, -} from "../../rfqs-tech/service" - -import { RfqsVendorTableToolbarActions } from "./rfqs-table-toolbar-actions" -import { RfqsItemsDialog } from "./ItemsDialog" -import { RfqAttachmentsSheet } from "./attachment-rfq-sheet" -import { CommentSheet } from "./comments-sheet" -import { getRfqResponsesForVendor } from "../service" -import { useSession } from "next-auth/react" // Next-auth session hook 추가 - -interface RfqsTableProps { - promises: Promise<[Awaited<ReturnType<typeof getRfqResponsesForVendor>>]> -} - -// 코멘트+첨부파일 구조 예시 -export interface RfqCommentWithAttachments { - id: number - commentText: string - commentedBy?: number - commentedByEmail?: string - createdAt?: Date - attachments?: { - id: number - fileName: string - filePath: string - }[] -} - -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 RfqsVendorTable({ promises }: RfqsTableProps) { - const { featureFlags } = useFeatureFlags() - const { data: session } = useSession() // 세션 정보 가져오기 - - // 1) 테이블 데이터( RFQs ) - const [{ data: responseData, pageCount }] = React.use(promises) - - // 데이터를 RfqWithAll 타입으로 변환 (id 필드 추가) - const data: RfqWithAll[] = React.useMemo(() => { - return responseData.map(item => ({ - ...item, - id: item.rfqId, // id 필드를 rfqId와 동일하게 설정 - })); - }, [responseData]); - - const router = useRouter() - - // 2) 첨부파일 시트 + 관련 상태 - const [attachmentsOpen, setAttachmentsOpen] = React.useState(false) - const [selectedRfqIdForAttachments, setSelectedRfqIdForAttachments] = React.useState<number | null>(null) - const [attachDefault, setAttachDefault] = React.useState<ExistingAttachment[]>([]) - - // 3) 코멘트 시트 + 관련 상태 - const [initialComments, setInitialComments] = React.useState<RfqCommentWithAttachments[]>([]) - const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) - const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) - - // 4) rowAction으로 다양한 모달/시트 열기 - const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqWithAll> | null>(null) - - // 열리고 닫힐 때마다, rowAction 등을 확인해서 시트 열기/닫기 처리 - React.useEffect(() => { - if (rowAction?.type === "comments" && rowAction?.row.original) { - openCommentSheet(rowAction.row.original.id) - } - }, [rowAction]) - - /** - * (A) 코멘트 시트를 열기 전에, - * DB에서 (rfqId에 해당하는) 코멘트들 + 각 코멘트별 첨부파일을 조회. - */ - const openCommentSheet = React.useCallback(async (rfqId: number) => { - setInitialComments([]) - - // 여기서 rowAction을 직접 참조하지 않고, 필요한 데이터만 파라미터로 받기 - const comments = data.find(rfq => rfq.rfqId === rfqId)?.comments || [] - - if (comments && comments.length > 0) { - const commentWithAttachments = await Promise.all( - comments.map(async (c) => { - const attachments = await fetchRfqAttachmentsbyCommentId(c.id) - return { - ...c, - commentedBy: c.commentedBy || 1, - attachments, - } - }) - ) - - setInitialComments(commentWithAttachments) - } - - setSelectedRfqIdForComments(rfqId) - setCommentSheetOpen(true) - }, [data]) // data만 의존성으로 추가 - - /** - * (B) 첨부파일 시트 열기 - */ - const openAttachmentsSheet = React.useCallback(async (rfqId: number) => { - const list = await fetchRfqAttachments(rfqId) - setAttachDefault(list) - setSelectedRfqIdForAttachments(rfqId) - setAttachmentsOpen(true) - }, []) - - // 5) DataTable 컬럼 세팅 - const columns = React.useMemo( - () => - getColumns({ - setRowAction, - router, - openAttachmentsSheet, - openCommentSheet - }), - [setRowAction, router, openAttachmentsSheet, openCommentSheet] - ) - - /** - * 간단한 filterFields 예시 - */ - const filterFields: DataTableFilterField<RfqWithAll>[] = [ - { - id: "rfqCode", - label: "RFQ Code", - placeholder: "Filter RFQ Code...", - }, - { - id: "projectName", - label: "Project", - placeholder: "Filter Project...", - }, - { - id: "rfqDescription", - label: "Description", - placeholder: "Filter Description...", - }, - ] - - /** - * Advanced filter fields 예시 - */ - const advancedFilterFields: DataTableAdvancedFilterField<RfqWithAll>[] = [ - { - id: "rfqCode", - label: "RFQ Code", - type: "text", - }, - { - id: "rfqDescription", - label: "Description", - type: "text", - }, - { - id: "projectCode", - label: "Project Code", - type: "text", - }, - { - id: "projectName", - label: "Project Name", - type: "text", - }, - { - id: "rfqDueDate", - label: "Due Date", - type: "date", - }, - { - id: "responseStatus", - label: "Response Status", - type: "select", - options: [ - { label: "Reviewing", value: "REVIEWING" }, - { label: "Accepted", value: "ACCEPTED" }, - { label: "Declined", value: "DECLINED" }, - ], - } - ] - - // useDataTable() 훅 -> pagination, sorting 등 관리 - const { table } = useDataTable({ - data, - columns, - pageCount, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: "respondedAt", desc: true }], - columnPinning: { right: ["actions"] }, - }, - getRowId: (originalRow) => String(originalRow.id), - shallow: false, - clearOnDefault: true, - }) - - const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0 - const currentVendorId = session?.user?.id ? session.user.companyId : 0 - - - - return ( - <> - <DataTable table={table}> - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - <RfqsVendorTableToolbarActions table={table} /> - </DataTableAdvancedToolbar> - </DataTable> - - {/* 1) 아이템 목록 Dialog */} - {rowAction?.type === "items" && rowAction?.row.original && ( - <RfqsItemsDialog - open={true} - onOpenChange={() => setRowAction(null)} - rfq={rowAction.row.original} - /> - )} - - {/* 2) 코멘트 시트 */} - {selectedRfqIdForComments && ( - <CommentSheet - open={commentSheetOpen} - onOpenChange={setCommentSheetOpen} - initialComments={initialComments} - rfqId={selectedRfqIdForComments} - vendorId={currentVendorId??0} - currentUserId={currentUserId} - /> - )} - - {/* 3) 첨부파일 시트 */} - <RfqAttachmentsSheet - open={attachmentsOpen} - onOpenChange={setAttachmentsOpen} - rfqId={selectedRfqIdForAttachments ?? 0} - attachments={attachDefault} - /> - </> - ) -}
\ No newline at end of file diff --git a/lib/tech-vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx b/lib/tech-vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx deleted file mode 100644 index 5e27a4aa..00000000 --- a/lib/tech-vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx +++ /dev/null @@ -1,348 +0,0 @@ -"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 { Loader, 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" - -// DB 스키마에서 필요한 타입들을 가져온다고 가정 -// (실제 프로젝트에 맞춰 import를 수정하세요.) -import { RfqWithAll } from "@/db/schema/rfq" -import { createRfqCommentWithAttachments } from "../../rfqs-tech/service" -import { formatDate } from "@/lib/utils" - -// 코멘트 + 첨부파일 구조 (단순 예시) -// 실제 DB 스키마에 맞춰 조정 -export interface TbeComment { - id: number - commentText: string - commentedBy?: number - createdAt?: string | Date - attachments?: { - id: number - fileName: string - filePath: string - }[] -} - -interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { - /** 코멘트를 작성할 RFQ 정보 */ - /** 이미 존재하는 모든 코멘트 목록 (서버에서 불러와 주입) */ - initialComments?: TbeComment[] - - /** 사용자(작성자) ID (로그인 세션 등에서 가져옴) */ - currentUserId: number - rfqId:number - tbeId:number - vendorId:number - /** 댓글 저장 후 갱신용 콜백 (옵션) */ - onCommentsUpdated?: (comments: TbeComment[]) => void - isLoading?: boolean // New prop - -} - -// 새 코멘트 작성 폼 스키마 -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 = [], - tbeId, - currentUserId, - onCommentsUpdated, - isLoading = false, // Default to false - ...props -}: CommentSheetProps) { - const [comments, setComments] = React.useState<TbeComment[]>(initialComments) - const [isPending, startTransition] = React.useTransition() - - React.useEffect(() => { - setComments(initialComments) - }, [initialComments]) - - - // RHF 세팅 - const form = useForm<CommentFormValues>({ - resolver: zodResolver(commentFormSchema), - defaultValues: { - commentText: "", - newFiles: [] - } - }) - - // formFieldArray 예시 (파일 목록) - const { fields: newFileFields, append, remove } = useFieldArray({ - control: form.control, - name: "newFiles" - }) - - // 1) 기존 코멘트 + 첨부 보여주기 - // 간단히 테이블 하나로 표현 - // 실제로는 Bubble 형태의 UI, Accordion, Timeline 등 다양하게 구성할 수 있음 - 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 || c.attachments.length === 0) && ( - <span className="text-sm text-muted-foreground">No files</span> - )} - {c.attachments && c.attachments.length > 0 && ( - <div className="flex flex-col gap-1"> - {c.attachments.map((att) => ( - <div key={att.id} className="flex items-center gap-2"> - <a - href={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.commentedBy ?? "-"} - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - ) - } - - // 2) 새 파일 Drop - function handleDropAccepted(files: File[]) { - // 드롭된 File[]을 RHF field array에 추가 - const toAppend = files.map((f) => f) - append(toAppend) - } - - - // 3) 저장(Submit) - async function onSubmit(data: CommentFormValues) { - - if (!rfqId) return - startTransition(async () => { - try { - // 서버 액션 호출 - const res = await createRfqCommentWithAttachments({ - rfqId: rfqId, - vendorId: vendorId, // 필요시 세팅 - commentText: data.commentText, - commentedBy: currentUserId, - evaluationId: tbeId, // 필요시 세팅 - files: data.newFiles - }) - - if (!res.ok) { - throw new Error("Failed to create comment") - } - - toast.success("Comment created") - - // 새 코멘트를 다시 불러오거나, - // 여기서는 임시로 "새로운 코멘트가 추가됐다" 라고 가정하여 클라이언트에서 상태 업데이트 - const newComment: TbeComment = { - id: res.commentId, // 서버에서 반환된 commentId - commentText: data.commentText, - commentedBy: currentUserId, - createdAt: new Date().toISOString(), - attachments: (data.newFiles?.map((f, idx) => ({ - id: Math.random() * 100000, - 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}> - <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 (파일 첨부) */} - <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 && <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/tech-vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx b/lib/tech-vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx deleted file mode 100644 index 26698c2e..00000000 --- a/lib/tech-vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx +++ /dev/null @@ -1,75 +0,0 @@ -"use client" - -import * as React from "react" -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { Badge } from "@/components/ui/badge" -import { formatDateTime } from "@/lib/utils" -import { CalendarClock } from "lucide-react" -import { RfqItemsTable } from "../vendor-cbe-table/rfq-items-table/rfq-items-table" -import { TbeVendorFields } from "@/config/vendorTbeColumnsConfig" - -interface RfqDeailDialogProps { - isOpen: boolean - onOpenChange: (open: boolean) => void - rfqId: number | null - rfq: TbeVendorFields | null -} - -export function RfqDeailDialog({ - isOpen, - onOpenChange, - rfqId, - rfq, -}: RfqDeailDialogProps) { - return ( - <Dialog open={isOpen} onOpenChange={onOpenChange}> - <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1000, height:480}}> - <DialogHeader> - <div className="flex flex-col space-y-2"> - <DialogTitle>{rfq && rfq.rfqCode} Detail</DialogTitle> - {rfq && ( - <div className="flex flex-col space-y-3 mt-2"> - <div className="text-sm text-muted-foreground"> - <span className="font-medium text-foreground">{rfq.rfqDescription && rfq.rfqDescription}</span> - </div> - - {/* 정보를 두 행으로 나누어 표시 */} - <div className="flex flex-col space-y-2 sm:space-y-0 sm:flex-row sm:justify-between sm:items-center"> - {/* 첫 번째 행: 상태 배지 */} - <div className="flex items-center flex-wrap gap-2"> - {rfq.vendorStatus && ( - <Badge variant="outline"> - {rfq.rfqStatus} - </Badge> - )} - </div> - - {/* 두 번째 행: Due Date를 강조 표시 */} - {rfq.rfqDueDate && ( - <div className="flex items-center"> - <Badge variant="secondary" className="flex gap-1 text-xs py-1 px-3"> - <CalendarClock className="h-3.5 w-3.5" /> - <span className="font-semibold">Due Date:</span> - <span>{formatDateTime(rfq.rfqDueDate)}</span> - </Badge> - </div> - )} - </div> - </div> - )} - </div> - </DialogHeader> - {rfqId && ( - <div className="py-4"> - <RfqItemsTable rfqId={rfqId} /> - </div> - )} - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/tech-vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx b/lib/tech-vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx deleted file mode 100644 index b880506a..00000000 --- a/lib/tech-vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx +++ /dev/null @@ -1,350 +0,0 @@ -"use client" - -import * as React from "react" -import { type DataTableRowAction } from "@/types/table" -import { type ColumnDef } from "@tanstack/react-table" -import { Download, MessageSquare, Upload } from "lucide-react" -import { toast } from "sonner" - -import { getErrorMessage } from "@/lib/handle-error" -import { formatDate } from "@/lib/utils" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { useRouter } from "next/navigation" - -import { - tbeVendorColumnsConfig, - VendorTbeColumnConfig, - vendorTbeColumnsConfig, - TbeVendorFields, -} from "@/config/vendorTbeColumnsConfig" - -type NextRouter = ReturnType<typeof useRouter> - -interface GetColumnsProps { - setRowAction: React.Dispatch< - React.SetStateAction<DataTableRowAction<TbeVendorFields> | null> - > - router: NextRouter - openCommentSheet: (vendorId: number) => void - handleDownloadTbeTemplate: (tbeId: number, vendorId: number, rfqId: number) => void - handleUploadTbeResponse: (tbeId: number, vendorId: number, rfqId: number, vendorResponseId:number) => void - openVendorContactsDialog: (rfqId: number, rfq: TbeVendorFields) => void // 수정된 시그니처 - -} - -/** - * tanstack table 컬럼 정의 (중첩 헤더 버전) - */ -export function getColumns({ - setRowAction, - router, - openCommentSheet, - handleDownloadTbeTemplate, - handleUploadTbeResponse, - openVendorContactsDialog -}: GetColumnsProps): ColumnDef<TbeVendorFields>[] { - // ---------------------------------------------------------------- - // 1) Select 컬럼 (체크박스) - // ---------------------------------------------------------------- - const selectColumn: ColumnDef<TbeVendorFields> = { - 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) 그룹화(Nested) 컬럼 구성 - // ---------------------------------------------------------------- - const groupMap: Record<string, ColumnDef<TbeVendorFields>[]> = {} - - tbeVendorColumnsConfig.forEach((cfg) => { - const groupName = cfg.group || "_noGroup" - if (!groupMap[groupName]) { - groupMap[groupName] = [] - } - - // childCol: ColumnDef<TbeVendorFields> - const childCol: ColumnDef<TbeVendorFields> = { - accessorKey: cfg.id, - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={cfg.label} /> - ), - meta: { - excelHeader: cfg.excelHeader, - group: cfg.group, - type: cfg.type, - }, - maxSize: 120, - // 셀 렌더링 - cell: ({ row, getValue }) => { - // 1) 필드값 가져오기 - const val = getValue() - - if (cfg.id === "vendorStatus") { - const statusVal = row.original.vendorStatus - if (!statusVal) return null - // const Icon = getStatusIcon(statusVal) - return ( - <Badge variant="outline"> - {statusVal} - </Badge> - ) - } - - - if (cfg.id === "rfqCode") { - const rfq = row.original; - const rfqId = rfq.rfqId; - - // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링 - const handleVendorNameClick = () => { - if (rfqId) { - openVendorContactsDialog(rfqId, rfq); // vendor 전체 객체 전달 - } else { - toast.error("협력업체 ID를 찾을 수 없습니다."); - } - }; - - return ( - <Button - variant="link" - className="p-0 h-auto text-left font-normal justify-start hover:underline" - onClick={handleVendorNameClick} - > - {val as string} - </Button> - ); - } - if (cfg.id === "rfqVendorStatus") { - const statusVal = row.original.rfqVendorStatus - if (!statusVal) return null - // const Icon = getStatusIcon(statusVal) - const variant = statusVal === "INVITED" ? "default" : statusVal === "REJECTED" ? "destructive" : statusVal === "ACCEPTED" ? "secondary" : "outline" - return ( - <Badge variant={variant}> - {statusVal} - </Badge> - ) - } - - // 예) TBE Updated (날짜) - if (cfg.id === "tbeUpdated") { - const dateVal = val as Date | undefined - if (!dateVal) return null - return formatDate(dateVal) - } - - // 그 외 필드는 기본 값 표시 - return val ?? "" - }, - } - - groupMap[groupName].push(childCol) - }) - - // groupMap → nestedColumns - const nestedColumns: ColumnDef<TbeVendorFields>[] = [] - Object.entries(groupMap).forEach(([groupName, colDefs]) => { - if (groupName === "_noGroup") { - nestedColumns.push(...colDefs) - } else { - nestedColumns.push({ - id: groupName, - header: groupName, - columns: colDefs, - }) - } - }) - - // ---------------------------------------------------------------- - // 3) Comments 컬럼 - // ---------------------------------------------------------------- - const commentsColumn: ColumnDef<TbeVendorFields> = { - id: "comments", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Comments" /> - ), - cell: ({ row }) => { - const vendor = row.original - const commCount = vendor.comments?.filter(c => c.evaluationId === vendor.tbeId)?.length ?? 0 - - function handleClick() { - // rowAction + openCommentSheet - setRowAction({ row, type: "comments" }) - openCommentSheet(vendor.tbeId ?? 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) TBE 다운로드 컬럼 - 템플릿 다운로드 기능 - // ---------------------------------------------------------------- - const tbeDownloadColumn: ColumnDef<TbeVendorFields> = { - id: "tbeDownload", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="TBE Sheets" /> - ), - cell: ({ row }) => { - const vendor = row.original - const tbeId = vendor.tbeId - const vendorId = vendor.vendorId - const rfqId = vendor.rfqId - const templateFileCount = vendor.templateFileCount || 0 - - if (!tbeId || !vendorId || !rfqId) { - return <div className="text-center text-muted-foreground">-</div> - } - - // 템플릿 파일이 없으면 다운로드 버튼 비활성화 - const isDisabled = templateFileCount <= 0 - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={ - isDisabled - ? undefined - : () => handleDownloadTbeTemplate(tbeId, vendorId, rfqId) - } - aria-label={ - templateFileCount > 0 - ? `TBE 템플릿 다운로드 (${templateFileCount}개)` - : "다운로드할 파일 없음" - } - disabled={isDisabled} - > - <Download className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - - {/* 파일이 1개 이상인 경우 뱃지로 개수 표시 */} - {templateFileCount > 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" - > - {templateFileCount} - </Badge> - )} - - <span className="sr-only"> - {templateFileCount > 0 - ? `TBE 템플릿 다운로드 (${templateFileCount}개)` - : "다운로드할 파일 없음"} - </span> - </Button> - ) - }, - enableSorting: false, - maxSize: 80, - } - // ---------------------------------------------------------------- - // 5) TBE 업로드 컬럼 - 응답 업로드 기능 - // ---------------------------------------------------------------- - const tbeUploadColumn: ColumnDef<TbeVendorFields> = { - id: "tbeUpload", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Upload Response" /> - ), - cell: ({ row }) => { - const vendor = row.original - const tbeId = vendor.tbeId - const vendorId = vendor.vendorId - const rfqId = vendor.rfqId - const vendorResponseId = vendor.vendorResponseId || 0 - const status = vendor.rfqVendorStatus - const hasResponse = vendor.hasResponse || false - - - if (!tbeId || !vendorId || !rfqId || status === "REJECTED") { - return <div className="text-center text-muted-foreground">-</div> - } - - return ( - <div > - <Button - variant="ghost" - size="sm" - className="h-8 w-8 p-0 group relative" - onClick={() => handleUploadTbeResponse(tbeId, vendorId, rfqId, vendorResponseId)} - aria-label={hasResponse ? "TBE 응답 확인" : "TBE 응답 업로드"} - > - <div className="flex items-center justify-center relative"> - <Upload className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - </div> - {hasResponse && ( - <span className="absolute -top-1 -right-1 inline-flex h-2 w-2 rounded-full" style={{ backgroundColor: '#10B981' }}></span> - )} - <span className="sr-only"> - {"TBE 응답 업로드"} - </span> - </Button> - </div> - ) - }, - enableSorting: false, - maxSize: 80 - } - - // ---------------------------------------------------------------- - // 6) 최종 컬럼 배열 - // ---------------------------------------------------------------- - return [ - selectColumn, - ...nestedColumns, - commentsColumn, - tbeDownloadColumn, - tbeUploadColumn, - ] -}
\ No newline at end of file diff --git a/lib/tech-vendor-rfq-response/vendor-tbe-table/tbe-table.tsx b/lib/tech-vendor-rfq-response/vendor-tbe-table/tbe-table.tsx deleted file mode 100644 index 2de2dd11..00000000 --- a/lib/tech-vendor-rfq-response/vendor-tbe-table/tbe-table.tsx +++ /dev/null @@ -1,191 +0,0 @@ -"use client" - -import * as React from "react" -import { useRouter } from "next/navigation" -import type { - DataTableAdvancedFilterField, - DataTableFilterField, - DataTableRowAction, -} from "@/types/table" -import { toast } from "sonner" -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 "./tbe-table-columns" -import { fetchRfqAttachmentsbyCommentId, getTBEforVendor } from "../../rfqs-tech/service" -import { CommentSheet, TbeComment } from "./comments-sheet" -import { TbeVendorFields } from "@/config/vendorTbeColumnsConfig" -import { useTbeFileHandlers } from "./tbeFileHandler" -import { useSession } from "next-auth/react" -import { RfqDeailDialog } from "./rfq-detail-dialog" - -interface VendorsTableProps { - promises: Promise< - [ - Awaited<ReturnType<typeof getTBEforVendor>>, - ] - > -} - -export function TbeVendorTable({ promises }: VendorsTableProps) { - const { data: session } = useSession() - const userVendorId = session?.user?.companyId - const userId = Number(session?.user?.id) - console.log("userVendorId", userVendorId) - console.log("userId", userId) - // Suspense로 받아온 데이터 - const [{ data, pageCount }] = React.use(promises) - const [rowAction, setRowAction] = React.useState<DataTableRowAction<TbeVendorFields> | null>(null) - - - // router 획득 - const router = useRouter() - - const [initialComments, setInitialComments] = React.useState<TbeComment[]>([]) - const [isLoadingComments, setIsLoadingComments] = React.useState(false) - - const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) - const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(0) - const [isRfqDetailDialogOpen, setIsRfqDetailDialogOpen] = React.useState(false) - - const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null) - const [selectedRfq, setSelectedRfq] = React.useState<TbeVendorFields | null>(null) - - const openVendorContactsDialog = (rfqId: number, rfq: TbeVendorFields) => { - setSelectedRfqId(rfqId) - setSelectedRfq(rfq) - setIsRfqDetailDialogOpen(true) - } - - // TBE 파일 핸들러 훅 사용 - const { - handleDownloadTbeTemplate, - handleUploadTbeResponse, - UploadDialog, - } = useTbeFileHandlers() - - React.useEffect(() => { - if (rowAction?.type === "comments") { - // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행 - openCommentSheet(Number(rowAction.row.original.tbeId)) - } - }, [rowAction]) - - async function openCommentSheet(tbeId: number) { - setInitialComments([]) - setIsLoadingComments(true) - - const comments = rowAction?.row.original.comments?.filter(c => c.evaluationId === tbeId) - - try { - if (comments && comments.length > 0) { - const commentWithAttachments: TbeComment[] = await Promise.all( - comments.map(async (c) => { - // 서버 액션을 사용하여 코멘트 첨부 파일 가져오기 - const attachments = await fetchRfqAttachmentsbyCommentId(c.id) - - return { - ...c, - commentedBy: userId, // DB나 API 응답에 있다고 가정 - attachments, - } - }) - ) - - setInitialComments(commentWithAttachments) - } - - setSelectedRfqIdForComments(rowAction?.row.original.rfqId ?? null) - setCommentSheetOpen(true) - - } catch (error) { - console.error("Error loading comments:", error) - toast.error("Failed to load comments") - } finally { - // End loading regardless of success/failure - setIsLoadingComments(false) - } -} - - // getColumns() 호출 시, 필요한 모든 핸들러 함수 주입 - const columns = React.useMemo( - () => getColumns({ - setRowAction, - router, - openCommentSheet, - handleDownloadTbeTemplate, - handleUploadTbeResponse, - openVendorContactsDialog - }), - [setRowAction, router, openCommentSheet, handleDownloadTbeTemplate, handleUploadTbeResponse, openVendorContactsDialog] - ) - - const filterFields: DataTableFilterField<TbeVendorFields>[] = [] - - const advancedFilterFields: DataTableAdvancedFilterField<TbeVendorFields>[] = [ - { id: "rfqCode", label: "RFQ Code", type: "text" }, - { id: "projectCode", label: "Project Code", type: "text" }, - { id: "projectName", label: "Project Name", type: "text" }, - { id: "rfqCode", label: "RFQ Code", type: "text" }, - { id: "tbeResult", label: "TBE Result", type: "text" }, - { id: "tbeNote", label: "TBE Note", type: "text" }, - { id: "rfqCode", label: "RFQ Code", type: "text" }, - { id: "hasResponse", label: "Response?", type: "boolean" }, - { id: "rfqVendorUpdated", label: "Updated at", type: "date" }, - { id: "dueDate", label: "Project Name", type: "date" }, - - ] - - const { table } = useDataTable({ - data, - columns, - pageCount, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: "rfqVendorUpdated", desc: true }], - columnPinning: { right: ["comments", "tbeDocuments"] }, // tbeDocuments 컬럼을 우측에 고정 - }, - getRowId: (originalRow) => String(originalRow.rfqId), - shallow: false, - clearOnDefault: true, - }) - - return ( - <> - <DataTable table={table}> - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - /> - </DataTable> - - {/* 코멘트 시트 */} - {commentSheetOpen && selectedRfqIdForComments && ( - <CommentSheet - open={commentSheetOpen} - onOpenChange={setCommentSheetOpen} - rfqId={selectedRfqIdForComments} - tbeId={rowAction?.row.original.tbeId || 0} - initialComments={initialComments} - vendorId={userVendorId || 0} - currentUserId={userId || 0} - isLoading={isLoadingComments} // Pass the loading state - - /> - )} - - <RfqDeailDialog - isOpen={isRfqDetailDialogOpen} - onOpenChange={setIsRfqDetailDialogOpen} - rfqId={selectedRfqId} - rfq={selectedRfq} - /> - - {/* TBE 파일 다이얼로그 */} - <UploadDialog /> - </> - ) -}
\ No newline at end of file diff --git a/lib/tech-vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx b/lib/tech-vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx deleted file mode 100644 index 6c622fd1..00000000 --- a/lib/tech-vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx +++ /dev/null @@ -1,354 +0,0 @@ -"use client"; - -import { useCallback, useState, useEffect } from "react"; -import { toast } from "sonner"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { - fetchTbeTemplateFiles, - uploadTbeResponseFile, - getTbeSubmittedFiles, - getFileFromRfqAttachmentsbyid, -} from "../../rfqs-tech/service"; -import { - Dropzone, - DropzoneDescription, - DropzoneInput, - DropzoneTitle, - DropzoneUploadIcon, - DropzoneZone, -} from "@/components/ui/dropzone"; -import { - FileList, - FileListAction, - FileListDescription, - FileListIcon, - FileListInfo, - FileListItem, - FileListName, - FileListSize, -} from "@/components/ui/file-list"; -import { Download, X } from "lucide-react"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { formatDateTime } from "@/lib/utils"; - -export function useTbeFileHandlers() { - // 모달 열림 여부, 현재 선택된 IDs - const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false); - const [currentTbeId, setCurrentTbeId] = useState<number | null>(null); - const [currentVendorId, setCurrentVendorId] = useState<number | null>(null); - const [currentRfqId, setCurrentRfqId] = useState<number | null>(null); - const [currentvendorResponseId, setCurrentvendorResponseId] = useState<number | null>(null); - - - - // 로딩 상태들 - const [isLoading, setIsLoading] = useState(false); - const [isFetchingFiles, setIsFetchingFiles] = useState(false); - - // 업로드할 파일, 제출된 파일 목록 - const [selectedFile, setSelectedFile] = useState<File | null>(null); - const [submittedFiles, setSubmittedFiles] = useState< - Array<{ id: number; fileName: string; filePath: string; uploadedAt: Date }> - >([]); - - // =================================== - // 1) 제출된 파일 목록 가져오기 - // =================================== - const fetchSubmittedFiles = useCallback(async (vendorResponseId: number) => { - if (!vendorResponseId ) return; - - setIsFetchingFiles(true); - try { - const { files, error } = await getTbeSubmittedFiles(vendorResponseId); - if (error) { - console.error(error); - return; - } - setSubmittedFiles(files); - } catch (error) { - console.error("Failed to fetch submitted files:", error); - } finally { - setIsFetchingFiles(false); - } - }, []); - - // =================================== - // 2) TBE 템플릿 다운로드 - // =================================== - const handleDownloadTbeTemplate = useCallback( - async (tbeId: number, vendorId: number, rfqId: number) => { - setCurrentTbeId(tbeId); - setCurrentVendorId(vendorId); - setCurrentRfqId(rfqId); - setIsLoading(true); - - try { - const { files, error } = await fetchTbeTemplateFiles(tbeId); - if (error) { - toast.error(error); - return; - } - if (files.length === 0) { - toast.warning("다운로드할 템플릿 파일이 없습니다"); - return; - } - // 순차적으로 파일 다운로드 - for (const file of files) { - await downloadFile(file.id); - } - toast.success("모든 템플릿 파일이 다운로드되었습니다"); - } catch (error) { - toast.error("템플릿 파일을 다운로드하는 데 실패했습니다"); - console.error(error); - } finally { - setIsLoading(false); - } - }, - [] - ); - - // 실제 다운로드 로직 - const downloadFile = useCallback(async (fileId: number) => { - try { - const { file, error } = await getFileFromRfqAttachmentsbyid(fileId); - if (error || !file) { - throw new Error(error || "파일 정보를 가져오는 데 실패했습니다"); - } - - const link = document.createElement("a"); - link.href = `/api/rfq-download?path=${encodeURIComponent(file.filePath)}`; - link.download = file.fileName; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - return true; - } catch (error) { - console.error(error); - return false; - } - }, []); - - // =================================== - // 3) 제출된 파일 다운로드 - // =================================== - const downloadSubmittedFile = useCallback((file: { id: number; fileName: string; filePath: string }) => { - try { - const link = document.createElement("a"); - link.href = `/api/tbe-download?path=${encodeURIComponent(file.filePath)}`; - link.download = file.fileName; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - toast.success(`${file.fileName} 다운로드 시작`); - } catch (error) { - console.error("Failed to download file:", error); - toast.error("파일 다운로드에 실패했습니다"); - } - }, []); - - // =================================== - // 4) TBE 응답 업로드 모달 열기 - // (이 시점에서는 데이터 fetch하지 않음) - // =================================== - const handleUploadTbeResponse = useCallback((tbeId: number, vendorId: number, rfqId: number, vendorResponseId:number) => { - setCurrentTbeId(tbeId); - setCurrentVendorId(vendorId); - setCurrentRfqId(rfqId); - setCurrentvendorResponseId(vendorResponseId); - setIsUploadDialogOpen(true); - }, []); - - // =================================== - // 5) Dialog 열고 닫힐 때 상태 초기화 - // 열렸을 때 -> useEffect로 파일 목록 가져오기 - // =================================== - useEffect(() => { - if (!isUploadDialogOpen) { - // 닫힐 때는 파일 상태들 초기화 - setSelectedFile(null); - setSubmittedFiles([]); - } - }, [isUploadDialogOpen]); - - useEffect(() => { - // Dialog가 열렸고, ID들이 유효하면 - if (isUploadDialogOpen &¤tvendorResponseId) { - fetchSubmittedFiles(currentvendorResponseId); - } - }, [isUploadDialogOpen, currentvendorResponseId, fetchSubmittedFiles]); - - // =================================== - // 6) 드롭존 파일 선택 & 제거 - // =================================== - const handleFileDrop = useCallback((files: File[]) => { - if (files && files.length > 0) { - setSelectedFile(files[0]); - } - }, []); - - const handleRemoveFile = useCallback(() => { - setSelectedFile(null); - }, []); - - // =================================== - // 7) 응답 파일 업로드 - // =================================== - const handleSubmitResponse = useCallback(async () => { - if (!selectedFile || !currentTbeId || !currentVendorId || !currentRfqId ||!currentvendorResponseId) { - toast.error("업로드할 파일을 선택해주세요"); - return; - } - - setIsLoading(true); - try { - // FormData 생성 - const formData = new FormData(); - formData.append("file", selectedFile); - formData.append("rfqId", currentRfqId.toString()); - formData.append("vendorId", currentVendorId.toString()); - formData.append("evaluationId", currentTbeId.toString()); - formData.append("vendorResponseId", currentvendorResponseId.toString()); - - const result = await uploadTbeResponseFile(formData); - if (!result.success) { - throw new Error(result.error || "파일 업로드에 실패했습니다"); - } - - toast.success(result.message || "응답이 성공적으로 업로드되었습니다"); - - // 업로드 후 다시 제출된 파일 목록 가져오기 - await fetchSubmittedFiles(currentvendorResponseId); - - // 업로드 성공 시 선택 파일 초기화 - setSelectedFile(null); - - // 페이지 새로고침으로 테이블 데이터 업데이트 - window.location.reload(); - } catch (error) { - toast.error(error instanceof Error ? error.message : "응답 업로드에 실패했습니다"); - console.error(error); - } finally { - setIsLoading(false); - } - }, [selectedFile, currentTbeId, currentVendorId, currentRfqId, currentvendorResponseId,fetchSubmittedFiles]); - - // =================================== - // 8) 실제 Dialog 컴포넌트 - // =================================== - const UploadDialog = () => ( - <Dialog open={isUploadDialogOpen} onOpenChange={setIsUploadDialogOpen}> - <DialogContent className="sm:max-w-lg"> - <DialogHeader> - <DialogTitle>TBE 응답 파일</DialogTitle> - <DialogDescription>제출된 파일을 확인하거나 새 파일을 업로드하세요.</DialogDescription> - </DialogHeader> - - <Tabs defaultValue="upload" className="w-full"> - <TabsList className="grid w-full grid-cols-2"> - <TabsTrigger value="upload">새 파일 업로드</TabsTrigger> - <TabsTrigger - value="submitted" - disabled={submittedFiles.length === 0} - className={submittedFiles.length > 0 ? "relative" : ""} - > - 제출된 파일{" "} - {submittedFiles.length > 0 && ( - <span className="ml-2 inline-flex items-center justify-center rounded-full bg-primary w-4 h-4 text-[10px] text-primary-foreground"> - {submittedFiles.length} - </span> - )} - </TabsTrigger> - </TabsList> - - {/* 업로드 탭 */} - <TabsContent value="upload" className="pt-4"> - <div className="grid gap-4"> - {selectedFile ? ( - <FileList> - <FileListItem> - <FileListIcon /> - <FileListInfo> - <FileListName>{selectedFile.name}</FileListName> - <FileListSize>{selectedFile.size}</FileListSize> - </FileListInfo> - <FileListAction onClick={handleRemoveFile}> - <X className="h-4 w-4" /> - <span className="sr-only">파일 제거</span> - </FileListAction> - </FileListItem> - </FileList> - ) : ( - <Dropzone onDrop={handleFileDrop}> - <DropzoneInput className="sr-only" /> - <DropzoneZone className="flex flex-col items-center justify-center gap-2 p-6"> - <DropzoneUploadIcon className="h-10 w-10 text-muted-foreground" /> - <DropzoneTitle>파일을 드래그하거나 클릭하여 업로드</DropzoneTitle> - <DropzoneDescription>TBE 응답 파일 (XLSX, XLS, DOCX, PDF 등)</DropzoneDescription> - </DropzoneZone> - </Dropzone> - )} - - <DialogFooter className="mt-4"> - <Button type="submit" onClick={handleSubmitResponse} disabled={!selectedFile || isLoading}> - {isLoading ? "업로드 중..." : "응답 업로드"} - </Button> - </DialogFooter> - </div> - </TabsContent> - - {/* 제출된 파일 탭 */} - <TabsContent value="submitted" className="pt-4"> - {isFetchingFiles ? ( - <div className="flex justify-center items-center py-8"> - <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div> - </div> - ) : submittedFiles.length > 0 ? ( - <div className="grid gap-2"> - <FileList> - {submittedFiles.map((file) => ( - <FileListItem key={file.id} className="flex items-center justify-between gap-3"> - <div className="flex items-center gap-3 flex-1"> - <FileListIcon className="flex-shrink-0" /> - <FileListInfo className="flex-1 min-w-0"> - <FileListName className="text-sm font-medium truncate">{file.fileName}</FileListName> - <FileListDescription className="text-xs text-muted-foreground"> - {file.uploadedAt ? formatDateTime(file.uploadedAt) : ""} - </FileListDescription> - </FileListInfo> - </div> - <FileListAction className="flex-shrink-0 ml-2" onClick={() => downloadSubmittedFile(file)}> - <Download className="h-4 w-4" /> - <span className="sr-only">파일 다운로드</span> - </FileListAction> - </FileListItem> - ))} - </FileList> - </div> - ) : ( - <div className="text-center py-8 text-muted-foreground">제출된 파일이 없습니다.</div> - )} - </TabsContent> - </Tabs> - </DialogContent> - </Dialog> - ); - - // =================================== - // 9) Hooks 내보내기 - // =================================== - return { - handleDownloadTbeTemplate, - handleUploadTbeResponse, - UploadDialog, - }; -}
\ No newline at end of file |
