summaryrefslogtreecommitdiff
path: root/lib/vendor-rfq-response
diff options
context:
space:
mode:
Diffstat (limited to 'lib/vendor-rfq-response')
-rw-r--r--lib/vendor-rfq-response/service.ts464
-rw-r--r--lib/vendor-rfq-response/types.ts76
-rw-r--r--lib/vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx365
-rw-r--r--lib/vendor-rfq-response/vendor-cbe-table/cbe-table.tsx272
-rw-r--r--lib/vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx323
-rw-r--r--lib/vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx427
-rw-r--r--lib/vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx89
-rw-r--r--lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx62
-rw-r--r--lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx86
-rw-r--r--lib/vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx125
-rw-r--r--lib/vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx106
-rw-r--r--lib/vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx320
-rw-r--r--lib/vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx108
-rw-r--r--lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx435
-rw-r--r--lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx40
-rw-r--r--lib/vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx280
-rw-r--r--lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx346
-rw-r--r--lib/vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx86
-rw-r--r--lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx350
-rw-r--r--lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx188
-rw-r--r--lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx355
21 files changed, 0 insertions, 4903 deletions
diff --git a/lib/vendor-rfq-response/service.ts b/lib/vendor-rfq-response/service.ts
deleted file mode 100644
index 8f2954d7..00000000
--- a/lib/vendor-rfq-response/service.ts
+++ /dev/null
@@ -1,464 +0,0 @@
-'use server'
-
-import { revalidateTag, unstable_cache } 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 } from "@/db/schema/items";
-import { GetRfqsForVendorsSchema } from "../rfqs/validations";
-import { ItemData } from "./vendor-cbe-table/rfq-items-table/rfq-items-table";
-import * as z from "zod"
-
-
-
-export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, vendorId: number) {
- return unstable_cache(
- async () => {
- const offset = (input.page - 1) * input.perPage;
- const limit = input.perPage;
-
- // 1) 메인 쿼리: vendorResponsesView 사용
- const { rows, total } = await db.transaction(async (tx) => {
- // 검색 조건
- let globalWhere;
- if (input.search) {
- const s = `%${input.search}%`;
- globalWhere = or(
- sql`${vendorResponsesView.rfqCode} ILIKE ${s}`,
- sql`${vendorResponsesView.projectName} ILIKE ${s}`,
- sql`${vendorResponsesView.rfqDescription} ILIKE ${s}`
- );
- }
-
- // 협력업체 ID 필터링
- const mainWhere = and(eq(vendorResponsesView.vendorId, vendorId), globalWhere);
-
- // 정렬: 응답 시간순
- const orderBy = [desc(vendorResponsesView.respondedAt)];
-
- // (A) 데이터 조회
- const data = await tx
- .select()
- .from(vendorResponsesView)
- .where(mainWhere)
- .orderBy(...orderBy)
- .offset(offset)
- .limit(limit);
-
- // (B) 전체 개수 카운트
- const [{ count }] = await tx
- .select({
- count: sql<number>`count(*)`.as("count"),
- })
- .from(vendorResponsesView)
- .where(mainWhere);
-
- 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,
- itemName: items.itemName,
- quantity: rfqItems.quantity,
- description: rfqItems.description,
- uom: rfqItems.uom,
- })
- .from(rfqItems)
- .leftJoin(items, eq(rfqItems.itemCode, items.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,
- itemName: it.itemName,
- 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,
- rfqType: row.rfqType,
- 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 };
- },
- [JSON.stringify(input), `${vendorId}`],
- {
- revalidate: 600,
- tags: ["rfqs-vendor", `vendor-${vendorId}`],
- }
- )();
-}
-
-
-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))
- }
- }
-
- // Use vendorId for revalidateTag
- revalidateTag(`cbe-vendor-${validated.vendorId}`)
-
- 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/vendor-rfq-response/types.ts b/lib/vendor-rfq-response/types.ts
deleted file mode 100644
index 3f595ebb..00000000
--- a/lib/vendor-rfq-response/types.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-// RFQ 아이템 타입
-export interface RfqResponseItem {
- id: number;
- itemCode: string;
- itemName: string;
- 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;
- rfqType?: string | null;
- 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/vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx b/lib/vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx
deleted file mode 100644
index c7be0bf4..00000000
--- a/lib/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/vendor-rfq-response/vendor-cbe-table/cbe-table.tsx b/lib/vendor-rfq-response/vendor-cbe-table/cbe-table.tsx
deleted file mode 100644
index 8477f550..00000000
--- a/lib/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/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/vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx b/lib/vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx
deleted file mode 100644
index 40d38145..00000000
--- a/lib/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/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/vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx b/lib/vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx
deleted file mode 100644
index 8cc4fa6f..00000000
--- a/lib/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/vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx b/lib/vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx
deleted file mode 100644
index e9328641..00000000
--- a/lib/vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx
+++ /dev/null
@@ -1,89 +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.rfqType && (
- <Badge
- variant={
- rfq.rfqType === "BUDGETARY" ? "default" :
- rfq.rfqType === "PURCHASE" ? "destructive" :
- rfq.rfqType === "PURCHASE_BUDGETARY" ? "secondary" : "outline"
- }
- >
- RFQ 유형: {rfq.rfqType}
- </Badge>
- )}
-
-
- {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/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx b/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx
deleted file mode 100644
index bf4ae709..00000000
--- a/lib/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/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx b/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx
deleted file mode 100644
index c5c67e54..00000000
--- a/lib/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/vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx b/lib/vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx
deleted file mode 100644
index 504fc177..00000000
--- a/lib/vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx
+++ /dev/null
@@ -1,125 +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,
- TableCaption,
- TableCell,
- TableFooter,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table"
-import { RfqWithAll } from "../types"
-/**
- * 아이템 구조 예시
- * - API 응답에서 quantity가 "string" 형태이므로,
- * 숫자로 사용하실 거라면 parse 과정이 필요할 수 있습니다.
- */
-export interface RfqItem {
- id: number
- itemCode: string
- itemName: string
- 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 Code</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.itemName || "No Name"}</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/vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx b/lib/vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx
deleted file mode 100644
index 6c51c12c..00000000
--- a/lib/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/vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx b/lib/vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx
deleted file mode 100644
index 5bb8a16a..00000000
--- a/lib/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/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/vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx b/lib/vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx
deleted file mode 100644
index 81131894..00000000
--- a/lib/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/vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx
deleted file mode 100644
index 70b91176..00000000
--- a/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx
+++ /dev/null
@@ -1,435 +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/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,
- }
-
- const rfqTypeColumn: ColumnDef<RfqWithAll> = {
- id: "rfqType",
- accessorKey: "rfqType",
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ Type" />
- ),
- cell: ({ row }) => row.original.rfqType || "-",
- 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,
- rfqTypeColumn,
- responseStatusColumn,
- projectNameColumn,
- descriptionColumn,
- dueDateColumn,
- itemsColumn,
- attachmentsColumn,
- commentsColumn,
- updatedAtColumn,
- actionsColumn,
- ]
-} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx
deleted file mode 100644
index 1bae99ef..00000000
--- a/lib/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/vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx
deleted file mode 100644
index 6aab7fef..00000000
--- a/lib/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/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/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx b/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx
deleted file mode 100644
index e0bf9727..00000000
--- a/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx
+++ /dev/null
@@ -1,346 +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/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
- 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 = [],
- 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: null, // 필요시 세팅
- 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/vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx b/lib/vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx
deleted file mode 100644
index 2056a48f..00000000
--- a/lib/vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx
+++ /dev/null
@@ -1,86 +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>
- )}
- {rfq.rfqType && (
- <Badge
- variant={
- rfq.rfqType === "BUDGETARY" ? "default" :
- rfq.rfqType === "PURCHASE" ? "destructive" :
- rfq.rfqType === "PURCHASE_BUDGETARY" ? "secondary" : "outline"
- }
- >
- {rfq.rfqType}
- </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/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx
deleted file mode 100644
index f664d9a3..00000000
--- a/lib/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?.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/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx
deleted file mode 100644
index 13d5dc64..00000000
--- a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx
+++ /dev/null
@@ -1,188 +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/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)
- // 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>(null)
- 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.id))
- }
- }, [rowAction])
-
- async function openCommentSheet(vendorId: number) {
- setInitialComments([])
- setIsLoadingComments(true)
-
- const comments = rowAction?.row.original.comments
-
- 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(vendorId)
- 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.id),
- 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}
- 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/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx
deleted file mode 100644
index a0b6f805..00000000
--- a/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx
+++ /dev/null
@@ -1,355 +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/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 &&currentvendorResponseId) {
- 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);
- } 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>
- <Button variant="ghost" size="icon" onClick={handleRemoveFile}>
- <X className="h-4 w-4" />
- <span className="sr-only">파일 제거</span>
- </Button>
- </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">
- <Button variant="ghost" size="icon" onClick={() => downloadSubmittedFile(file)}>
- <Download className="h-4 w-4" />
- <span className="sr-only">파일 다운로드</span>
- </Button>
- </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