summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/[lng]/evcp/(evcp)/po-rfq/page.tsx86
-rw-r--r--app/[lng]/partners/(partners)/rfq-all/[id]/page.tsx80
-rw-r--r--app/[lng]/partners/(partners)/rfq-all/page.tsx171
-rw-r--r--app/api/procurement-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts145
-rw-r--r--app/api/table-presets/[id]/route.ts57
-rw-r--r--app/api/table-presets/route.ts98
-rw-r--r--components/data-table/data-table-preset.tsx373
-rw-r--r--components/data-table/use-table-presets.tsx338
-rw-r--r--components/po-rfq/detail-table/add-vendor-dialog.tsx512
-rw-r--r--components/po-rfq/detail-table/delete-vendor-dialog.tsx150
-rw-r--r--components/po-rfq/detail-table/rfq-detail-column.tsx369
-rw-r--r--components/po-rfq/detail-table/rfq-detail-table.tsx519
-rw-r--r--components/po-rfq/detail-table/update-vendor-sheet.tsx449
-rw-r--r--components/po-rfq/detail-table/vendor-communication-drawer.tsx518
-rw-r--r--components/po-rfq/detail-table/vendor-quotation-comparison-dialog.tsx665
-rw-r--r--components/po-rfq/po-rfq-container.tsx261
-rw-r--r--components/po-rfq/rfq-filter-sheet.tsx643
-rw-r--r--hooks/use-local-storage.ts48
-rw-r--r--hooks/useAutoSizeColumns.ts40
-rw-r--r--lib/procurement-rfqs/services.ts7
-rw-r--r--lib/procurement-rfqs/table/rfq-table copy.tsx209
-rw-r--r--lib/procurement-rfqs/table/rfq-table.tsx258
-rw-r--r--lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx282
-rw-r--r--lib/procurement-rfqs/vendor-response/table/vendor-quotations-table.tsx31
-rw-r--r--lib/utils.ts84
25 files changed, 6224 insertions, 169 deletions
diff --git a/app/[lng]/evcp/(evcp)/po-rfq/page.tsx b/app/[lng]/evcp/(evcp)/po-rfq/page.tsx
new file mode 100644
index 00000000..dfaa7708
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/po-rfq/page.tsx
@@ -0,0 +1,86 @@
+import { Suspense } from "react"
+import { getPORfqs } from "@/lib/procurement-rfqs/services"
+import { searchParamsCache } from "@/lib/procurement-rfqs/validations"
+import { Shell } from "@/components/shell"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import RFQContainer from "@/components/po-rfq/po-rfq-container"
+
+interface RfqPageProps {
+ searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
+ title?: string;
+ description?: string;
+}
+
+export default async function RfqPage({
+ searchParams,
+ title = "발주용 견적",
+ description = "SAP으로부터 전송된 발주용 견적을 관리할 수 있습니다.",
+}: RfqPageProps) {
+ // searchParams를 await하여 resolve
+ const resolvedSearchParams = await searchParams;
+
+ // 서버 액션: RFQ 데이터 가져오기
+ async function fetchRfqData(params: any) {
+ "use server"
+
+ try {
+ // URL 파라미터를 추출하고 필요한 형식으로 변환
+ const parsedParams = searchParamsCache.parse(params);
+
+ // RFQ 데이터 가져오기
+ const data = await getPORfqs(parsedParams)
+
+ return data
+ } catch (error) {
+ console.error("RFQ 데이터 조회 오류:", error)
+ // 에러 발생 시 빈 결과 반환
+ return { data: [], pageCount: 0, total: 0 }
+ }
+ }
+
+ // 현재 resolvedSearchParams를 파싱하여 초기 데이터 로드
+ const initialParams = {
+ page: resolvedSearchParams.page?.toString() || "1",
+ perPage: resolvedSearchParams.perPage?.toString() || "10",
+ sort: resolvedSearchParams.sort?.toString() || JSON.stringify([{ id: "updatedAt", desc: true }]),
+ filters: resolvedSearchParams.filters?.toString() || null,
+ joinOperator: resolvedSearchParams.joinOperator?.toString() || "and",
+ basicFilters: resolvedSearchParams.basicFilters?.toString() || null,
+ basicJoinOperator: resolvedSearchParams.basicJoinOperator?.toString() || "and",
+ search: resolvedSearchParams.search?.toString() || "",
+ }
+
+ // 초기 데이터 로드
+ const initialData = await fetchRfqData(initialParams)
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ {title}
+ </h2>
+ </div>
+ </div>
+ </div>
+
+ <Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <RFQContainer
+ initialData={initialData}
+ fetchData={fetchRfqData}
+ />
+ </Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/rfq-all/[id]/page.tsx b/app/[lng]/partners/(partners)/rfq-all/[id]/page.tsx
new file mode 100644
index 00000000..c8858704
--- /dev/null
+++ b/app/[lng]/partners/(partners)/rfq-all/[id]/page.tsx
@@ -0,0 +1,80 @@
+// app/vendor/quotations/[id]/page.tsx - 견적 응답 페이지
+import { Metadata } from "next"
+import { notFound } from "next/navigation"
+import db from "@/db/db";
+import { eq } from "drizzle-orm"
+import { procurementVendorQuotations } from "@/db/schema"
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import VendorQuotationEditor from "@/lib/procurement-rfqs/vendor-response/quotation-editor";
+
+
+interface PageProps {
+ params: {
+ id: string
+ }
+}
+
+export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
+ return {
+ title: "견적서 응답",
+ description: "RFQ에 대한 견적서 작성 및 제출",
+ }
+}
+
+export default async function VendorQuotationPage({ params }: PageProps) {
+ const quotationId = parseInt(params.id)
+
+ if (isNaN(quotationId)) {
+ notFound()
+ }
+
+ // 인증 확인
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ return (
+ <div className="flex h-full items-center justify-center">
+ <div className="text-center">
+ <h2 className="text-xl font-bold">로그인이 필요합니다</h2>
+ <p className="mt-2 text-muted-foreground">견적서 응답을 위해 로그인해주세요.</p>
+ </div>
+ </div>
+ )
+ }
+
+ // 견적서 정보 가져오기
+ const quotation = await db.query.procurementVendorQuotations.findFirst({
+ where: eq(procurementVendorQuotations.id, quotationId),
+ with: {
+ rfq: true, // 관계 설정 필요
+ vendor: true, // 관계 설정 필요
+ items: true, // 관계 설정 필요
+ }
+ })
+
+ if (!quotation) {
+ notFound()
+ }
+
+ // 벤더 권한 확인 (필요한 경우)
+ const isAuthorized = session.user.domain === "partners" &&
+ session.user.companyId === quotation.vendorId
+
+ if (!isAuthorized) {
+ return (
+ <div className="flex h-full items-center justify-center">
+ <div className="text-center">
+ <h2 className="text-xl font-bold">접근 권한이 없습니다</h2>
+ <p className="mt-2 text-muted-foreground">이 견적서에 대한 권한이 없습니다.</p>
+ </div>
+ </div>
+ )
+ }
+
+ return (
+ <div className="container py-8">
+ <VendorQuotationEditor quotation={quotation} />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/rfq-all/page.tsx b/app/[lng]/partners/(partners)/rfq-all/page.tsx
new file mode 100644
index 00000000..e7dccb02
--- /dev/null
+++ b/app/[lng]/partners/(partners)/rfq-all/page.tsx
@@ -0,0 +1,171 @@
+// app/vendor/quotations/page.tsx
+import * as React from "react";
+import Link from "next/link";
+import { Metadata } from "next";
+import { getServerSession } from "next-auth/next";
+import { authOptions } from "@/app/api/auth/[...nextauth]/route";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { LogIn } from "lucide-react";
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
+import { Shell } from "@/components/shell";
+import { getValidFilters } from "@/lib/data-table";
+import { type SearchParams } from "@/types/table";
+import { searchParamsVendorRfqCache } from "@/lib/procurement-rfqs/validations";
+import { getQuotationStatusCounts, getVendorQuotations } from "@/lib/procurement-rfqs/services";
+import { VendorQuotationsTable } from "@/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table";
+
+export const metadata: Metadata = {
+ title: "견적 목록",
+ description: "진행 중인 견적서 목록",
+};
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsVendorRfqCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+ // 인증 확인
+ const session = await getServerSession(authOptions);
+
+ // 로그인 확인
+ if (!session || !session.user) {
+ return (
+ <Shell className="gap-6">
+ <div className="flex items-center justify-between">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ 견적 목록
+ </h2>
+ <p className="text-muted-foreground">
+ 진행 중인 견적서 목록을 확인하고 관리합니다.
+ </p>
+ </div>
+ </div>
+
+ <div className="flex flex-col items-center justify-center py-12 text-center">
+ <div className="rounded-lg border border-dashed p-10 shadow-sm">
+ <h3 className="mb-2 text-xl font-semibold">로그인이 필요합니다</h3>
+ <p className="mb-6 text-muted-foreground">
+ 견적서를 확인하려면 먼저 로그인하세요.
+ </p>
+ <Button size="lg" asChild>
+ <Link href="/partners?callbackUrl=/vendor/quotations">
+ <LogIn className="mr-2 h-4 w-4" />
+ 로그인하기
+ </Link>
+ </Button>
+ </div>
+ </div>
+ </Shell>
+ );
+ }
+
+ // 벤더 ID 확인
+ const vendorId = session.user.companyId ? String(session.user.companyId) : "0";
+
+ // 벤더 권한 확인
+ if (session.user.domain !== "partners") {
+ return (
+ <Shell className="gap-6">
+ <div className="flex items-center justify-between">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ 접근 권한 없음
+ </h2>
+ </div>
+ </div>
+ <div className="flex flex-col items-center justify-center py-12 text-center">
+ <div className="rounded-lg border border-dashed p-10 shadow-sm">
+ <h3 className="mb-2 text-xl font-semibold">벤더 계정이 필요합니다</h3>
+ <p className="mb-6 text-muted-foreground">
+ 벤더 계정으로 로그인해주세요.
+ </p>
+ </div>
+ </div>
+ </Shell>
+ );
+ }
+
+ // 데이터 가져오기
+ const quotationsPromise = getVendorQuotations({
+ ...search,
+ filters: validFilters
+ }, vendorId);
+
+ // 상태별 개수 가져오기
+ const statusCountsPromise = getQuotationStatusCounts(vendorId);
+
+ // 모든 프로미스 병렬 실행
+ const promises = Promise.all([quotationsPromise]);
+ const statusCounts = await statusCountsPromise;
+
+ return (
+ <Shell className="gap-6">
+ <div className="flex justify-between items-center">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">견적 목록</h2>
+ <p className="text-muted-foreground">
+ 진행 중인 견적서 목록을 확인하고 관리합니다.
+ </p>
+ </div>
+ </div>
+
+ <div className="grid gap-4 md:grid-cols-4">
+ <Card>
+ <CardHeader className="py-4">
+ <CardTitle className="text-base">전체 견적</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">
+ {Object.values(statusCounts).reduce((sum, count) => sum + count, 0)}건
+ </div>
+ </CardContent>
+ </Card>
+ <Card>
+ <CardHeader className="py-4">
+ <CardTitle className="text-base">작성 중</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">{statusCounts.Draft || 0}건</div>
+ </CardContent>
+ </Card>
+ <Card>
+ <CardHeader className="py-4">
+ <CardTitle className="text-base">제출됨</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">
+ {(statusCounts.Submitted || 0) + (statusCounts.Revised || 0)}건
+ </div>
+ </CardContent>
+ </Card>
+ <Card>
+ <CardHeader className="py-4">
+ <CardTitle className="text-base">승인됨</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">{statusCounts.Accepted || 0}건</div>
+ </CardContent>
+ </Card>
+ </div>
+
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={7}
+ searchableColumnCount={2}
+ filterableColumnCount={3}
+ cellWidths={["10rem", "10rem", "8rem", "10rem", "10rem", "10rem", "8rem"]}
+ />
+ }
+ >
+ <VendorQuotationsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ );
+} \ No newline at end of file
diff --git a/app/api/procurement-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts b/app/api/procurement-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts
new file mode 100644
index 00000000..51430118
--- /dev/null
+++ b/app/api/procurement-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts
@@ -0,0 +1,145 @@
+// app/api/procurement-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts
+import { NextRequest, NextResponse } from "next/server"
+
+import db from '@/db/db';
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+
+import { procurementRfqComments, procurementRfqAttachments } from "@/db/schema"
+import { revalidateTag } from "next/cache"
+
+// 파일 저장을 위한 유틸리티
+import { writeFile, mkdir } from 'fs/promises'
+import { join } from 'path'
+import crypto from 'crypto'
+
+/**
+ * 코멘트 생성 API 엔드포인트
+ */
+export async function POST(
+ request: NextRequest,
+ { params }: { params: { rfqId: string; vendorId: string } }
+) {
+ try {
+ // 인증 확인
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ return NextResponse.json(
+ { success: false, message: "인증이 필요합니다" },
+ { status: 401 }
+ )
+ }
+
+ const rfqId = parseInt(params.rfqId)
+ const vendorId = parseInt(params.vendorId)
+
+ // 유효성 검사
+ if (isNaN(rfqId) || isNaN(vendorId)) {
+ return NextResponse.json(
+ { success: false, message: "유효하지 않은 매개변수입니다" },
+ { status: 400 }
+ )
+ }
+
+ // FormData 파싱
+ const formData = await request.formData()
+ const content = formData.get("content") as string
+ const isVendorComment = formData.get("isVendorComment") === "true"
+ const files = formData.getAll("attachments") as File[]
+
+ if (!content && files.length === 0) {
+ return NextResponse.json(
+ { success: false, message: "내용이나 첨부파일이 필요합니다" },
+ { status: 400 }
+ )
+ }
+
+ // 코멘트 생성
+ const [comment] = await db
+ .insert(procurementRfqComments)
+ .values({
+ rfqId,
+ vendorId,
+ userId: parseInt(session.user.id),
+ content,
+ isVendorComment,
+ isRead: !isVendorComment, // 본인 메시지는 읽음 처리
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning()
+
+ // 첨부파일 처리
+ const attachments = []
+ if (files.length > 0) {
+ // 디렉토리 생성
+ const uploadDir = join(process.cwd(), "public", `rfq-${rfqId}`, `vendor-${vendorId}`, `comment-${comment.id}`)
+ await mkdir(uploadDir, { recursive: true })
+
+ // 각 파일 저장
+ for (const file of files) {
+ const buffer = Buffer.from(await file.arrayBuffer())
+ const filename = `${Date.now()}-${crypto.randomBytes(8).toString("hex")}-${file.name.replace(/[^a-zA-Z0-9.-]/g, "_")}`
+ const filePath = join(uploadDir, filename)
+
+ // 파일 쓰기
+ await writeFile(filePath, buffer)
+
+ // DB에 첨부파일 정보 저장
+ const [attachment] = await db
+ .insert(procurementRfqAttachments)
+ .values({
+ rfqId,
+ commentId: comment.id,
+ fileName: file.name,
+ fileSize: file.size,
+ fileType: file.type,
+ filePath: `/rfq-${rfqId}/vendor-${vendorId}/comment-${comment.id}/${filename}`,
+ isVendorUpload: isVendorComment,
+ uploadedBy: parseInt(session.user.id),
+ vendorId,
+ uploadedAt: new Date(),
+ })
+ .returning()
+
+ attachments.push({
+ id: attachment.id,
+ fileName: attachment.fileName,
+ fileSize: attachment.fileSize,
+ fileType: attachment.fileType,
+ filePath: attachment.filePath,
+ uploadedAt: attachment.uploadedAt
+ })
+ }
+ }
+
+ // 캐시 무효화
+ revalidateTag(`rfq-${rfqId}-comments`)
+
+ // 응답 데이터 구성
+ const responseData = {
+ id: comment.id,
+ rfqId: comment.rfqId,
+ vendorId: comment.vendorId,
+ userId: comment.userId,
+ content: comment.content,
+ isVendorComment: comment.isVendorComment,
+ createdAt: comment.createdAt,
+ updatedAt: comment.updatedAt,
+ userName: session.user.name,
+ attachments,
+ isRead: comment.isRead
+ }
+
+ return NextResponse.json({
+ success: true,
+ data: { comment: responseData }
+ })
+ } catch (error) {
+ console.error("코멘트 생성 오류:", error)
+ return NextResponse.json(
+ { success: false, message: "코멘트 생성 중 오류가 발생했습니다" },
+ { status: 500 }
+ )
+ }
+} \ No newline at end of file
diff --git a/app/api/table-presets/[id]/route.ts b/app/api/table-presets/[id]/route.ts
new file mode 100644
index 00000000..8146889d
--- /dev/null
+++ b/app/api/table-presets/[id]/route.ts
@@ -0,0 +1,57 @@
+// app/api/table-presets/[id]/route.ts
+import { NextRequest, NextResponse } from "next/server"
+import { getServerSession } from "next-auth"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import db from "@/db/db"
+import { tablePresets } from "@/db/schema/setting"
+import { eq } from "drizzle-orm"
+
+export async function PUT(
+ request: NextRequest,
+ { params }: { params: { id: string } }
+) {
+ try {
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
+ }
+
+ const presetId = params.id
+ const body = await request.json()
+
+ const updatedPreset = await db
+ .update(tablePresets)
+ .set({
+ ...body,
+ updatedAt: new Date(),
+ })
+ .where(eq(tablePresets.id, presetId))
+ .returning()
+
+ return NextResponse.json(updatedPreset[0])
+ } catch (error) {
+ console.error("Error updating preset:", error)
+ return NextResponse.json({ error: "Internal Server Error" }, { status: 500 })
+ }
+}
+
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: { id: string } }
+) {
+ try {
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
+ }
+
+ const presetId = params.id
+
+ await db.delete(tablePresets).where(eq(tablePresets.id, presetId))
+
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ console.error("Error deleting preset:", error)
+ return NextResponse.json({ error: "Internal Server Error" }, { status: 500 })
+ }
+} \ No newline at end of file
diff --git a/app/api/table-presets/route.ts b/app/api/table-presets/route.ts
new file mode 100644
index 00000000..f276b469
--- /dev/null
+++ b/app/api/table-presets/route.ts
@@ -0,0 +1,98 @@
+// app/api/table-presets/route.ts
+import { NextRequest, NextResponse } from "next/server"
+import { getServerSession } from "next-auth"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import db from "@/db/db"
+import { eq, and, or } from "drizzle-orm"
+import { tablePresets } from "@/db/schema/setting"
+
+export async function GET(request: NextRequest) {
+ try {
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
+ }
+
+ const { searchParams } = new URL(request.url)
+ const tableId = searchParams.get("tableId")
+
+ if (!tableId) {
+ return NextResponse.json({ error: "tableId is required" }, { status: 400 })
+ }
+
+ // 사용자의 프리셋 + 공유된 프리셋 조회
+ const presets = await db
+ .select()
+ .from(tablePresets)
+ .where(
+ and(
+ eq(tablePresets.tableId, tableId),
+ or(
+ eq(tablePresets.userId, session.user.id),
+ eq(tablePresets.isShared, true)
+ )
+ )
+ )
+ .orderBy(tablePresets.createdAt)
+
+ return NextResponse.json(presets)
+ } catch (error) {
+ console.error("Error fetching presets:", error)
+ return NextResponse.json({ error: "Internal Server Error" }, { status: 500 })
+ }
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
+ }
+
+ const body = await request.json()
+ const { tableId, name, settings, isDefault } = body
+
+ // 기본 프리셋으로 설정하는 경우 기존 기본 프리셋 해제
+ if (isDefault) {
+ await db
+ .update(tablePresets)
+ .set({ isDefault: false })
+ .where(
+ and(
+ eq(tablePresets.userId, session.user.id),
+ eq(tablePresets.tableId, tableId)
+ )
+ )
+ }
+
+ const newPreset = await db
+ .insert(tablePresets)
+ .values({
+ userId: session.user.id,
+ tableId,
+ name,
+ settings,
+ isDefault: Boolean(isDefault),
+ isActive: true,
+ createdBy: session.user.id,
+ })
+ .returning()
+
+ // 다른 프리셋들의 active 상태 해제
+ await db
+ .update(tablePresets)
+ .set({ isActive: false })
+ .where(
+ and(
+ eq(tablePresets.userId, session.user.id),
+ eq(tablePresets.tableId, tableId),
+ eq(tablePresets.id, newPreset[0].id)
+ )
+ )
+
+ return NextResponse.json(newPreset[0])
+ } catch (error) {
+ console.error("Error creating preset:", error)
+ return NextResponse.json({ error: "Internal Server Error" }, { status: 500 })
+ }
+} \ No newline at end of file
diff --git a/components/data-table/data-table-preset.tsx b/components/data-table/data-table-preset.tsx
new file mode 100644
index 00000000..007bde2f
--- /dev/null
+++ b/components/data-table/data-table-preset.tsx
@@ -0,0 +1,373 @@
+// components/data-table/table-preset-manager.tsx
+import React, { useState } from 'react'
+import { Button } from '@/components/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+} from '@/components/ui/dropdown-menu'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Checkbox } from '@/components/ui/checkbox'
+import {
+ Settings,
+ Save,
+ Star,
+ Edit,
+ Trash2,
+ Plus,
+ Check,
+ Loader2,
+ ChevronDown,
+ Bookmark
+} from 'lucide-react'
+import { TableSettings } from '@/db/schema'
+
+interface TablePreset {
+ id: string
+ name: string
+ isDefault: boolean
+ isActive: boolean
+ settings: TableSettings
+}
+
+interface TablePresetManagerProps<TData = any> {
+ presets: TablePreset[]
+ activePresetId: string | null
+ currentSettings: TableSettings<TData>
+ hasUnsavedChanges: boolean
+ isLoading: boolean
+ onCreatePreset: (name: string, settings: TableSettings<TData>, isDefault: boolean) => Promise<boolean>
+ onUpdatePreset: (presetId: string, settings: TableSettings<TData>) => Promise<void>
+ onDeletePreset: (presetId: string) => Promise<boolean>
+ onApplyPreset: (presetId: string) => Promise<boolean>
+ onSetDefaultPreset: (presetId: string) => Promise<void>
+ onRenamePreset: (presetId: string, newName: string) => Promise<boolean>
+}
+
+export function TablePresetManager<TData = any>({
+ presets,
+ activePresetId,
+ currentSettings,
+ hasUnsavedChanges,
+ isLoading,
+ onCreatePreset,
+ onUpdatePreset,
+ onDeletePreset,
+ onApplyPreset,
+ onSetDefaultPreset,
+ onRenamePreset,
+}: TablePresetManagerProps<TData>) {
+ const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
+ const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false)
+ const [newPresetName, setNewPresetName] = useState('')
+ const [isDefaultPreset, setIsDefaultPreset] = useState(false)
+ const [renamingPresetId, setRenamingPresetId] = useState<string | null>(null)
+ const [processingPresetId, setProcessingPresetId] = useState<string | null>(null)
+
+ const activePreset = presets.find(p => p.id === activePresetId)
+
+ const handleCreatePreset = async () => {
+ if (!newPresetName.trim()) return
+
+ const success = await onCreatePreset(newPresetName, currentSettings, isDefaultPreset)
+ if (success) {
+ setIsCreateDialogOpen(false)
+ setNewPresetName('')
+ setIsDefaultPreset(false)
+ }
+ }
+
+ const handleRenamePreset = async () => {
+ if (renamingPresetId && newPresetName.trim()) {
+ const success = await onRenamePreset(renamingPresetId, newPresetName)
+ if (success) {
+ setIsRenameDialogOpen(false)
+ setNewPresetName('')
+ setRenamingPresetId(null)
+ }
+ }
+ }
+
+ const openRenameDialog = (preset: TablePreset) => {
+ setRenamingPresetId(preset.id)
+ setNewPresetName(preset.name)
+ setIsRenameDialogOpen(true)
+ }
+
+ const handleUpdateCurrentPreset = async () => {
+ if (activePresetId) {
+ await onUpdatePreset(activePresetId, currentSettings)
+ }
+ }
+
+ const handleApplyPreset = async (presetId: string) => {
+ setProcessingPresetId(presetId)
+ await onApplyPreset(presetId)
+ setProcessingPresetId(null)
+ }
+
+ if (isLoading) {
+ return (
+ <Button variant="outline" size="sm" className="h-8" disabled>
+ <Loader2 size={16} className="mr-2 animate-spin" />
+ 로딩 중...
+ </Button>
+ )
+ }
+
+ return (
+ <>
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="outline" size="sm" className="h-8">
+ <Settings size={16} className="mr-2" />
+ {/* <span className="hidden sm:inline">맞춤 설정</span> */}
+ {activePreset ? (
+ <div className="flex items-center ml-2">
+ <span className="font-medium text-sm">{activePreset.name}</span>
+ {hasUnsavedChanges && <span className="ml-1 text-amber-500">*</span>}
+ {activePreset.isDefault && <Star size={12} className="ml-1 text-yellow-500" />}
+ </div>
+ ) : null}
+ <ChevronDown size={14} className="ml-1" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent className="w-72">
+ <DropdownMenuLabel className="flex items-center">
+ <Bookmark size={16} className="mr-2" />
+ 테이블 맞춤 설정
+ </DropdownMenuLabel>
+ <DropdownMenuSeparator />
+
+ {/* 활성 맞춤 설정 표시 */}
+ {activePreset && (
+ <>
+ <div className="px-2 py-2 bg-muted/50 rounded-sm mx-1">
+ <div className="text-xs text-muted-foreground">현재 활성</div>
+ <div className="flex items-center justify-between">
+ <span className="font-medium text-sm">{activePreset.name}</span>
+ <div className="flex items-center gap-1">
+ {activePreset.isDefault && <Star size={12} className="text-yellow-500" />}
+ {hasUnsavedChanges && <span className="text-amber-500 text-xs">수정됨</span>}
+ </div>
+ </div>
+ </div>
+ <DropdownMenuSeparator />
+ </>
+ )}
+
+ {/* 빠른 액션 */}
+ <DropdownMenuItem onClick={() => setIsCreateDialogOpen(true)}>
+ <Plus size={14} className="mr-2" />
+ 현재 설정으로 새 맞춤 설정 만들기
+ </DropdownMenuItem>
+
+ {activePresetId && (
+ <DropdownMenuItem onClick={handleUpdateCurrentPreset}>
+ <Save size={14} className="mr-2" />
+ {hasUnsavedChanges ? '변경 내용 저장' : '현재 설정 업데이트'}
+ </DropdownMenuItem>
+ )}
+
+ <DropdownMenuSeparator />
+
+ {/* 맞춤 설정 목록 */}
+ <DropdownMenuLabel className="text-xs text-muted-foreground">
+ 저장된 맞춤 설정
+ </DropdownMenuLabel>
+
+ {presets.length === 0 ? (
+ <div className="px-2 py-2 text-center text-sm text-muted-foreground">
+ 저장된 프리셋이 없습니다
+ </div>
+ ) : (
+ presets.map((preset) => (
+ <DropdownMenuSub key={preset.id}>
+ <DropdownMenuSubTrigger>
+ <div className="flex items-center w-full">
+ {preset.id === activePresetId ? (
+ <Check size={14} className="mr-2" />
+ ) : (
+ <div className="w-[14px] mr-2" />
+ )}
+ <span className={preset.id === activePresetId ? "font-medium" : ""}>
+ {preset.name}
+ </span>
+ {preset.isDefault && <Star size={12} className="ml-1 text-yellow-500" />}
+ </div>
+ </DropdownMenuSubTrigger>
+ <DropdownMenuSubContent className="w-48">
+ <DropdownMenuItem onClick={() => handleApplyPreset(preset.id)}>
+ {processingPresetId === preset.id ? (
+ <Loader2 size={14} className="mr-2 animate-spin" />
+ ) : (
+ <Check size={14} className="mr-2" />
+ )}
+ 적용
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => openRenameDialog(preset)}>
+ <Edit size={14} className="mr-2" />
+ 이름 변경
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => onSetDefaultPreset(preset.id)}>
+ <Star size={14} className="mr-2" />
+ {preset.isDefault ? '기본 해제' : '기본으로 설정'}
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ {presets.length > 1 && (
+ <DropdownMenuItem
+ onClick={() => onDeletePreset(preset.id)}
+ className="text-red-600 focus:text-red-600"
+ >
+ <Trash2 size={14} className="mr-2" />
+ 삭제
+ </DropdownMenuItem>
+ )}
+ </DropdownMenuSubContent>
+ </DropdownMenuSub>
+ ))
+ )}
+ </DropdownMenuContent>
+ </DropdownMenu>
+
+ {/* Create Dialog */}
+ <Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
+ <DialogContent className="sm:max-w-[425px]">
+ <DialogHeader>
+ <DialogTitle>새 맞춤 설정 저장</DialogTitle>
+ <DialogDescription>
+ 현재 테이블 설정을 새로운 프리셋으로 저장합니다.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="grid gap-4 py-4">
+ <div className="grid grid-cols-4 items-center gap-4">
+ <Label htmlFor="presetName" className="text-right">
+ 이름
+ </Label>
+ <div className="col-span-3">
+ <Input
+ id="presetName"
+ value={newPresetName}
+ onChange={(e) => setNewPresetName(e.target.value)}
+ placeholder="맞춤 설정 이름을 입력하세요"
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ handleCreatePreset()
+ }
+ }}
+ />
+ </div>
+ </div>
+ <div className="grid grid-cols-4 items-center gap-4">
+ <Label className="text-right">옵션</Label>
+ <div className="col-span-3">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="isDefault"
+ checked={isDefaultPreset}
+ onCheckedChange={(checked) => setIsDefaultPreset(checked as boolean)}
+ />
+ <Label htmlFor="isDefault" className="text-sm font-normal">
+ 기본 프리셋으로 설정
+ </Label>
+ </div>
+ <p className="text-xs text-muted-foreground mt-1">
+ 기본 프리셋은 테이블 로드 시 자동으로 적용됩니다.
+ </p>
+ </div>
+ </div>
+ </div>
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => {
+ setIsCreateDialogOpen(false)
+ setNewPresetName('')
+ setIsDefaultPreset(false)
+ }}
+ >
+ 취소
+ </Button>
+ <Button
+ type="button"
+ onClick={handleCreatePreset}
+ disabled={!newPresetName.trim()}
+ >
+ <Save size={14} className="mr-2" />
+ 저장
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* Rename Dialog */}
+ <Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
+ <DialogContent className="sm:max-w-[425px]">
+ <DialogHeader>
+ <DialogTitle>맞춤 설정 이름 변경</DialogTitle>
+ <DialogDescription>
+ '{presets.find(p => p.id === renamingPresetId)?.name}' 프리셋의 이름을 변경합니다.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="grid gap-4 py-4">
+ <div className="grid grid-cols-4 items-center gap-4">
+ <Label htmlFor="renamePresetName" className="text-right">
+ 새 이름
+ </Label>
+ <div className="col-span-3">
+ <Input
+ id="renamePresetName"
+ value={newPresetName}
+ onChange={(e) => setNewPresetName(e.target.value)}
+ placeholder="새 맞춤 설정 이름을 입력하세요"
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ handleRenamePreset()
+ }
+ }}
+ />
+ </div>
+ </div>
+ </div>
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => {
+ setIsRenameDialogOpen(false)
+ setNewPresetName('')
+ setRenamingPresetId(null)
+ }}
+ >
+ 취소
+ </Button>
+ <Button
+ type="button"
+ onClick={handleRenamePreset}
+ disabled={!newPresetName.trim()}
+ >
+ <Edit size={14} className="mr-2" />
+ 변경
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </>
+ )
+} \ No newline at end of file
diff --git a/components/data-table/use-table-presets.tsx b/components/data-table/use-table-presets.tsx
new file mode 100644
index 00000000..5e641762
--- /dev/null
+++ b/components/data-table/use-table-presets.tsx
@@ -0,0 +1,338 @@
+// hooks/use-table-presets.ts
+import { TableSettings } from "@/db/schema"
+import { useSession } from "next-auth/react"
+import { useCallback, useEffect, useState } from "react"
+import { toast } from "sonner"
+import useSWR from "swr"
+import { useSearchParams } from "next/navigation"
+
+interface TablePreset {
+ id: string
+ userId: string
+ tableId: string
+ name: string
+ settings: TableSettings
+ isDefault: boolean
+ isActive: boolean
+ isShared: boolean
+ createdAt: Date
+ updatedAt: Date
+}
+
+export function useTablePresets<TData>(
+ tableId: string,
+ initialSettings: TableSettings<TData>
+) {
+ const { data: session } = useSession()
+ const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
+ const [isClient, setIsClient] = useState(false)
+ const searchParams = useSearchParams()
+
+ // 클라이언트 마운트 확인
+ useEffect(() => {
+ setIsClient(true)
+ }, [])
+
+ // SWR을 사용한 데이터 페칭
+ const {
+ data: presets,
+ error,
+ mutate,
+ isLoading
+ } = useSWR<TablePreset[]>(
+ session?.user ? `/api/table-presets?tableId=${tableId}` : null,
+ (url: string) => fetch(url).then(res => res.json()),
+ {
+ revalidateOnFocus: false,
+ revalidateOnReconnect: true,
+ }
+ )
+
+ const activePreset = presets?.find(p => p.isActive)
+
+ // 현재 설정 가져오기 (URL + 활성 프리셋의 클라이언트 상태)
+ const getCurrentSettings = useCallback((): TableSettings<TData> => {
+ // 서버 렌더링 중이면 initialSettings 사용
+ if (!isClient || typeof window === 'undefined') {
+ return initialSettings
+ }
+
+ // 클라이언트에서만 URL 파라미터 읽기
+ const urlSettings = {
+ page: parseInt(searchParams.get('page') || '1'),
+ perPage: parseInt(searchParams.get('perPage') || '10'),
+ sort: JSON.parse(searchParams.get('sort') || JSON.stringify(initialSettings.sort)),
+ filters: JSON.parse(searchParams.get('filters') || '[]'),
+ joinOperator: (searchParams.get('joinOperator') as "and" | "or") || "and",
+ basicFilters: JSON.parse(searchParams.get('basicFilters') || '[]'),
+ basicJoinOperator: (searchParams.get('basicJoinOperator') as "and" | "or") || "and",
+ search: searchParams.get('search') || '',
+ from: searchParams.get('from') || undefined,
+ to: searchParams.get('to') || undefined,
+ }
+
+ // 활성 프리셋의 클라이언트 설정 병합
+ if (activePreset) {
+ return {
+ ...urlSettings,
+ columnVisibility: activePreset.settings.columnVisibility || {},
+ columnOrder: activePreset.settings.columnOrder || [],
+ pinnedColumns: activePreset.settings.pinnedColumns || { left: [], right: [] },
+ groupBy: activePreset.settings.groupBy || [],
+ expandedRows: activePreset.settings.expandedRows || []
+ }
+ }
+
+ return urlSettings
+ }, [activePreset, initialSettings, isClient, searchParams])
+
+ // 프리셋 생성
+ const createPreset = useCallback(async (
+ name: string,
+ settings: TableSettings<TData>,
+ isDefault = false
+ ) => {
+ try {
+ const response = await fetch('/api/table-presets', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ tableId,
+ name,
+ settings,
+ isDefault
+ })
+ })
+
+ if (!response.ok) {
+ throw new Error('Failed to create preset')
+ }
+
+ await mutate()
+ toast.success(`프리셋 '${name}'이 저장되었습니다`)
+ return true
+ } catch (error) {
+ console.error('Error creating preset:', error)
+ toast.error('프리셋 저장 중 오류가 발생했습니다')
+ return false
+ }
+ }, [tableId, mutate])
+
+ // 프리셋 적용
+ const applyPreset = useCallback(async (presetId: string) => {
+ try {
+ // 이전 활성 프리셋 비활성화 (있는 경우)
+ if (activePreset && activePreset.id !== presetId) {
+ await fetch(`/api/table-presets/${activePreset.id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ isActive: false })
+ })
+ }
+
+ // 새 프리셋 활성화
+ await fetch(`/api/table-presets/${presetId}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ isActive: true })
+ })
+
+ const preset = presets?.find(p => p.id === presetId)
+ if (!preset) return false
+
+ // 클라이언트에서만 URL 업데이트
+ if (isClient && typeof window !== 'undefined') {
+ const params = new URLSearchParams()
+ if (preset.settings.page !== 1) params.set('page', preset.settings.page.toString())
+ if (preset.settings.perPage !== 10) params.set('perPage', preset.settings.perPage.toString())
+ if (preset.settings.sort && preset.settings.sort.length > 0) params.set('sort', JSON.stringify(preset.settings.sort))
+ if (preset.settings.filters && preset.settings.filters.length > 0) params.set('filters', JSON.stringify(preset.settings.filters))
+ if (preset.settings.joinOperator !== 'and') params.set('joinOperator', preset.settings.joinOperator)
+ if (preset.settings.basicFilters && preset.settings.basicFilters.length > 0) params.set('basicFilters', JSON.stringify(preset.settings.basicFilters))
+ if (preset.settings.basicJoinOperator !== 'and') params.set('basicJoinOperator', preset.settings.basicJoinOperator)
+ if (preset.settings.search) params.set('search', preset.settings.search)
+ if (preset.settings.from) params.set('from', preset.settings.from)
+ if (preset.settings.to) params.set('to', preset.settings.to)
+
+ const url = window.location.pathname + '?' + params.toString()
+ window.history.pushState({}, '', url)
+ }
+
+ await mutate()
+
+ // Next.js App Router의 경우 router.push나 window.location.reload 사용
+ if (isClient && typeof window !== 'undefined') {
+ window.location.reload()
+ }
+
+ return true
+ } catch (error) {
+ console.error('Error applying preset:', error)
+ toast.error('프리셋 적용 중 오류가 발생했습니다')
+ return false
+ }
+ }, [activePreset, presets, mutate, isClient])
+
+ // 프리셋 업데이트
+ const updatePreset = useCallback(async (
+ presetId: string,
+ settings: TableSettings<TData>
+ ) => {
+ try {
+ const response = await fetch(`/api/table-presets/${presetId}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ settings })
+ })
+
+ if (!response.ok) {
+ throw new Error('Failed to update preset')
+ }
+
+ await mutate()
+ const preset = presets?.find(p => p.id === presetId)
+ toast.success(`${preset?.name || '프리셋'}이 업데이트되었습니다`)
+ } catch (error) {
+ console.error('Error updating preset:', error)
+ toast.error('프리셋 업데이트 중 오류가 발생했습니다')
+ }
+ }, [presets, mutate])
+
+ // 프리셋 삭제
+ const deletePreset = useCallback(async (presetId: string) => {
+ try {
+ const response = await fetch(`/api/table-presets/${presetId}`, {
+ method: 'DELETE'
+ })
+
+ if (!response.ok) {
+ throw new Error('Failed to delete preset')
+ }
+
+ // 삭제할 프리셋이 활성 프리셋인 경우 다른 프리셋을 활성화
+ if (activePreset?.id === presetId && presets && presets.length > 1) {
+ const defaultPreset = presets.find(p => p.isDefault && p.id !== presetId) ||
+ presets.find(p => p.id !== presetId)
+ if (defaultPreset) {
+ await applyPreset(defaultPreset.id)
+ return true
+ }
+ }
+
+ await mutate()
+ const preset = presets?.find(p => p.id === presetId)
+ toast.success(`프리셋 '${preset?.name}'이 삭제되었습니다`)
+ return true
+ } catch (error) {
+ console.error('Error deleting preset:', error)
+ toast.error('프리셋 삭제 중 오류가 발생했습니다')
+ return false
+ }
+ }, [activePreset, presets, mutate, applyPreset])
+
+ // 기본 프리셋 설정
+ const setDefaultPreset = useCallback(async (presetId: string) => {
+ try {
+ // 기존 기본 프리셋 해제
+ const defaultPreset = presets?.find(p => p.isDefault)
+ if (defaultPreset && defaultPreset.id !== presetId) {
+ await fetch(`/api/table-presets/${defaultPreset.id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ isDefault: false })
+ })
+ }
+
+ // 새 기본 프리셋 설정
+ await fetch(`/api/table-presets/${presetId}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ isDefault: true })
+ })
+
+ await mutate()
+ toast.success('기본 프리셋이 변경되었습니다')
+ } catch (error) {
+ console.error('Error setting default preset:', error)
+ toast.error('기본 프리셋 설정 중 오류가 발생했습니다')
+ }
+ }, [presets, mutate])
+
+ // 프리셋 이름 변경
+ const renamePreset = useCallback(async (presetId: string, newName: string) => {
+ try {
+ if (!newName.trim()) {
+ toast.error('프리셋 이름을 입력해주세요')
+ return false
+ }
+
+ const response = await fetch(`/api/table-presets/${presetId}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: newName.trim() })
+ })
+
+ if (!response.ok) {
+ const error = await response.json()
+ throw new Error(error.message || 'Failed to rename preset')
+ }
+
+ await mutate()
+ toast.success('프리셋 이름이 변경되었습니다')
+ return true
+ } catch (error) {
+ console.error('Error renaming preset:', error)
+ toast.error('프리셋 이름 변경 중 오류가 발생했습니다')
+ return false
+ }
+ }, [mutate])
+
+ // 클라이언트 상태 업데이트 (컬럼 가시성, 핀 등)
+ const updateClientState = useCallback(async (newClientState: Partial<TableSettings<TData>>) => {
+ if (!activePreset) return
+
+ const updatedSettings = {
+ ...activePreset.settings,
+ ...newClientState
+ }
+
+ await updatePreset(activePreset.id, updatedSettings)
+ }, [activePreset, updatePreset])
+
+ // URL 변경 감지 및 미저장 변경사항 체크
+ useEffect(() => {
+ if (!isClient || !presets || !activePreset) return
+
+ const currentSettings = getCurrentSettings()
+
+ // 현재 URL 설정과 활성 프리셋 설정 비교
+ const isSettingsChanged =
+ currentSettings.perPage !== activePreset.settings.perPage ||
+ JSON.stringify(currentSettings.sort) !== JSON.stringify(activePreset.settings.sort) ||
+ JSON.stringify(currentSettings.filters) !== JSON.stringify(activePreset.settings.filters) ||
+ currentSettings.joinOperator !== activePreset.settings.joinOperator ||
+ JSON.stringify(currentSettings.basicFilters) !== JSON.stringify(activePreset.settings.basicFilters) ||
+ currentSettings.basicJoinOperator !== activePreset.settings.basicJoinOperator ||
+ currentSettings.search !== activePreset.settings.search
+
+ setHasUnsavedChanges(isSettingsChanged)
+ }, [isClient, presets, activePreset, getCurrentSettings])
+
+ return {
+ // 상태
+ presets: presets || [],
+ activePresetId: activePreset?.id || null,
+ isLoading,
+ hasUnsavedChanges,
+
+ // 액션
+ createPreset,
+ applyPreset,
+ updatePreset,
+ deletePreset,
+ setDefaultPreset,
+ renamePreset,
+ updateClientState,
+ getCurrentSettings,
+ }
+} \ No newline at end of file
diff --git a/components/po-rfq/detail-table/add-vendor-dialog.tsx b/components/po-rfq/detail-table/add-vendor-dialog.tsx
new file mode 100644
index 00000000..5e83ad8f
--- /dev/null
+++ b/components/po-rfq/detail-table/add-vendor-dialog.tsx
@@ -0,0 +1,512 @@
+"use client"
+
+import * as React from "react"
+import { useState } from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { toast } from "sonner"
+import { Check, ChevronsUpDown, File, Upload, X } from "lucide-react"
+
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { ProcurementRfqsView } from "@/db/schema"
+import { addVendorToRfq } from "@/lib/procurement-rfqs/services"
+import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
+import { cn } from "@/lib/utils"
+import { ScrollArea } from "@/components/ui/scroll-area"
+
+// 필수 필드를 위한 커스텀 레이블 컴포넌트
+const RequiredLabel = ({ children }: { children: React.ReactNode }) => (
+ <FormLabel>
+ {children} <span className="text-red-500">*</span>
+ </FormLabel>
+);
+
+// 폼 유효성 검증 스키마
+const vendorFormSchema = z.object({
+ vendorId: z.string().min(1, "벤더를 선택해주세요"),
+ currency: z.string().min(1, "통화를 선택해주세요"),
+ paymentTermsCode: z.string().min(1, "지불 조건을 선택해주세요"),
+ incotermsCode: z.string().min(1, "인코텀즈를 선택해주세요"),
+ incotermsDetail: z.string().optional(),
+ deliveryDate: z.string().optional(),
+ taxCode: z.string().optional(),
+ placeOfShipping: z.string().optional(),
+ placeOfDestination: z.string().optional(),
+ materialPriceRelatedYn: z.boolean().default(false),
+})
+
+type VendorFormValues = z.infer<typeof vendorFormSchema>
+
+interface AddVendorDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedRfq: ProcurementRfqsView | null
+ // 벤더 및 기타 옵션 데이터를 prop으로 받음
+ vendors?: { id: number; vendorName: string; vendorCode: string }[]
+ currencies?: { code: string; name: string }[]
+ paymentTerms?: { code: string; description: string }[]
+ incoterms?: { code: string; description: string }[]
+ onSuccess?: () => void
+ existingVendorIds?: number[]
+
+}
+
+export function AddVendorDialog({
+ open,
+ onOpenChange,
+ selectedRfq,
+ vendors = [],
+ currencies = [],
+ paymentTerms = [],
+ incoterms = [],
+ onSuccess,
+ existingVendorIds = [], // 기본값 빈 배열
+}: AddVendorDialogProps) {
+
+
+ const availableVendors = React.useMemo(() => {
+ return vendors.filter(vendor => !existingVendorIds.includes(vendor.id));
+ }, [vendors, existingVendorIds]);
+
+
+ // 파일 업로드 상태 관리
+ const [attachments, setAttachments] = useState<File[]>([])
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ // 벤더 선택을 위한 팝오버 상태
+ const [vendorOpen, setVendorOpen] = useState(false)
+
+ const form = useForm<VendorFormValues>({
+ resolver: zodResolver(vendorFormSchema),
+ defaultValues: {
+ vendorId: "",
+ currency: "",
+ paymentTermsCode: "",
+ incotermsCode: "",
+ incotermsDetail: "",
+ deliveryDate: "",
+ taxCode: "",
+ placeOfShipping: "",
+ placeOfDestination: "",
+ materialPriceRelatedYn: false,
+ },
+ })
+
+ // 폼 제출 핸들러
+ async function onSubmit(values: VendorFormValues) {
+ if (!selectedRfq) {
+ toast.error("선택된 RFQ가 없습니다")
+ return
+ }
+
+ try {
+ setIsSubmitting(true)
+
+ // FormData 생성
+ const formData = new FormData()
+ formData.append("rfqId", selectedRfq.id.toString())
+
+ // 폼 데이터 추가
+ Object.entries(values).forEach(([key, value]) => {
+ formData.append(key, value.toString())
+ })
+
+ // 첨부파일 추가
+ attachments.forEach((file, index) => {
+ formData.append(`attachment-${index}`, file)
+ })
+
+ // 서버 액션 호출
+ const result = await addVendorToRfq(formData)
+
+ if (result.success) {
+ toast.success("벤더가 성공적으로 추가되었습니다")
+ onOpenChange(false)
+ form.reset()
+ setAttachments([])
+ onSuccess?.()
+ } else {
+ toast.error(result.message || "벤더 추가 중 오류가 발생했습니다")
+ }
+ } catch (error) {
+ console.error("벤더 추가 오류:", error)
+ toast.error("벤더 추가 중 오류가 발생했습니다")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ // 파일 업로드 핸들러
+ const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
+ if (event.target.files && event.target.files.length > 0) {
+ const newFiles = Array.from(event.target.files)
+ setAttachments((prev) => [...prev, ...newFiles])
+ }
+ }
+
+ // 파일 삭제 핸들러
+ const handleRemoveFile = (index: number) => {
+ setAttachments((prev) => prev.filter((_, i) => i !== index))
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ {/* 커스텀 DialogContent - 고정 헤더, 스크롤 가능한 콘텐츠, 고정 푸터 */}
+ <DialogContent className="sm:max-w-[600px] p-0 h-[85vh] flex flex-col overflow-hidden">
+ {/* 고정 헤더 */}
+ <div className="p-6 border-b">
+ <DialogHeader>
+ <DialogTitle>벤더 추가</DialogTitle>
+ <DialogDescription>
+ {selectedRfq ? (
+ <>
+ <span className="font-medium">{selectedRfq.rfqCode}</span> RFQ에 벤더를 추가합니다.
+ </>
+ ) : (
+ "RFQ에 벤더를 추가합니다."
+ )}
+ </DialogDescription>
+ </DialogHeader>
+ </div>
+
+ {/* 스크롤 가능한 콘텐츠 영역 */}
+ <div className="flex-1 overflow-y-auto p-6">
+ <Form {...form}>
+ <form id="vendor-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ {/* 검색 가능한 벤더 선택 필드 */}
+ <FormField
+ control={form.control}
+ name="vendorId"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <RequiredLabel>벤더</RequiredLabel>
+ <Popover open={vendorOpen} onOpenChange={setVendorOpen}>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={vendorOpen}
+ className={cn(
+ "w-full justify-between",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value
+ ? vendors.find((vendor) => String(vendor.id) === field.value)
+ ? `${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorName} (${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorCode})`
+ : "벤더를 선택하세요"
+ : "벤더를 선택하세요"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="벤더 검색..." />
+ <CommandEmpty>검색 결과가 없습니다</CommandEmpty>
+ <CommandList>
+ <ScrollArea className="h-60">
+ <CommandGroup>
+ {availableVendors.length > 0 ? (
+ availableVendors.map((vendor) => (
+ <CommandItem
+ key={vendor.id}
+ value={`${vendor.vendorName} ${vendor.vendorCode}`}
+ onSelect={() => {
+ form.setValue("vendorId", String(vendor.id), {
+ shouldValidate: true,
+ })
+ setVendorOpen(false)
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ String(vendor.id) === field.value
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {vendor.vendorName} ({vendor.vendorCode})
+ </CommandItem>
+ ))
+ ) : (
+ <CommandItem disabled>추가 가능한 벤더가 없습니다</CommandItem>
+ )}
+ </CommandGroup>
+ </ScrollArea>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="currency"
+ render={({ field }) => (
+ <FormItem>
+ <RequiredLabel>통화</RequiredLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="통화를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {currencies.map((currency) => (
+ <SelectItem key={currency.code} value={currency.code}>
+ {currency.name} ({currency.code})
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="paymentTermsCode"
+ render={({ field }) => (
+ <FormItem>
+ <RequiredLabel>지불 조건</RequiredLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="지불 조건 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {paymentTerms.map((term) => (
+ <SelectItem key={term.code} value={term.code}>
+ {term.description}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="incotermsCode"
+ render={({ field }) => (
+ <FormItem>
+ <RequiredLabel>인코텀즈</RequiredLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="인코텀즈 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {incoterms.map((incoterm) => (
+ <SelectItem key={incoterm.code} value={incoterm.code}>
+ {incoterm.description}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 나머지 필드들은 동일하게 유지 */}
+ <FormField
+ control={form.control}
+ name="incotermsDetail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>인코텀즈 세부사항</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="인코텀즈 세부사항" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="deliveryDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>납품 예정일</FormLabel>
+ <FormControl>
+ <Input {...field} type="date" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="taxCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>세금 코드</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="세금 코드" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="placeOfShipping"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>선적지</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="선적지" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="placeOfDestination"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>도착지</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="도착지" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <FormField
+ control={form.control}
+ name="materialPriceRelatedYn"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
+ <FormControl>
+ <input
+ type="checkbox"
+ checked={field.value}
+ onChange={field.onChange}
+ className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none">
+ <FormLabel>자재 가격 관련 여부</FormLabel>
+ </div>
+ </FormItem>
+ )}
+ />
+
+ {/* 파일 업로드 섹션 */}
+ <div className="space-y-2">
+ <Label>첨부 파일</Label>
+ <div className="border rounded-md p-4">
+ <div className="flex items-center justify-center w-full">
+ <label
+ htmlFor="file-upload"
+ className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100"
+ >
+ <div className="flex flex-col items-center justify-center pt-5 pb-6">
+ <Upload className="w-8 h-8 mb-2 text-gray-500" />
+ <p className="mb-2 text-sm text-gray-500">
+ <span className="font-semibold">클릭하여 파일 업로드</span> 또는 파일을 끌어 놓으세요
+ </p>
+ <p className="text-xs text-gray-500">PDF, DOCX, XLSX, JPG, PNG (최대 10MB)</p>
+ </div>
+ <input
+ id="file-upload"
+ type="file"
+ className="hidden"
+ multiple
+ onChange={handleFileUpload}
+ />
+ </label>
+ </div>
+
+ {/* 업로드된 파일 목록 */}
+ {attachments.length > 0 && (
+ <div className="mt-4 space-y-2">
+ <h4 className="text-sm font-medium">업로드된 파일</h4>
+ <ul className="space-y-2">
+ {attachments.map((file, index) => (
+ <li
+ key={index}
+ className="flex items-center justify-between p-2 text-sm bg-gray-50 rounded-md"
+ >
+ <div className="flex items-center space-x-2">
+ <File className="w-4 h-4 text-gray-500" />
+ <span className="truncate max-w-[250px]">{file.name}</span>
+ <span className="text-gray-500 text-xs">
+ ({(file.size / 1024).toFixed(1)} KB)
+ </span>
+ </div>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleRemoveFile(index)}
+ >
+ <X className="w-4 h-4 text-gray-500" />
+ </Button>
+ </li>
+ ))}
+ </ul>
+ </div>
+ )}
+ </div>
+ </div>
+ </form>
+ </Form>
+ </div>
+
+ {/* 고정 푸터 */}
+ <div className="p-6 border-t">
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ form="vendor-form"
+ disabled={isSubmitting}
+ >
+ {isSubmitting ? "처리 중..." : "벤더 추가"}
+ </Button>
+ </DialogFooter>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/components/po-rfq/detail-table/delete-vendor-dialog.tsx b/components/po-rfq/detail-table/delete-vendor-dialog.tsx
new file mode 100644
index 00000000..49d982e1
--- /dev/null
+++ b/components/po-rfq/detail-table/delete-vendor-dialog.tsx
@@ -0,0 +1,150 @@
+"use client"
+
+import * as React from "react"
+import { type RfqDetailView } from "./rfq-detail-column"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Trash } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import { deleteRfqDetail } from "@/lib/procurement-rfqs/services"
+
+
+interface DeleteRfqDetailDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ detail: RfqDetailView | null
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteRfqDetailDialog({
+ detail,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteRfqDetailDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ if (!detail) return
+
+ startDeleteTransition(async () => {
+ try {
+ const result = await deleteRfqDetail(detail.detailId)
+
+ if (!result.success) {
+ toast.error(result.message || "삭제 중 오류가 발생했습니다")
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("RFQ 벤더 정보가 삭제되었습니다")
+ onSuccess?.()
+ } catch (error) {
+ console.error("RFQ 벤더 삭제 오류:", error)
+ toast.error("삭제 중 오류가 발생했습니다")
+ }
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="destructive" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
+ <DialogDescription>
+ 이 작업은 되돌릴 수 없습니다. 벤더 &quot;{detail?.vendorName}&quot;({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="선택한 RFQ 벤더 정보 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 삭제
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="destructive" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
+ <DrawerDescription>
+ 이 작업은 되돌릴 수 없습니다. 벤더 &quot;{detail?.vendorName}&quot;({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="선택한 RFQ 벤더 정보 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 삭제
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/components/po-rfq/detail-table/rfq-detail-column.tsx b/components/po-rfq/detail-table/rfq-detail-column.tsx
new file mode 100644
index 00000000..31f251ce
--- /dev/null
+++ b/components/po-rfq/detail-table/rfq-detail-column.tsx
@@ -0,0 +1,369 @@
+"use client"
+
+import * as React from "react"
+import type { ColumnDef, Row } from "@tanstack/react-table";
+import { formatDate, formatDateTime } from "@/lib/utils"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Ellipsis, MessageCircle } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+
+export interface DataTableRowAction<TData> {
+ row: Row<TData>;
+ type: "delete" | "update" | "communicate"; // communicate 타입 추가
+}
+
+// procurementRfqDetailsView 타입 정의 (DB 스키마에 맞게 조정 필요)
+export interface RfqDetailView {
+ detailId: number
+ rfqId: number
+ rfqCode: string
+ vendorId?: number | null // 벤더 ID 필드 추가
+ projectCode: string | null
+ projectName: string | null
+ vendorCountry: string | null
+ itemCode: string | null
+ itemName: string | null
+ vendorName: string | null
+ vendorCode: string | null
+ currency: string | null
+ paymentTermsCode: string | null
+ paymentTermsDescription: string | null
+ incotermsCode: string | null
+ incotermsDescription: string | null
+ incotermsDetail: string | null
+ deliveryDate: Date | null
+ taxCode: string | null
+ placeOfShipping: string | null
+ placeOfDestination: string | null
+ materialPriceRelatedYn: boolean | null
+ hasQuotation: boolean | null
+ updatedByUserName: string | null
+ quotationStatus: string | null
+ updatedAt: Date | null
+ prItemsCount: number
+ majorItemsCount: number
+ quotationVersion:number | null
+ // 커뮤니케이션 관련 필드 추가
+ commentCount?: number // 전체 코멘트 수
+ unreadCount?: number // 읽지 않은 코멘트 수
+ lastCommentDate?: Date // 마지막 코멘트 날짜
+}
+
+interface GetColumnsProps<TData> {
+ setRowAction: React.Dispatch<
+ React.SetStateAction<DataTableRowAction<TData> | null>
+ >;
+ unreadMessages?: Record<number, number>; // 벤더 ID별 읽지 않은 메시지 수
+}
+
+export function getRfqDetailColumns({
+ setRowAction,
+ unreadMessages = {},
+}: GetColumnsProps<RfqDetailView>): ColumnDef<RfqDetailView>[] {
+ return [
+ {
+ accessorKey: "quotationStatus",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="견적 상태" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("quotationStatus")}</div>,
+ meta: {
+ excelHeader: "견적 상태"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "quotationVersion",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="견적 버전" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("quotationVersion")}</div>,
+ meta: {
+ excelHeader: "견적 버전"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "vendorCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더 코드" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("vendorCode")}</div>,
+ meta: {
+ excelHeader: "벤더 코드"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "vendorName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더명" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("vendorName")}</div>,
+ meta: {
+ excelHeader: "벤더명"
+ },
+ enableResizing: true,
+ size: 160,
+ },
+ {
+ accessorKey: "vendorType",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="내외자" />
+ ),
+ cell: ({ row }) => <div>{row.original.vendorCountry === "KR"?"D":"F"}</div>,
+ meta: {
+ excelHeader: "내외자"
+ },
+ enableResizing: true,
+ size: 80,
+ },
+ {
+ accessorKey: "currency",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="통화" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("currency")}</div>,
+ meta: {
+ excelHeader: "통화"
+ },
+ enableResizing: true,
+ size: 80,
+ },
+ {
+ accessorKey: "paymentTermsCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="지불 조건 코드" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("paymentTermsCode")}</div>,
+ meta: {
+ excelHeader: "지불 조건 코드"
+ },
+ enableResizing: true,
+ size: 140,
+ },
+ {
+ accessorKey: "paymentTermsDescription",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="지불 조건" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("paymentTermsDescription")}</div>,
+ meta: {
+ excelHeader: "지불 조건"
+ },
+ enableResizing: true,
+ size: 160,
+ },
+ {
+ accessorKey: "incotermsCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="인코텀스 코드" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("incotermsCode")}</div>,
+ meta: {
+ excelHeader: "인코텀스 코드"
+ },
+ enableResizing: true,
+ size: 140,
+ },
+ {
+ accessorKey: "incotermsDescription",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="인코텀스" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("incotermsDescription")}</div>,
+ meta: {
+ excelHeader: "인코텀스"
+ },
+ enableResizing: true,
+ size: 160,
+ },
+ {
+ accessorKey: "incotermsDetail",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="인코텀스 상세" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("incotermsDetail")}</div>,
+ meta: {
+ excelHeader: "인코텀스 상세"
+ },
+ enableResizing: true,
+ size: 160,
+ },
+ {
+ accessorKey: "deliveryDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="납품일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDate(value as Date, "KR") : "";
+ },
+ meta: {
+ excelHeader: "납품일"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "taxCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="세금 코드" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("taxCode")}</div>,
+ meta: {
+ excelHeader: "세금 코드"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "placeOfShipping",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="선적지" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("placeOfShipping")}</div>,
+ meta: {
+ excelHeader: "선적지"
+ },
+ enableResizing: true,
+ size: 140,
+ },
+ {
+ accessorKey: "placeOfDestination",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="도착지" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("placeOfDestination")}</div>,
+ meta: {
+ excelHeader: "도착지"
+ },
+ enableResizing: true,
+ size: 140,
+ },
+ {
+ accessorKey: "materialPriceRelatedYn",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="원자재 가격 연동" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("materialPriceRelatedYn") ? "Y" : "N"}</div>,
+ meta: {
+ excelHeader: "원자재 가격 연동"
+ },
+ enableResizing: true,
+ size: 140,
+ },
+ {
+ accessorKey: "updatedByUserName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수정자" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("updatedByUserName")}</div>,
+ meta: {
+ excelHeader: "수정자"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수정일시" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDateTime(value as Date, "KR") : "";
+ },
+ meta: {
+ excelHeader: "수정일시"
+ },
+ enableResizing: true,
+ size: 140,
+ },
+ // 커뮤니케이션 컬럼 추가
+ {
+ id: "communication",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="커뮤니케이션" />
+ ),
+ cell: ({ row }) => {
+ const vendorId = row.original.vendorId || 0;
+ const unreadCount = unreadMessages[vendorId] || 0;
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative p-0 h-8 w-8 flex items-center justify-center"
+ onClick={() => setRowAction({ row, type: "communicate" })}
+ >
+ <MessageCircle className="h-4 w-4" />
+ {unreadCount > 0 && (
+ <Badge
+ variant="destructive"
+ className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs"
+ >
+ {unreadCount}
+ </Badge>
+ )}
+ </Button>
+ );
+ },
+ enableResizing: false,
+ size: 80,
+ },
+ {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-7 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ Edit
+ </DropdownMenuItem>
+
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+ ]
+} \ No newline at end of file
diff --git a/components/po-rfq/detail-table/rfq-detail-table.tsx b/components/po-rfq/detail-table/rfq-detail-table.tsx
new file mode 100644
index 00000000..787b7c3b
--- /dev/null
+++ b/components/po-rfq/detail-table/rfq-detail-table.tsx
@@ -0,0 +1,519 @@
+"use client"
+
+import * as React from "react"
+import { useEffect, useState } from "react"
+import {
+ DataTableRowAction,
+ getRfqDetailColumns,
+ RfqDetailView
+} from "./rfq-detail-column"
+import { toast } from "sonner"
+
+import { Skeleton } from "@/components/ui/skeleton"
+import { Card, CardContent } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { ProcurementRfqsView } from "@/db/schema"
+import {
+ fetchCurrencies,
+ fetchIncoterms,
+ fetchPaymentTerms,
+ fetchRfqDetails,
+ fetchVendors,
+ fetchUnreadMessages
+} from "@/lib/procurement-rfqs/services"
+import { ClientDataTable } from "@/components/client-data-table/data-table"
+import { AddVendorDialog } from "./add-vendor-dialog"
+import { Button } from "@/components/ui/button"
+import { Loader2, UserPlus, BarChart2 } from "lucide-react" // 아이콘 추가
+import { DeleteRfqDetailDialog } from "./delete-vendor-dialog"
+import { UpdateRfqDetailSheet } from "./update-vendor-sheet"
+import { VendorCommunicationDrawer } from "./vendor-communication-drawer"
+import { VendorQuotationComparisonDialog } from "./vendor-quotation-comparison-dialog" // 새로운 컴포넌트 임포트
+
+// 프로퍼티 정의
+interface RfqDetailTablesProps {
+ selectedRfq: ProcurementRfqsView | null
+}
+
+// 데이터 타입 정의
+interface Vendor {
+ id: number;
+ vendorName: string;
+ vendorCode: string | null; // Update this to allow null
+ // 기타 필요한 벤더 속성들
+}
+
+interface Currency {
+ code: string;
+ name: string;
+}
+
+interface PaymentTerm {
+ code: string;
+ description: string;
+}
+
+interface Incoterm {
+ code: string;
+ description: string;
+}
+
+export function RfqDetailTables({ selectedRfq }: RfqDetailTablesProps) {
+
+ console.log("selectedRfq", selectedRfq)
+ // 상태 관리
+ const [isLoading, setIsLoading] = useState(false)
+ const [isRefreshing, setIsRefreshing] = useState(false)
+ const [details, setDetails] = useState<RfqDetailView[]>([])
+ const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false)
+ const [updateSheetOpen, setUpdateSheetOpen] = React.useState(false)
+ const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false)
+ const [selectedDetail, setSelectedDetail] = React.useState<RfqDetailView | null>(null)
+
+ const [vendors, setVendors] = React.useState<Vendor[]>([])
+ const [currencies, setCurrencies] = React.useState<Currency[]>([])
+ const [paymentTerms, setPaymentTerms] = React.useState<PaymentTerm[]>([])
+ const [incoterms, setIncoterms] = React.useState<Incoterm[]>([])
+ const [isAdddialogLoading, setIsAdddialogLoading] = useState(false)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqDetailView> | null>(null)
+
+ // 벤더 커뮤니케이션 상태 관리
+ const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false)
+ const [selectedVendor, setSelectedVendor] = useState<RfqDetailView | null>(null)
+
+ // 읽지 않은 메시지 개수
+ const [unreadMessages, setUnreadMessages] = useState<Record<number, number>>({})
+ const [isUnreadLoading, setIsUnreadLoading] = useState(false)
+
+ // 견적 비교 다이얼로그 상태 관리 (추가)
+ const [comparisonDialogOpen, setComparisonDialogOpen] = useState(false)
+
+ const existingVendorIds = React.useMemo(() => {
+ return details.map(detail => Number(detail.vendorId)).filter(Boolean);
+ }, [details]);
+
+ const handleAddVendor = async () => {
+ try {
+ setIsAdddialogLoading(true)
+
+ // 필요한 데이터 로드 (벤더, 통화, 지불조건, 인코텀즈)
+ const [vendorsData, currenciesData, paymentTermsData, incotermsData] = await Promise.all([
+ fetchVendors(),
+ fetchCurrencies(),
+ fetchPaymentTerms(),
+ fetchIncoterms()
+ ])
+
+ setVendors(vendorsData.data || [])
+ setCurrencies(currenciesData.data || [])
+ setPaymentTerms(paymentTermsData.data || [])
+ setIncoterms(incotermsData.data || [])
+
+ setVendorDialogOpen(true)
+ } catch (error) {
+ console.error("데이터 로드 오류:", error)
+ toast.error("벤더 정보를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setIsAdddialogLoading(false)
+ }
+ }
+
+ // 견적 비교 다이얼로그 열기 핸들러 (추가)
+ const handleOpenComparisonDialog = () => {
+ // 제출된 견적이 있는 벤더가 최소 1개 이상 있는지 확인
+ const hasSubmittedQuotations = details.some(detail =>
+ detail.hasQuotation && detail.quotationStatus === "Submitted"
+ );
+
+ if (!hasSubmittedQuotations) {
+ toast.warning("제출된 견적이 없습니다.");
+ return;
+ }
+
+ setComparisonDialogOpen(true);
+ }
+
+ // 읽지 않은 메시지 로드
+ const loadUnreadMessages = async () => {
+ if (!selectedRfq || !selectedRfq.id) return;
+
+ try {
+ setIsUnreadLoading(true);
+
+ // 읽지 않은 메시지 수 가져오기
+ const unreadData = await fetchUnreadMessages(selectedRfq.id);
+ setUnreadMessages(unreadData);
+ } catch (error) {
+ console.error("읽지 않은 메시지 로드 오류:", error);
+ // 조용히 실패 - 사용자에게 알림 표시하지 않음
+ } finally {
+ setIsUnreadLoading(false);
+ }
+ };
+
+ // 칼럼 정의 - unreadMessages 상태 전달
+ const columns = React.useMemo(() =>
+ getRfqDetailColumns({
+ setRowAction,
+ unreadMessages
+ }), [unreadMessages])
+
+ // 필터 필드 정의 (필터 사용 시)
+ const advancedFilterFields = React.useMemo(
+ () => [
+ {
+ id: "vendorName",
+ label: "벤더명",
+ type: "text",
+ },
+ {
+ id: "vendorCode",
+ label: "벤더 코드",
+ type: "text",
+ },
+ {
+ id: "currency",
+ label: "통화",
+ type: "text",
+ },
+ ],
+ []
+ )
+
+ // RFQ ID가 변경될 때 데이터 로드
+ useEffect(() => {
+ async function loadRfqDetails() {
+ if (!selectedRfq || !selectedRfq.id) {
+ setDetails([])
+ return
+ }
+
+ try {
+ setIsLoading(true)
+ const transformRfqDetails = (data: any[]): RfqDetailView[] => {
+ return data.map(item => ({
+ ...item,
+ // Convert vendorId from string|null to number|undefined
+ vendorId: item.vendorId ? Number(item.vendorId) : undefined,
+ // Transform any other fields that need type conversion
+ }));
+ };
+
+ // Then in your useEffect:
+ const result = await fetchRfqDetails(selectedRfq.id);
+ setDetails(transformRfqDetails(result.data));
+
+ // 읽지 않은 메시지 개수 로드
+ await loadUnreadMessages();
+ } catch (error) {
+ console.error("RFQ 디테일 로드 오류:", error)
+ setDetails([])
+ toast.error("RFQ 세부정보를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadRfqDetails()
+ }, [selectedRfq])
+
+ // 주기적으로 읽지 않은 메시지 갱신 (60초마다)
+ useEffect(() => {
+ if (!selectedRfq || !selectedRfq.id) return;
+
+ const intervalId = setInterval(() => {
+ loadUnreadMessages();
+ }, 60000); // 60초마다 갱신
+
+ return () => clearInterval(intervalId);
+ }, [selectedRfq]);
+
+ // rowAction 처리
+ useEffect(() => {
+ if (!rowAction) return
+
+ const handleRowAction = async () => {
+ try {
+ // 통신 액션인 경우 드로어 열기
+ if (rowAction.type === "communicate") {
+ setSelectedVendor(rowAction.row.original);
+ setCommunicationDrawerOpen(true);
+
+ // 해당 벤더의 읽지 않은 메시지를 0으로 설정 (메시지를 읽은 것으로 간주)
+ const vendorId = rowAction.row.original.vendorId;
+ if (vendorId) {
+ setUnreadMessages(prev => ({
+ ...prev,
+ [vendorId]: 0
+ }));
+ }
+
+ // rowAction 초기화
+ setRowAction(null);
+ return;
+ }
+
+ // 다른 액션들은 기존과 동일하게 처리
+ setIsAdddialogLoading(true);
+
+ // 필요한 데이터 로드 (벤더, 통화, 지불조건, 인코텀즈)
+ const [vendorsData, currenciesData, paymentTermsData, incotermsData] = await Promise.all([
+ fetchVendors(),
+ fetchCurrencies(),
+ fetchPaymentTerms(),
+ fetchIncoterms()
+ ]);
+
+ setVendors(vendorsData.data || []);
+ setCurrencies(currenciesData.data || []);
+ setPaymentTerms(paymentTermsData.data || []);
+ setIncoterms(incotermsData.data || []);
+
+ // 이제 데이터가 로드되었으므로 필요한 작업 수행
+ if (rowAction.type === "update") {
+ setSelectedDetail(rowAction.row.original);
+ setUpdateSheetOpen(true);
+ } else if (rowAction.type === "delete") {
+ setSelectedDetail(rowAction.row.original);
+ setDeleteDialogOpen(true);
+ }
+ } catch (error) {
+ console.error("데이터 로드 오류:", error);
+ toast.error("데이터를 불러오는 중 오류가 발생했습니다");
+ } finally {
+ // communicate 타입이 아닌 경우에만 로딩 상태 변경
+ if (rowAction && rowAction.type !== "communicate") {
+ setIsAdddialogLoading(false);
+ }
+ }
+ };
+
+ handleRowAction();
+ }, [rowAction])
+
+ // RFQ가 선택되지 않은 경우
+ if (!selectedRfq) {
+ return (
+ <div className="flex h-full items-center justify-center text-muted-foreground">
+ RFQ를 선택하세요
+ </div>
+ )
+ }
+
+ // 로딩 중인 경우
+ if (isLoading) {
+ return (
+ <div className="p-4 space-y-4">
+ <Skeleton className="h-8 w-1/2" />
+ <Skeleton className="h-24 w-full" />
+ <Skeleton className="h-48 w-full" />
+ </div>
+ )
+ }
+
+ const handleRefreshData = async () => {
+ if (!selectedRfq || !selectedRfq.id) return
+
+ try {
+ setIsRefreshing(true)
+
+ const transformRfqDetails = (data: any[]): RfqDetailView[] => {
+ return data.map(item => ({
+ ...item,
+ // Convert vendorId from string|null to number|undefined
+ vendorId: item.vendorId ? Number(item.vendorId) : undefined,
+ // Transform any other fields that need type conversion
+ }));
+ };
+
+ // Then in your useEffect:
+ const result = await fetchRfqDetails(selectedRfq.id);
+ setDetails(transformRfqDetails(result.data));
+
+ // 읽지 않은 메시지 개수 업데이트
+ await loadUnreadMessages();
+
+ toast.success("데이터가 새로고침되었습니다")
+ } catch (error) {
+ console.error("RFQ 디테일 로드 오류:", error)
+ toast.error("데이터 새로고침 중 오류가 발생했습니다")
+ } finally {
+ setIsRefreshing(false)
+ }
+ }
+
+ // 전체 읽지 않은 메시지 수 계산
+ const totalUnreadMessages = Object.values(unreadMessages).reduce((sum, count) => sum + count, 0);
+
+ // 견적이 있는 벤더 수 계산
+ const vendorsWithQuotations = details.filter(detail => detail.hasQuotation && detail.quotationStatus === "Submitted").length;
+
+ return (
+ <div className="p-4 h-full overflow-auto">
+
+ {/* 메시지 및 새로고침 영역 */}
+
+
+ {/* 테이블 또는 빈 상태 표시 */}
+ {details.length > 0 ? (
+
+ <ClientDataTable
+ columns={columns}
+ data={details}
+ advancedFilterFields={advancedFilterFields}
+ >
+
+ <div className="flex justify-between items-center">
+ <div className="flex items-center gap-2 mr-2">
+ {totalUnreadMessages > 0 && (
+ <Badge variant="destructive" className="h-6">
+ 읽지 않은 메시지: {totalUnreadMessages}건
+ </Badge>
+ )}
+ {vendorsWithQuotations > 0 && (
+ <Badge variant="outline" className="h-6">
+ 견적 제출: {vendorsWithQuotations}개 벤더
+ </Badge>
+ )}
+ </div>
+ <div className="flex gap-2">
+ {/* 견적 비교 버튼 추가 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleOpenComparisonDialog}
+ className="gap-2"
+ disabled={
+ !selectedRfq ||
+ details.length === 0 ||
+ (!!selectedRfq.rfqSealedYn && selectedRfq.dueDate && new Date() < new Date(selectedRfq.dueDate))
+ }
+ >
+ <BarChart2 className="size-4" aria-hidden="true" />
+ <span>견적 비교</span>
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefreshData}
+ disabled={isRefreshing}
+ >
+ {isRefreshing ? (
+ <>
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
+ 새로고침 중...
+ </>
+ ) : (
+ '새로고침'
+ )}
+ </Button>
+ </div>
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleAddVendor}
+ className="gap-2"
+ disabled={!selectedRfq || isAdddialogLoading}
+ >
+ {isAdddialogLoading ? (
+ <>
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ <span>로딩 중...</span>
+ </>
+ ) : (
+ <>
+ <UserPlus className="size-4" aria-hidden="true" />
+ <span>벤더 추가</span>
+ </>
+ )}
+ </Button>
+ </ClientDataTable>
+
+ ) : (
+ <div className="flex h-48 items-center justify-center text-muted-foreground border rounded-md">
+ <div className="flex flex-col items-center gap-4">
+ <p>RFQ에 대한 세부 정보가 없습니다</p>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleAddVendor}
+ className="gap-2"
+ disabled={!selectedRfq || isAdddialogLoading}
+ >
+ {isAdddialogLoading ? (
+ <>
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ <span>로딩 중...</span>
+ </>
+ ) : (
+ <>
+ <UserPlus className="size-4" aria-hidden="true" />
+ <span>벤더 추가</span>
+ </>
+ )}
+ </Button>
+ </div>
+ </div>
+ )}
+
+ {/* 벤더 추가 다이얼로그 */}
+ <AddVendorDialog
+ open={vendorDialogOpen}
+ onOpenChange={(open) => {
+ setVendorDialogOpen(open);
+ if (!open) setIsAdddialogLoading(false);
+ }}
+ selectedRfq={selectedRfq}
+ vendors={vendors}
+ currencies={currencies}
+ paymentTerms={paymentTerms}
+ incoterms={incoterms}
+ onSuccess={handleRefreshData}
+ existingVendorIds={existingVendorIds}
+ />
+
+ {/* 벤더 정보 수정 시트 */}
+ <UpdateRfqDetailSheet
+ open={updateSheetOpen}
+ onOpenChange={setUpdateSheetOpen}
+ detail={selectedDetail}
+ vendors={vendors}
+ currencies={currencies}
+ paymentTerms={paymentTerms}
+ incoterms={incoterms}
+ onSuccess={handleRefreshData}
+ />
+
+ {/* 벤더 정보 삭제 다이얼로그 */}
+ <DeleteRfqDetailDialog
+ open={deleteDialogOpen}
+ onOpenChange={setDeleteDialogOpen}
+ detail={selectedDetail}
+ showTrigger={false}
+ onSuccess={handleRefreshData}
+ />
+
+ {/* 벤더 커뮤니케이션 드로어 */}
+ <VendorCommunicationDrawer
+ open={communicationDrawerOpen}
+ onOpenChange={(open) => {
+ setCommunicationDrawerOpen(open);
+ // 드로어가 닫힐 때 읽지 않은 메시지 개수 갱신
+ if (!open) loadUnreadMessages();
+ }}
+ selectedRfq={selectedRfq}
+ selectedVendor={selectedVendor}
+ onSuccess={handleRefreshData}
+ />
+
+ {/* 견적 비교 다이얼로그 추가 */}
+ <VendorQuotationComparisonDialog
+ open={comparisonDialogOpen}
+ onOpenChange={setComparisonDialogOpen}
+ selectedRfq={selectedRfq}
+ />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/components/po-rfq/detail-table/update-vendor-sheet.tsx b/components/po-rfq/detail-table/update-vendor-sheet.tsx
new file mode 100644
index 00000000..45e4a602
--- /dev/null
+++ b/components/po-rfq/detail-table/update-vendor-sheet.tsx
@@ -0,0 +1,449 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Check, ChevronsUpDown, Loader } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import { z } from "zod"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+} from "@/components/ui/command"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Checkbox } from "@/components/ui/checkbox"
+import { ScrollArea } from "@/components/ui/scroll-area"
+
+import { RfqDetailView } from "./rfq-detail-column"
+import { updateRfqDetail } from "@/lib/procurement-rfqs/services"
+
+// 폼 유효성 검증 스키마
+const updateRfqDetailSchema = z.object({
+ vendorId: z.string().min(1, "벤더를 선택해주세요"),
+ currency: z.string().min(1, "통화를 선택해주세요"),
+ paymentTermsCode: z.string().min(1, "지불 조건을 선택해주세요"),
+ incotermsCode: z.string().min(1, "인코텀즈를 선택해주세요"),
+ incotermsDetail: z.string().optional(),
+ deliveryDate: z.string().optional(),
+ taxCode: z.string().optional(),
+ placeOfShipping: z.string().optional(),
+ placeOfDestination: z.string().optional(),
+ materialPriceRelatedYn: z.boolean().default(false),
+})
+
+type UpdateRfqDetailFormValues = z.infer<typeof updateRfqDetailSchema>
+
+// 데이터 타입 정의
+interface Vendor {
+ id: number;
+ vendorName: string;
+ vendorCode: string;
+}
+
+interface Currency {
+ code: string;
+ name: string;
+}
+
+interface PaymentTerm {
+ code: string;
+ description: string;
+}
+
+interface Incoterm {
+ code: string;
+ description: string;
+}
+
+interface UpdateRfqDetailSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ detail: RfqDetailView | null;
+ vendors: Vendor[];
+ currencies: Currency[];
+ paymentTerms: PaymentTerm[];
+ incoterms: Incoterm[];
+ onSuccess?: () => void;
+}
+
+export function UpdateRfqDetailSheet({
+ detail,
+ vendors,
+ currencies,
+ paymentTerms,
+ incoterms,
+ onSuccess,
+ ...props
+}: UpdateRfqDetailSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const [vendorOpen, setVendorOpen] = React.useState(false)
+
+ const form = useForm<UpdateRfqDetailFormValues>({
+ resolver: zodResolver(updateRfqDetailSchema),
+ defaultValues: {
+ vendorId: detail?.vendorName ? String(vendors.find(v => v.vendorName === detail.vendorName)?.id || "") : "",
+ currency: detail?.currency || "",
+ paymentTermsCode: detail?.paymentTermsCode || "",
+ incotermsCode: detail?.incotermsCode || "",
+ incotermsDetail: detail?.incotermsDetail || "",
+ deliveryDate: detail?.deliveryDate ? new Date(detail.deliveryDate).toISOString().split('T')[0] : "",
+ taxCode: detail?.taxCode || "",
+ placeOfShipping: detail?.placeOfShipping || "",
+ placeOfDestination: detail?.placeOfDestination || "",
+ materialPriceRelatedYn: detail?.materialPriceRelatedYn || false,
+ },
+ })
+
+ // detail이 변경될 때 form 값 업데이트
+ React.useEffect(() => {
+ if (detail) {
+ const vendorId = vendors.find(v => v.vendorName === detail.vendorName)?.id
+
+ form.reset({
+ vendorId: vendorId ? String(vendorId) : "",
+ currency: detail.currency || "",
+ paymentTermsCode: detail.paymentTermsCode || "",
+ incotermsCode: detail.incotermsCode || "",
+ incotermsDetail: detail.incotermsDetail || "",
+ deliveryDate: detail.deliveryDate ? new Date(detail.deliveryDate).toISOString().split('T')[0] : "",
+ taxCode: detail.taxCode || "",
+ placeOfShipping: detail.placeOfShipping || "",
+ placeOfDestination: detail.placeOfDestination || "",
+ materialPriceRelatedYn: detail.materialPriceRelatedYn || false,
+ })
+ }
+ }, [detail, form, vendors])
+
+ function onSubmit(values: UpdateRfqDetailFormValues) {
+ if (!detail) return
+
+ startUpdateTransition(async () => {
+ try {
+ const result = await updateRfqDetail(detail.detailId, values)
+
+ if (!result.success) {
+ toast.error(result.message || "수정 중 오류가 발생했습니다")
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("RFQ 벤더 정보가 수정되었습니다")
+ onSuccess?.()
+ } catch (error) {
+ console.error("RFQ 벤더 수정 오류:", error)
+ toast.error("수정 중 오류가 발생했습니다")
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex w-full flex-col gap-6 sm:max-w-xl">
+ <SheetHeader className="text-left">
+ <SheetTitle>RFQ 벤더 정보 수정</SheetTitle>
+ <SheetDescription>
+ 벤더 정보를 수정하고 저장하세요
+ </SheetDescription>
+ </SheetHeader>
+ <ScrollArea className="flex-1 pr-4">
+ <Form {...form}>
+ <form
+ id="update-rfq-detail-form"
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4"
+ >
+ {/* 검색 가능한 벤더 선택 필드 */}
+ <FormField
+ control={form.control}
+ name="vendorId"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>벤더 <span className="text-red-500">*</span></FormLabel>
+ <Popover open={vendorOpen} onOpenChange={setVendorOpen}>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={vendorOpen}
+ className={cn(
+ "w-full justify-between",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value
+ ? vendors.find((vendor) => String(vendor.id) === field.value)
+ ? `${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorName} (${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorCode})`
+ : "벤더를 선택하세요"
+ : "벤더를 선택하세요"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="벤더 검색..." />
+ <CommandEmpty>검색 결과가 없습니다</CommandEmpty>
+ <ScrollArea className="h-60">
+ <CommandGroup>
+ {vendors.map((vendor) => (
+ <CommandItem
+ key={vendor.id}
+ value={`${vendor.vendorName} ${vendor.vendorCode}`}
+ onSelect={() => {
+ form.setValue("vendorId", String(vendor.id), {
+ shouldValidate: true,
+ })
+ setVendorOpen(false)
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ String(vendor.id) === field.value
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {vendor.vendorName} ({vendor.vendorCode})
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </ScrollArea>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="currency"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>통화 <span className="text-red-500">*</span></FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="통화를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {currencies.map((currency) => (
+ <SelectItem key={currency.code} value={currency.code}>
+ {currency.name} ({currency.code})
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="paymentTermsCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>지불 조건 <span className="text-red-500">*</span></FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="지불 조건 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {paymentTerms.map((term) => (
+ <SelectItem key={term.code} value={term.code}>
+ {term.description}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="incotermsCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>인코텀즈 <span className="text-red-500">*</span></FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="인코텀즈 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {incoterms.map((incoterm) => (
+ <SelectItem key={incoterm.code} value={incoterm.code}>
+ {incoterm.description}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <FormField
+ control={form.control}
+ name="incotermsDetail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>인코텀즈 세부사항</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="인코텀즈 세부사항" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="deliveryDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>납품 예정일</FormLabel>
+ <FormControl>
+ <Input {...field} type="date" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="taxCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>세금 코드</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="세금 코드" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="placeOfShipping"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>선적지</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="선적지" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="placeOfDestination"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>도착지</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="도착지" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <FormField
+ control={form.control}
+ name="materialPriceRelatedYn"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
+ <FormControl>
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none">
+ <FormLabel>자재 가격 관련 여부</FormLabel>
+ </div>
+ </FormItem>
+ )}
+ />
+ </form>
+ </Form>
+ </ScrollArea>
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ 취소
+ </Button>
+ </SheetClose>
+ <Button
+ type="submit"
+ form="update-rfq-detail-form"
+ disabled={isUpdatePending}
+ >
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 저장
+ </Button>
+ </SheetFooter>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/components/po-rfq/detail-table/vendor-communication-drawer.tsx b/components/po-rfq/detail-table/vendor-communication-drawer.tsx
new file mode 100644
index 00000000..34efdfc2
--- /dev/null
+++ b/components/po-rfq/detail-table/vendor-communication-drawer.tsx
@@ -0,0 +1,518 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect, useRef } from "react"
+import { ProcurementRfqsView } from "@/db/schema"
+import { RfqDetailView } from "./rfq-detail-column"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { Avatar, AvatarFallback } from "@/components/ui/avatar"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+} from "@/components/ui/drawer"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Badge } from "@/components/ui/badge"
+import { toast } from "sonner"
+import {
+ Send,
+ Paperclip,
+ DownloadCloud,
+ File,
+ FileText,
+ Image as ImageIcon,
+ AlertCircle,
+ X
+} from "lucide-react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { formatDateTime } from "@/lib/utils"
+import { formatFileSize } from "@/lib/utils" // formatFileSize 유틸리티 임포트
+import { fetchVendorComments, markMessagesAsRead } from "@/lib/procurement-rfqs/services"
+
+// 타입 정의
+interface Comment {
+ id: number;
+ rfqId: number;
+ vendorId: number | null // null 허용으로 변경
+ userId?: number | null // null 허용으로 변경
+ content: string;
+ isVendorComment: boolean | null; // null 허용으로 변경
+ createdAt: Date;
+ updatedAt: Date;
+ userName?: string | null // null 허용으로 변경
+ vendorName?: string | null // null 허용으로 변경
+ attachments: Attachment[];
+ isRead: boolean | null // null 허용으로 변경
+}
+
+interface Attachment {
+ id: number;
+ fileName: string;
+ fileSize: number;
+ fileType: string;
+ filePath: string;
+ uploadedAt: Date;
+}
+
+// 프롭스 정의
+interface VendorCommunicationDrawerProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ selectedRfq: ProcurementRfqsView | null;
+ selectedVendor: RfqDetailView | null;
+ onSuccess?: () => void;
+}
+
+async function sendComment(params: {
+ rfqId: number;
+ vendorId: number;
+ content: string;
+ attachments?: File[];
+}): Promise<Comment> {
+ try {
+ // 폼 데이터 생성 (파일 첨부를 위해)
+ const formData = new FormData();
+ formData.append('rfqId', params.rfqId.toString());
+ formData.append('vendorId', params.vendorId.toString());
+ formData.append('content', params.content);
+ formData.append('isVendorComment', 'false');
+
+ // 첨부파일 추가
+ if (params.attachments && params.attachments.length > 0) {
+ params.attachments.forEach((file) => {
+ formData.append(`attachments`, file);
+ });
+ }
+
+ // API 엔드포인트 구성
+ const url = `/api/procurement-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`;
+
+ // API 호출
+ const response = await fetch(url, {
+ method: 'POST',
+ body: formData, // multipart/form-data 형식 사용
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`API 요청 실패: ${response.status} ${errorText}`);
+ }
+
+ // 응답 데이터 파싱
+ const result = await response.json();
+
+ if (!result.success || !result.data) {
+ throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다');
+ }
+
+ return result.data.comment;
+ } catch (error) {
+ console.error('코멘트 전송 오류:', error);
+ throw error;
+ }
+}
+
+export function VendorCommunicationDrawer({
+ open,
+ onOpenChange,
+ selectedRfq,
+ selectedVendor,
+ onSuccess
+}: VendorCommunicationDrawerProps) {
+ // 상태 관리
+ const [comments, setComments] = useState<Comment[]>([]);
+ const [newComment, setNewComment] = useState("");
+ const [attachments, setAttachments] = useState<File[]>([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const fileInputRef = useRef<HTMLInputElement>(null);
+ const messagesEndRef = useRef<HTMLDivElement>(null);
+
+ // 첨부파일 관련 상태
+ const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
+ const [selectedAttachment, setSelectedAttachment] = useState<Attachment | null>(null);
+
+ // 드로어가 열릴 때 데이터 로드
+ useEffect(() => {
+ if (open && selectedRfq && selectedVendor) {
+ loadComments();
+ }
+ }, [open, selectedRfq, selectedVendor]);
+
+ // 스크롤 최하단으로 이동
+ useEffect(() => {
+ if (messagesEndRef.current) {
+ messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
+ }
+ }, [comments]);
+
+ // 코멘트 로드 함수
+ const loadComments = async () => {
+ if (!selectedRfq || !selectedVendor) return;
+
+ try {
+ setIsLoading(true);
+
+ // Server Action을 사용하여 코멘트 데이터 가져오기
+ const commentsData = await fetchVendorComments(selectedRfq.id, selectedVendor.vendorId);
+ setComments(commentsData);
+
+ // Server Action을 사용하여 읽지 않은 메시지를 읽음 상태로 변경
+ await markMessagesAsRead(selectedRfq.id, selectedVendor.vendorId);
+ } catch (error) {
+ console.error("코멘트 로드 오류:", error);
+ toast.error("메시지를 불러오는 중 오류가 발생했습니다");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ // 파일 선택 핸들러
+ const handleFileSelect = () => {
+ fileInputRef.current?.click();
+ };
+
+ // 파일 변경 핸들러
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ if (e.target.files && e.target.files.length > 0) {
+ const newFiles = Array.from(e.target.files);
+ setAttachments(prev => [...prev, ...newFiles]);
+ }
+ };
+
+ // 파일 제거 핸들러
+ const handleRemoveFile = (index: number) => {
+ setAttachments(prev => prev.filter((_, i) => i !== index));
+ };
+
+ console.log(newComment)
+
+ // 코멘트 전송 핸들러
+ const handleSubmitComment = async () => {
+ console.log("버튼 클릭1", selectedRfq,selectedVendor, selectedVendor?.vendorId )
+ console.log(!newComment.trim() && attachments.length === 0)
+
+ if (!newComment.trim() && attachments.length === 0) return;
+ if (!selectedRfq || !selectedVendor || !selectedVendor.vendorId) return;
+
+ console.log("버튼 클릭")
+
+ try {
+ setIsSubmitting(true);
+
+ // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용)
+ const newCommentObj = await sendComment({
+ rfqId: selectedRfq.id,
+ vendorId: selectedVendor.vendorId,
+ content: newComment,
+ attachments: attachments
+ });
+
+ // 상태 업데이트
+ setComments(prev => [...prev, newCommentObj]);
+ setNewComment("");
+ setAttachments([]);
+
+ toast.success("메시지가 전송되었습니다");
+
+ // 데이터 새로고침
+ if (onSuccess) {
+ onSuccess();
+ }
+ } catch (error) {
+ console.error("코멘트 전송 오류:", error);
+ toast.error("메시지 전송 중 오류가 발생했습니다");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ // 첨부파일 미리보기
+ const handleAttachmentPreview = (attachment: Attachment) => {
+ setSelectedAttachment(attachment);
+ setPreviewDialogOpen(true);
+ };
+
+ // 첨부파일 다운로드
+ const handleAttachmentDownload = (attachment: Attachment) => {
+ // TODO: 실제 다운로드 구현
+ window.open(attachment.filePath, '_blank');
+ };
+
+ // 파일 아이콘 선택
+ const getFileIcon = (fileType: string) => {
+ if (fileType.startsWith("image/")) return <ImageIcon className="h-5 w-5 text-blue-500" />;
+ if (fileType.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />;
+ if (fileType.includes("spreadsheet") || fileType.includes("excel"))
+ return <FileText className="h-5 w-5 text-green-500" />;
+ if (fileType.includes("document") || fileType.includes("word"))
+ return <FileText className="h-5 w-5 text-blue-500" />;
+ return <File className="h-5 w-5 text-gray-500" />;
+ };
+
+ // 첨부파일 미리보기 다이얼로그
+ const renderAttachmentPreviewDialog = () => {
+ if (!selectedAttachment) return null;
+
+ const isImage = selectedAttachment.fileType.startsWith("image/");
+ const isPdf = selectedAttachment.fileType.includes("pdf");
+
+ return (
+ <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
+ <DialogContent className="max-w-3xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ {getFileIcon(selectedAttachment.fileType)}
+ {selectedAttachment.fileName}
+ </DialogTitle>
+ <DialogDescription>
+ {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt)}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="min-h-[300px] flex items-center justify-center p-4">
+ {isImage ? (
+ <img
+ src={selectedAttachment.filePath}
+ alt={selectedAttachment.fileName}
+ className="max-h-[500px] max-w-full object-contain"
+ />
+ ) : isPdf ? (
+ <iframe
+ src={`${selectedAttachment.filePath}#toolbar=0`}
+ className="w-full h-[500px]"
+ title={selectedAttachment.fileName}
+ />
+ ) : (
+ <div className="flex flex-col items-center gap-4 p-8">
+ {getFileIcon(selectedAttachment.fileType)}
+ <p className="text-muted-foreground text-sm">미리보기를 지원하지 않는 파일 형식입니다.</p>
+ <Button
+ variant="outline"
+ onClick={() => handleAttachmentDownload(selectedAttachment)}
+ >
+ <DownloadCloud className="h-4 w-4 mr-2" />
+ 다운로드
+ </Button>
+ </div>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+ };
+
+ if (!selectedRfq || !selectedVendor) {
+ return null;
+ }
+
+ return (
+ <Drawer open={open} onOpenChange={onOpenChange}>
+ <DrawerContent className="max-h-[85vh]">
+ <DrawerHeader className="border-b">
+ <DrawerTitle className="flex items-center gap-2">
+ <Avatar className="h-8 w-8">
+ <AvatarFallback className="bg-primary/10">
+ {selectedVendor.vendorName?.[0] || 'V'}
+ </AvatarFallback>
+ </Avatar>
+ <div>
+ <span>{selectedVendor.vendorName}</span>
+ <Badge variant="outline" className="ml-2">{selectedVendor.vendorCode}</Badge>
+ </div>
+ </DrawerTitle>
+ <DrawerDescription>
+ RFQ: {selectedRfq.rfqCode} • 프로젝트: {selectedRfq.projectName}
+ </DrawerDescription>
+ </DrawerHeader>
+
+ <div className="p-0 flex flex-col h-[60vh]">
+ {/* 메시지 목록 */}
+ <ScrollArea className="flex-1 p-4">
+ {isLoading ? (
+ <div className="flex h-full items-center justify-center">
+ <p className="text-muted-foreground">메시지 로딩 중...</p>
+ </div>
+ ) : comments.length === 0 ? (
+ <div className="flex h-full items-center justify-center">
+ <div className="flex flex-col items-center gap-2">
+ <AlertCircle className="h-6 w-6 text-muted-foreground" />
+ <p className="text-muted-foreground">아직 메시지가 없습니다</p>
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {comments.map(comment => (
+ <div
+ key={comment.id}
+ className={`flex gap-3 ${comment.isVendorComment ? 'justify-start' : 'justify-end'}`}
+ >
+ {comment.isVendorComment && (
+ <Avatar className="h-8 w-8 mt-1">
+ <AvatarFallback className="bg-primary/10">
+ {comment.vendorName?.[0] || 'V'}
+ </AvatarFallback>
+ </Avatar>
+ )}
+
+ <div className={`rounded-lg p-3 max-w-[80%] ${
+ comment.isVendorComment
+ ? 'bg-muted'
+ : 'bg-primary text-primary-foreground'
+ }`}>
+ <div className="text-sm font-medium mb-1">
+ {comment.isVendorComment ? comment.vendorName : comment.userName}
+ </div>
+
+ {comment.content && (
+ <div className="text-sm whitespace-pre-wrap break-words">
+ {comment.content}
+ </div>
+ )}
+
+ {/* 첨부파일 표시 */}
+ {comment.attachments.length > 0 && (
+ <div className={`mt-2 pt-2 ${
+ comment.isVendorComment
+ ? 'border-t border-t-border/30'
+ : 'border-t border-t-primary-foreground/20'
+ }`}>
+ {comment.attachments.map(attachment => (
+ <div
+ key={attachment.id}
+ className="flex items-center text-xs gap-2 mb-1 p-1 rounded hover:bg-black/5 cursor-pointer"
+ onClick={() => handleAttachmentPreview(attachment)}
+ >
+ {getFileIcon(attachment.fileType)}
+ <span className="flex-1 truncate">{attachment.fileName}</span>
+ <span className="text-xs opacity-70">
+ {formatFileSize(attachment.fileSize)}
+ </span>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-6 w-6 rounded-full"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleAttachmentDownload(attachment);
+ }}
+ >
+ <DownloadCloud className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ )}
+
+ <div className="text-xs mt-1 opacity-70 flex items-center gap-1 justify-end">
+ {formatDateTime(comment.createdAt)}
+ </div>
+ </div>
+
+ {!comment.isVendorComment && (
+ <Avatar className="h-8 w-8 mt-1">
+ <AvatarFallback className="bg-primary/20">
+ {comment.userName?.[0] || 'U'}
+ </AvatarFallback>
+ </Avatar>
+ )}
+ </div>
+ ))}
+ <div ref={messagesEndRef} />
+ </div>
+ )}
+ </ScrollArea>
+
+ {/* 선택된 첨부파일 표시 */}
+ {attachments.length > 0 && (
+ <div className="p-2 bg-muted mx-4 rounded-md mb-2">
+ <div className="text-xs font-medium mb-1">첨부파일</div>
+ <div className="flex flex-wrap gap-2">
+ {attachments.map((file, index) => (
+ <div key={index} className="flex items-center bg-background rounded-md p-1 pr-2 text-xs">
+ {file.type.startsWith("image/") ? (
+ <ImageIcon className="h-4 w-4 mr-1 text-blue-500" />
+ ) : (
+ <File className="h-4 w-4 mr-1 text-gray-500" />
+ )}
+ <span className="truncate max-w-[100px]">{file.name}</span>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 ml-1 p-0"
+ onClick={() => handleRemoveFile(index)}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 메시지 입력 영역 */}
+ <div className="p-4 border-t">
+ <div className="flex gap-2 items-end">
+ <div className="flex-1">
+ <Textarea
+ placeholder="메시지를 입력하세요..."
+ className="min-h-[80px] resize-none"
+ value={newComment}
+ onChange={(e) => setNewComment(e.target.value)}
+ />
+ </div>
+ <div className="flex flex-col gap-2">
+ <input
+ type="file"
+ ref={fileInputRef}
+ className="hidden"
+ multiple
+ onChange={handleFileChange}
+ />
+ <Button
+ variant="outline"
+ size="icon"
+ onClick={handleFileSelect}
+ title="파일 첨부"
+ >
+ <Paperclip className="h-4 w-4" />
+ </Button>
+ <Button
+ onClick={handleSubmitComment}
+ disabled={(!newComment.trim() && attachments.length === 0) || isSubmitting}
+ >
+ <Send className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <DrawerFooter className="border-t">
+ <div className="flex justify-between">
+ <Button variant="outline" onClick={() => loadComments()}>
+ 새로고침
+ </Button>
+ <DrawerClose asChild>
+ <Button variant="outline">닫기</Button>
+ </DrawerClose>
+ </div>
+ </DrawerFooter>
+ </DrawerContent>
+
+ {renderAttachmentPreviewDialog()}
+ </Drawer>
+ );
+} \ No newline at end of file
diff --git a/components/po-rfq/detail-table/vendor-quotation-comparison-dialog.tsx b/components/po-rfq/detail-table/vendor-quotation-comparison-dialog.tsx
new file mode 100644
index 00000000..72cf187c
--- /dev/null
+++ b/components/po-rfq/detail-table/vendor-quotation-comparison-dialog.tsx
@@ -0,0 +1,665 @@
+"use client"
+
+import * as React from "react"
+import { useEffect, useState } from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Skeleton } from "@/components/ui/skeleton"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { toast } from "sonner"
+
+// Lucide 아이콘
+import { Plus, Minus } from "lucide-react"
+
+import { ProcurementRfqsView } from "@/db/schema"
+import { fetchVendorQuotations, fetchQuotationItems } from "@/lib/procurement-rfqs/services"
+import { formatCurrency, formatDate } from "@/lib/utils"
+
+// 견적 정보 타입
+interface VendorQuotation {
+ id: number
+ rfqId: number
+ vendorId: number
+ vendorName?: string | null
+ quotationCode: string
+ quotationVersion: number
+ totalItemsCount: number
+ subTotal: string
+ taxTotal: string
+ discountTotal: string
+ totalPrice: string
+ currency: string
+ validUntil: string | Date // 수정: string | Date 허용
+ estimatedDeliveryDate: string | Date // 수정: string | Date 허용
+ paymentTermsCode: string
+ paymentTermsDescription?: string | null
+ incotermsCode: string
+ incotermsDescription?: string | null
+ incotermsDetail: string
+ status: string
+ remark: string
+ rejectionReason: string
+ submittedAt: string | Date // 수정: string | Date 허용
+ acceptedAt: string | Date // 수정: string | Date 허용
+ createdAt: string | Date // 수정: string | Date 허용
+ updatedAt: string | Date // 수정: string | Date 허용
+}
+
+// 견적 아이템 타입
+interface QuotationItem {
+ id: number
+ quotationId: number
+ prItemId: number
+ materialCode: string | null // Changed from string to string | null
+ materialDescription: string | null // Changed from string to string | null
+ quantity: string
+ uom: string | null // Changed assuming this might be null
+ unitPrice: string
+ totalPrice: string
+ currency: string | null // Changed from string to string | null
+ vendorMaterialCode: string | null // Changed from string to string | null
+ vendorMaterialDescription: string | null // Changed from string to string | null
+ deliveryDate: Date | null // Changed from string to string | null
+ leadTimeInDays: number | null // Changed from number to number | null
+ taxRate: string | null // Changed from string to string | null
+ taxAmount: string | null // Changed from string to string | null
+ discountRate: string | null // Changed from string to string | null
+ discountAmount: string | null // Changed from string to string | null
+ remark: string | null // Changed from string to string | null
+ isAlternative: boolean | null // Changed from boolean to boolean | null
+ isRecommended: boolean | null // Changed from boolean to boolean | null
+}
+
+interface VendorQuotationComparisonDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedRfq: ProcurementRfqsView | null
+}
+
+export function VendorQuotationComparisonDialog({
+ open,
+ onOpenChange,
+ selectedRfq,
+}: VendorQuotationComparisonDialogProps) {
+ const [isLoading, setIsLoading] = useState(false)
+ const [quotations, setQuotations] = useState<VendorQuotation[]>([])
+ const [quotationItems, setQuotationItems] = useState<Record<number, QuotationItem[]>>({})
+ const [activeTab, setActiveTab] = useState("summary")
+
+ // 벤더별 접힘 상태 (true=접힘, false=펼침), 기본값: 접힘
+ const [collapsedVendors, setCollapsedVendors] = useState<Record<number, boolean>>({})
+
+ useEffect(() => {
+ async function loadQuotationData() {
+ if (!open || !selectedRfq?.id) return
+
+ try {
+ setIsLoading(true)
+ // 1) 견적 목록
+ const quotationsResult = await fetchVendorQuotations(selectedRfq.id)
+ const rawQuotationsData = quotationsResult.data || []
+
+ const quotationsData = rawQuotationsData.map((rawData): VendorQuotation => ({
+ id: rawData.id,
+ rfqId: rawData.rfqId,
+ vendorId: rawData.vendorId,
+ vendorName: rawData.vendorName || null,
+ quotationCode: rawData.quotationCode || '',
+ quotationVersion: rawData.quotationVersion || 0,
+ totalItemsCount: rawData.totalItemsCount || 0,
+ subTotal: rawData.subTotal || '0',
+ taxTotal: rawData.taxTotal || '0',
+ discountTotal: rawData.discountTotal || '0',
+ totalPrice: rawData.totalPrice || '0',
+ currency: rawData.currency || 'KRW',
+ validUntil: rawData.validUntil || '',
+ estimatedDeliveryDate: rawData.estimatedDeliveryDate || '',
+ paymentTermsCode: rawData.paymentTermsCode || '',
+ paymentTermsDescription: rawData.paymentTermsDescription || null,
+ incotermsCode: rawData.incotermsCode || '',
+ incotermsDescription: rawData.incotermsDescription || null,
+ incotermsDetail: rawData.incotermsDetail || '',
+ status: rawData.status || '',
+ remark: rawData.remark || '',
+ rejectionReason: rawData.rejectionReason || '',
+ submittedAt: rawData.submittedAt || '',
+ acceptedAt: rawData.acceptedAt || '',
+ createdAt: rawData.createdAt || '',
+ updatedAt: rawData.updatedAt || '',
+ }));
+
+ setQuotations(quotationsData);
+
+ // 벤더별로 접힘 상태 기본값(true) 설정
+ const collapsedInit: Record<number, boolean> = {}
+ quotationsData.forEach((q) => {
+ collapsedInit[q.id] = true
+ })
+ setCollapsedVendors(collapsedInit)
+
+ // 2) 견적 아이템
+ const qIds = quotationsData.map((q) => q.id)
+ if (qIds.length > 0) {
+ const itemsResult = await fetchQuotationItems(qIds)
+ const itemsData = itemsResult.data || []
+
+ const itemsByQuotation: Record<number, QuotationItem[]> = {}
+ itemsData.forEach((item) => {
+ if (!itemsByQuotation[item.quotationId]) {
+ itemsByQuotation[item.quotationId] = []
+ }
+ itemsByQuotation[item.quotationId].push(item)
+ })
+ setQuotationItems(itemsByQuotation)
+ }
+ } catch (error) {
+ console.error("견적 데이터 로드 오류:", error)
+ toast.error("견적 데이터를 불러오는 데 실패했습니다")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadQuotationData()
+ }, [open, selectedRfq])
+
+ // 견적 상태 -> 뱃지 색
+ const getStatusBadgeVariant = (status: string) => {
+ switch (status) {
+ case "Submitted":
+ return "default"
+ case "Accepted":
+ return "default"
+ case "Rejected":
+ return "destructive"
+ case "Revised":
+ return "destructive"
+ default:
+ return "secondary"
+ }
+ }
+
+ // 모든 prItemId 모음
+ const allItemIds = React.useMemo(() => {
+ const itemSet = new Set<number>()
+ Object.values(quotationItems).forEach((items) => {
+ items.forEach((it) => itemSet.add(it.prItemId))
+ })
+ return Array.from(itemSet)
+ }, [quotationItems])
+
+ // 아이템 찾는 함수
+ const findItemByQuotationId = (prItemId: number, qid: number) => {
+ const items = quotationItems[qid] || []
+ return items.find((i) => i.prItemId === prItemId)
+ }
+
+ // 접힘 상태 토글
+ const toggleVendor = (qid: number) => {
+ setCollapsedVendors((prev) => ({
+ ...prev,
+ [qid]: !prev[qid],
+ }))
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ {/* 다이얼로그 자체는 max-h, max-w 설정, 내부에 스크롤 컨테이너를 둠 */}
+ <DialogContent className="max-w-[90vw] lg:max-w-5xl max-h-[90vh]" style={{ maxWidth: '90vw', maxHeight: '90vh' }}>
+ <DialogHeader>
+ <DialogTitle>벤더 견적 비교</DialogTitle>
+ <DialogDescription>
+ {selectedRfq
+ ? `RFQ ${selectedRfq.rfqCode} - ${selectedRfq.itemName || ""}`
+ : ""}
+ </DialogDescription>
+ </DialogHeader>
+
+ {isLoading ? (
+ <div className="space-y-4">
+ <Skeleton className="h-8 w-1/2" />
+ <Skeleton className="h-48 w-full" />
+ </div>
+ ) : quotations.length === 0 ? (
+ <div className="py-8 text-center text-muted-foreground">
+ 제출된(Submitted) 견적이 없습니다
+ </div>
+ ) : (
+ <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
+ <TabsList className="grid w-full grid-cols-2">
+ <TabsTrigger value="summary">견적 요약 비교</TabsTrigger>
+ <TabsTrigger value="items">아이템별 비교</TabsTrigger>
+ </TabsList>
+
+ {/* ======================== 요약 비교 탭 ======================== */}
+ <TabsContent value="summary" className="mt-4">
+ {/*
+ table-fixed + 가로 너비를 크게 잡아줌 (예: w-[1200px])
+ -> 컨테이너보다 넓으면 수평 스크롤 발생.
+ */}
+ <div className="border rounded-md max-h-[60vh] overflow-auto">
+ <table className="table-fixed w-full border-collapse">
+ <thead className="sticky top-0 bg-background z-10">
+ <TableRow>
+ <TableHead
+ className="sticky left-0 top-0 z-20 bg-background p-2"
+ >
+ 항목
+ </TableHead>
+ {quotations.map((q) => (
+ <TableHead key={q.id} className="p-2 text-center whitespace-nowrap">
+ {q.vendorName || `벤더 ID: ${q.vendorId}`}
+ </TableHead>
+ ))}
+ </TableRow>
+ </thead>
+ <tbody>
+ {/* 견적 상태 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 견적 상태
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`status-${q.id}`} className="p-2">
+ <Badge variant={getStatusBadgeVariant(q.status)}>
+ {q.status}
+ </Badge>
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 견적 버전 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 견적 버전
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`version-${q.id}`} className="p-2">
+ v{q.quotationVersion}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 총 금액 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 총 금액
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`total-${q.id}`} className="p-2 font-semibold">
+ {formatCurrency(Number(q.totalPrice), q.currency)}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 소계 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 소계
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`subtotal-${q.id}`} className="p-2">
+ {formatCurrency(Number(q.subTotal), q.currency)}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 세금 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 세금
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`tax-${q.id}`} className="p-2">
+ {formatCurrency(Number(q.taxTotal), q.currency)}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 할인 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 할인
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`discount-${q.id}`} className="p-2">
+ {formatCurrency(Number(q.discountTotal), q.currency)}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 통화 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 통화
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`currency-${q.id}`} className="p-2">
+ {q.currency}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 유효기간 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 유효 기간
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`valid-${q.id}`} className="p-2">
+ {formatDate(q.validUntil, "KR")}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 예상 배송일 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 예상 배송일
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`delivery-${q.id}`} className="p-2">
+ {formatDate(q.estimatedDeliveryDate, "KR")}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 지불 조건 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 지불 조건
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`payment-${q.id}`} className="p-2">
+ {q.paymentTermsDescription || q.paymentTermsCode}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 인코텀즈 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 인코텀즈
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`incoterms-${q.id}`} className="p-2">
+ {q.incotermsDescription || q.incotermsCode}
+ {q.incotermsDetail && (
+ <div className="text-xs text-muted-foreground mt-1">
+ {q.incotermsDetail}
+ </div>
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 제출일 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 제출일
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`submitted-${q.id}`} className="p-2">
+ {formatDate(q.submittedAt, "KR")}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 비고 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 비고
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell
+ key={`remark-${q.id}`}
+ className="p-2 whitespace-pre-wrap"
+ >
+ {q.remark || "-"}
+ </TableCell>
+ ))}
+ </TableRow>
+ </tbody>
+ </table>
+ </div>
+ </TabsContent>
+
+ {/* ====================== 아이템별 비교 탭 ====================== */}
+ <TabsContent value="items" className="mt-4">
+ {/* 컨테이너에 테이블 관련 클래스 직접 적용 */}
+ <div className="border rounded-md max-h-[60vh] overflow-y-auto overflow-x-auto" >
+ <div className="min-w-full w-max" style={{ maxWidth: '70vw' }}>
+ <table className="w-full border-collapse">
+ <thead className="sticky top-0 bg-background z-10">
+ {/* 첫 번째 헤더 행 */}
+ <tr>
+ {/* 첫 행: 자재(코드) 컬럼 */}
+ <th
+ rowSpan={2}
+ className="sticky left-0 top-0 z-20 p-2 border border-gray-200 text-left"
+ style={{
+ width: '250px',
+ minWidth: '250px',
+ backgroundColor: 'white',
+ }}
+ >
+ 자재 (코드)
+ </th>
+
+ {/* 벤더 헤더 (접힘/펼침) */}
+ {quotations.map((q, index) => {
+ const collapsed = collapsedVendors[q.id]
+ // 접힌 상태면 1칸, 펼친 상태면 6칸
+ return (
+ <th
+ key={q.id}
+ className="p-2 text-center whitespace-nowrap border border-gray-200"
+ colSpan={collapsed ? 1 : 6}
+ style={{
+ borderRight: index < quotations.length - 1 ? '1px solid #e5e7eb' : '',
+ backgroundColor: 'white',
+ }}
+ >
+ {/* + / - 버튼 */}
+ <div className="flex items-center gap-2 justify-center">
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-7 w-7 p-1"
+ onClick={() => toggleVendor(q.id)}
+ >
+ {collapsed ? <Plus size={16} /> : <Minus size={16} />}
+ </Button>
+ <span>{q.vendorName || `벤더 ID: ${q.vendorId}`}</span>
+ </div>
+ </th>
+ )
+ })}
+ </tr>
+
+ {/* 두 번째 헤더 행 - 하위 컬럼들 */}
+ <tr className="border-b border-b-gray-200">
+ {/* 펼쳐진 벤더의 하위 컬럼들만 표시 */}
+ {quotations.flatMap((q, qIndex) => {
+ // 접힌 상태면 추가 헤더 없음
+ if (collapsedVendors[q.id]) {
+ return [
+ <th
+ key={`${q.id}-collapsed`}
+ className="p-2 text-center whitespace-nowrap border border-gray-200"
+ style={{ backgroundColor: 'white' }}
+ >
+ 총액
+ </th>
+ ];
+ }
+
+ // 펼친 상태면 6개 컬럼 표시
+ const columns = [
+ { key: 'unitprice', label: '단가' },
+ { key: 'totalprice', label: '총액' },
+ { key: 'tax', label: '세금' },
+ { key: 'discount', label: '할인' },
+ { key: 'leadtime', label: '리드타임' },
+ { key: 'alternative', label: '대체품' },
+ ];
+
+ return columns.map((col, colIndex) => {
+ const isFirstInGroup = colIndex === 0;
+ const isLastInGroup = colIndex === columns.length - 1;
+
+ return (
+ <th
+ key={`${q.id}-${col.key}`}
+ className={`p-2 text-center whitespace-nowrap border border-gray-200 ${
+ isFirstInGroup ? 'border-l border-l-gray-200' : ''
+ } ${
+ isLastInGroup ? 'border-r border-r-gray-200' : ''
+ }`}
+ style={{ backgroundColor: 'white' }}
+ >
+ {col.label}
+ </th>
+ );
+ });
+ })}
+ </tr>
+ </thead>
+
+ {/* 테이블 바디 */}
+ <tbody>
+ {allItemIds.map((itemId) => {
+ // 자재 기본 정보는 첫 번째 벤더 아이템 기준
+ const firstQid = quotations[0]?.id
+ const sampleItem = firstQid
+ ? findItemByQuotationId(itemId, firstQid)
+ : undefined
+
+ return (
+ <tr key={itemId} className="border-b border-gray-100">
+ {/* 자재 (코드) 셀 */}
+ <td
+ className="sticky left-0 z-10 p-2 align-top border-r border-gray-100"
+ style={{
+ width: '250px',
+ minWidth: '250px',
+ backgroundColor: 'white',
+ }}
+ >
+ {sampleItem?.materialDescription || sampleItem?.materialCode || ""}
+ {sampleItem && (
+ <div className="text-xs text-muted-foreground mt-1">
+ 코드: {sampleItem.materialCode} | 수량:{" "}
+ {sampleItem.quantity} {sampleItem.uom}
+ </div>
+ )}
+ </td>
+
+ {/* 벤더별 아이템 데이터 */}
+ {quotations.flatMap((q, qIndex) => {
+ const collapsed = collapsedVendors[q.id]
+ const itemData = findItemByQuotationId(itemId, q.id)
+
+ // 접힌 상태면 총액만 표시
+ if (collapsed) {
+ return [
+ <td
+ key={`${q.id}-collapsed`}
+ className="p-2 text-center text-sm font-medium whitespace-nowrap border-r border-gray-100"
+ >
+ {itemData
+ ? formatCurrency(Number(itemData.totalPrice), itemData.currency)
+ : "N/A"}
+ </td>
+ ];
+ }
+
+ // 펼친 상태 - 아이템 없음
+ if (!itemData) {
+ return [
+ <td
+ key={`${q.id}-empty`}
+ colSpan={6}
+ className="p-2 text-center text-sm border-r border-gray-100"
+ >
+ 없음
+ </td>
+ ];
+ }
+
+ // 펼친 상태 - 모든 컬럼 표시
+ const columns = [
+ { key: 'unitprice', render: () => formatCurrency(Number(itemData.unitPrice), itemData.currency), align: 'right' },
+ { key: 'totalprice', render: () => formatCurrency(Number(itemData.totalPrice), itemData.currency), align: 'right', bold: true },
+ { key: 'tax', render: () => itemData.taxRate ? `${itemData.taxRate}% (${formatCurrency(Number(itemData.taxAmount), itemData.currency)})` : "-", align: 'right' },
+ { key: 'discount', render: () => itemData.discountRate ? `${itemData.discountRate}% (${formatCurrency(Number(itemData.discountAmount), itemData.currency)})` : "-", align: 'right' },
+ { key: 'leadtime', render: () => itemData.leadTimeInDays ? `${itemData.leadTimeInDays}일` : "-", align: 'center' },
+ { key: 'alternative', render: () => itemData.isAlternative ? "대체품" : "표준품", align: 'center' },
+ ];
+
+ return columns.map((col, colIndex) => {
+ const isFirstInGroup = colIndex === 0;
+ const isLastInGroup = colIndex === columns.length - 1;
+
+ return (
+ <td
+ key={`${q.id}-${col.key}`}
+ className={`p-2 text-${col.align} ${col.bold ? 'font-semibold' : ''} ${
+ isFirstInGroup ? 'border-l border-l-gray-100' : ''
+ } ${
+ isLastInGroup ? 'border-r border-r-gray-100' : 'border-r border-gray-100'
+ }`}
+ >
+ {col.render()}
+ </td>
+ );
+ });
+ })}
+ </tr>
+ );
+ })}
+
+ {/* 아이템이 전혀 없는 경우 */}
+ {allItemIds.length === 0 && (
+ <tr>
+ <td
+ colSpan={100} // 충분히 큰 수로 설정해 모든 컬럼을 커버
+ className="text-center p-4 border border-gray-100"
+ >
+ 아이템 정보가 없습니다
+ </td>
+ </tr>
+ )}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </TabsContent>
+ </Tabs>
+ )}
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 닫기
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/components/po-rfq/po-rfq-container.tsx b/components/po-rfq/po-rfq-container.tsx
new file mode 100644
index 00000000..e5159242
--- /dev/null
+++ b/components/po-rfq/po-rfq-container.tsx
@@ -0,0 +1,261 @@
+"use client"
+
+import { useState, useEffect, useCallback, useRef } from "react"
+import { useSearchParams, useRouter } from "next/navigation"
+import { Button } from "@/components/ui/button"
+import { PanelLeft, PanelLeftClose, PanelLeftOpen } from "lucide-react"
+
+// shadcn/ui components
+import {
+ ResizablePanelGroup,
+ ResizablePanel,
+ ResizableHandle,
+} from "@/components/ui/resizable"
+
+import { cn } from "@/lib/utils"
+import { ProcurementRfqsView } from "@/db/schema"
+import { RFQListTable } from "@/lib/procurement-rfqs/table/rfq-table"
+import { getPORfqs } from "@/lib/procurement-rfqs/services"
+import { RFQFilterSheet } from "./rfq-filter-sheet"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { RfqDetailTables } from "./detail-table/rfq-detail-table"
+
+interface RfqContainerProps {
+ // 초기 데이터 (필수)
+ initialData: Awaited<ReturnType<typeof getPORfqs>>
+ // 서버 액션으로 데이터를 가져오는 함수
+ fetchData: (params: any) => Promise<Awaited<ReturnType<typeof getPORfqs>>>
+}
+
+export default function RFQContainer({
+ initialData,
+ fetchData
+}: RfqContainerProps) {
+ const router = useRouter()
+ const searchParams = useSearchParams()
+
+ // Whether the filter panel is open (now a side panel instead of sheet)
+ const [isFilterPanelOpen, setIsFilterPanelOpen] = useState(false)
+
+ // 데이터 상태 관리 - 초기 데이터로 시작
+ const [data, setData] = useState<Awaited<ReturnType<typeof getPORfqs>>>(initialData)
+ const [isLoading, setIsLoading] = useState(false)
+
+ // 선택된 문서를 이 state로 관리
+ const [selectedRfq, setSelectedRfq] = useState<ProcurementRfqsView | null>(null)
+
+ // 패널 collapse
+ const [isTopCollapsed, setIsTopCollapsed] = useState(false)
+
+ // 이전 URL 파라미터를 저장하기 위한 ref
+ const prevParamsRef = useRef<string>(searchParams.toString())
+
+ // 현재 URL 파라미터로부터 필터 데이터 구성
+ const getFilterParams = useCallback(() => {
+ return {
+ page: searchParams.get('page') || '1',
+ perPage: searchParams.get('perPage') || '10',
+ sort: searchParams.get('sort') || JSON.stringify([{ id: "updatedAt", desc: true }]),
+ basicFilters: searchParams.get('basicFilters') || null,
+ basicJoinOperator: searchParams.get('basicJoinOperator') || 'and',
+ filters: searchParams.get('filters') || null,
+ joinOperator: searchParams.get('joinOperator') || 'and',
+ search: searchParams.get('search') || '',
+ }
+ }, [searchParams])
+
+ // 데이터 로드 함수
+ const loadData = useCallback(async () => {
+ try {
+ setIsLoading(true)
+ const filterParams = getFilterParams()
+
+ console.log("데이터 로드 시작:", filterParams)
+
+ // 서버 액션으로 데이터 가져오는 함수
+ const newData = await fetchData(filterParams)
+
+ console.log("데이터 로드 완료:", newData.data.length, "건")
+
+ setData(newData)
+ } catch (error) {
+ console.error("데이터 로드 오류:", error)
+ } finally {
+ setIsLoading(false)
+ }
+ }, [fetchData, getFilterParams])
+
+ const refreshData = useCallback(() => {
+ // 현재 파라미터로 데이터 다시 로드
+ loadData();
+ }, [loadData]);
+
+ // URL 파라미터 변경 감지
+ useEffect(() => {
+ const currentParams = searchParams.toString()
+
+ // 파라미터가 변경되었을 때만 데이터 로드
+ if (currentParams !== prevParamsRef.current) {
+ console.log("URL 파라미터 변경 감지:", {
+ previous: prevParamsRef.current,
+ current: currentParams,
+ })
+
+ prevParamsRef.current = currentParams
+ loadData()
+ }
+ }, [searchParams, loadData])
+
+ // 문서 선택 핸들러
+ const handleSelectRfq = (rfq: ProcurementRfqsView | null) => {
+ setSelectedRfq(rfq)
+ }
+
+ // 조회 버튼 클릭 핸들러 - RFQFilterSheet에 전달
+ // 페이지 리라우팅을 통해 처리하므로 별도 로직 불필요
+ const handleSearch = () => {
+ // Close the panel after search
+ setIsFilterPanelOpen(false)
+ }
+
+ const [panelHeight, setPanelHeight] = useState<number>(400)
+
+ // Get active filter count for UI display
+ const getActiveFilterCount = () => {
+ try {
+ const basicFilters = searchParams.get('basicFilters')
+ return basicFilters ? JSON.parse(basicFilters).length : 0
+ } catch (e) {
+ console.error("Error parsing filters:", e)
+ return 0
+ }
+ }
+
+ // Filter panel width in pixels
+ const FILTER_PANEL_WIDTH = 400;
+
+ // Table refresh key - 패널 상태가 변경되면 테이블을 강제로 재렌더링
+ const [tableRefreshKey, setTableRefreshKey] = useState(0);
+
+ useEffect(() => {
+ // 패널 상태가 변경될 때 테이블 강제 재렌더링
+ setTableRefreshKey(prev => prev + 1);
+ }, [isFilterPanelOpen]);
+
+ return (
+ <div className="h-[calc(100vh-220px)] w-full overflow-hidden relative">
+ {/* Fixed Filter Panel - 가장 왼쪽부터 시작, 전체 높이 맞춤 */}
+ <div
+ className={cn(
+ "fixed left-0 bg-background border-r overflow-hidden flex flex-col transition-all duration-300 ease-in-out z-30",
+ isFilterPanelOpen ? "border-r" : "border-r-0"
+ )}
+ style={{
+ width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
+ height: 'calc(100vh - 130px)' // 나머지 높이 전체 사용
+ }}
+ >
+ {/* Filter Content - 제목 포함하여 내부에서 처리 */}
+ <div className="h-full">
+ <RFQFilterSheet
+ isOpen={isFilterPanelOpen}
+ onClose={() => setIsFilterPanelOpen(false)}
+ onSearch={handleSearch}
+ isLoading={isLoading}
+ />
+ </div>
+ </div>
+
+ {/* Main Content Panel - 패널이 열릴 때 오른쪽으로 이동 */}
+ <div
+ className="h-full overflow-hidden transition-all duration-300 ease-in-out"
+ style={{
+ marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px'
+ }}
+ >
+ {/* Filter Toggle Button - 메인 콘텐츠 상단에 위치 */}
+ <div className="flex items-center p-4 border-b bg-background pl-0">
+ <Button
+ variant="outline"
+ size="sm"
+ type='button'
+ onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
+ className="flex items-center shadow-md"
+ >
+ {
+ isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>
+ }
+ {/* 검색 필터 */}
+ {getActiveFilterCount() > 0 && (
+ <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
+ {getActiveFilterCount()}
+ </span>
+ )}
+ </Button>
+
+ {/* 추가적인 헤더 정보나 버튼들을 여기에 배치할 수 있음 */}
+ <div className="flex-1" />
+ <div className="text-sm text-muted-foreground">
+ {data && !isLoading && (
+ <span>총 {data.total || 0}건</span>
+ )}
+ </div>
+ </div>
+
+ {/* Main Content Area */}
+ <div className="h-[calc(100%-64px)] w-full overflow-hidden">
+ {isLoading ? (
+ // 로딩 중 상태
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ ) : (
+ // 데이터 로드 완료 상태
+ <ResizablePanelGroup direction="vertical" className="h-full">
+ <ResizablePanel
+ defaultSize={55}
+ minSize={0}
+ maxSize={95}
+ collapsible
+ collapsedSize={10}
+ onCollapse={() => setIsTopCollapsed(true)}
+ onExpand={() => setIsTopCollapsed(false)}
+ onResize={(size) => {
+ setPanelHeight(size)
+ }}
+ className={cn("overflow-y-auto overflow-x-hidden border-b", isTopCollapsed && "transition-all")}
+ >
+ <div className="flex h-full min-h-0 flex-col">
+ <RFQListTable
+ key={tableRefreshKey} // Force re-render when panel toggles
+ maxHeight={`${panelHeight*0.5}vh`}
+ data={data}
+ onSelectRFQ={handleSelectRfq}
+ onDataRefresh={refreshData}
+ />
+ </div>
+ </ResizablePanel>
+
+ <ResizableHandle
+ withHandle
+ className="pointer-events-none data-[resize-handle]:pointer-events-auto"
+ />
+
+ <ResizablePanel
+ minSize={0}
+ defaultSize={35}
+ className="overflow-y-auto overflow-x-hidden"
+ >
+ <RfqDetailTables selectedRfq={selectedRfq} />
+ </ResizablePanel>
+ </ResizablePanelGroup>
+ )}
+ </div>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/components/po-rfq/rfq-filter-sheet.tsx b/components/po-rfq/rfq-filter-sheet.tsx
new file mode 100644
index 00000000..31f02442
--- /dev/null
+++ b/components/po-rfq/rfq-filter-sheet.tsx
@@ -0,0 +1,643 @@
+"use client"
+
+import { useEffect, useTransition, useState, useRef } from "react"
+import { useRouter, useParams } from "next/navigation"
+import { z } from "zod"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { CalendarIcon, ChevronRight, Search, X } from "lucide-react"
+import { customAlphabet } from "nanoid"
+import { parseAsStringEnum, useQueryState } from "nuqs"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { DateRangePicker } from "../date-range-picker"
+import { Badge } from "@/components/ui/badge"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { cn } from "@/lib/utils"
+import { useTranslation } from '@/i18n/client'
+import { getFiltersStateParser } from "@/lib/parsers"
+
+// nanoid 생성기
+const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6)
+
+// 필터 스키마 정의 (RFQFilterBox와 동일)
+const filterSchema = z.object({
+ picCode: z.string().optional(),
+ projectCode: z.string().optional(),
+ rfqCode: z.string().optional(),
+ itemCode: z.string().optional(),
+ majorItemMaterialCode: z.string().optional(),
+ status: z.string().optional(),
+ dateRange: z.object({
+ from: z.date().optional(),
+ to: z.date().optional(),
+ }).optional(),
+})
+
+// 상태 옵션 정의
+const statusOptions = [
+ { value: "RFQ Created", label: "RFQ Created" },
+ { value: "RFQ Vendor Assignned", label: "RFQ Vendor Assignned" },
+ { value: "RFQ Sent", label: "RFQ Sent" },
+ { value: "Quotation Analysis", label: "Quotation Analysis" },
+ { value: "PO Transfer", label: "PO Transfer" },
+ { value: "PO Create", label: "PO Create" },
+]
+
+type FilterFormValues = z.infer<typeof filterSchema>
+
+interface RFQFilterSheetProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSearch?: () => void;
+ isLoading?: boolean;
+}
+
+// Updated component for inline use (not a sheet anymore)
+export function RFQFilterSheet({
+ isOpen,
+ onClose,
+ onSearch,
+ isLoading = false
+}: RFQFilterSheetProps) {
+ const router = useRouter()
+ const params = useParams();
+ const lng = params ? (params.lng as string) : 'ko';
+ const { t } = useTranslation(lng);
+
+ const [isPending, startTransition] = useTransition()
+
+ // 초기화 상태 추가 - 폼 초기화 중에는 상태 변경을 방지
+ const [isInitializing, setIsInitializing] = useState(false)
+ // 마지막으로 적용된 필터를 추적하기 위한 ref
+ const lastAppliedFilters = useRef<string>("")
+
+ // nuqs로 URL 상태 관리 - 파라미터명을 'basicFilters'로 변경
+ const [filters, setFilters] = useQueryState(
+ "basicFilters",
+ getFiltersStateParser().withDefault([])
+ )
+
+ // joinOperator 설정
+ const [joinOperator, setJoinOperator] = useQueryState(
+ "basicJoinOperator",
+ parseAsStringEnum(["and", "or"]).withDefault("and")
+ )
+
+ // 현재 URL의 페이지 파라미터도 가져옴
+ const [page, setPage] = useQueryState("page", { defaultValue: "1" })
+
+ // 폼 상태 초기화
+ const form = useForm<FilterFormValues>({
+ resolver: zodResolver(filterSchema),
+ defaultValues: {
+ picCode: "",
+ projectCode: "",
+ rfqCode: "",
+ itemCode: "",
+ majorItemMaterialCode: "",
+ status: "",
+ dateRange: {
+ from: undefined,
+ to: undefined,
+ },
+ },
+ })
+
+ // URL 필터에서 초기 폼 상태 설정 - 개선된 버전
+ useEffect(() => {
+ // 현재 필터를 문자열로 직렬화
+ const currentFiltersString = JSON.stringify(filters);
+
+ // 패널이 열렸고, 필터가 있고, 마지막에 적용된 필터와 다를 때만 업데이트
+ if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) {
+ setIsInitializing(true);
+
+ const formValues = { ...form.getValues() };
+ let formUpdated = false;
+
+ filters.forEach(filter => {
+ if (filter.id === "rfqSendDate" && Array.isArray(filter.value) && filter.value.length > 0) {
+ formValues.dateRange = {
+ from: filter.value[0] ? new Date(filter.value[0]) : undefined,
+ to: filter.value[1] ? new Date(filter.value[1]) : undefined,
+ };
+ formUpdated = true;
+ } else if (filter.id in formValues) {
+ // @ts-ignore - 동적 필드 접근
+ formValues[filter.id] = filter.value;
+ formUpdated = true;
+ }
+ });
+
+ // 폼 값이 변경된 경우에만 reset으로 한 번에 업데이트
+ if (formUpdated) {
+ form.reset(formValues);
+ lastAppliedFilters.current = currentFiltersString;
+ }
+
+ setIsInitializing(false);
+ }
+ }, [filters, isOpen]) // form 의존성 제거
+
+ // 현재 적용된 필터 카운트
+ const getActiveFilterCount = () => {
+ return filters?.length || 0
+ }
+
+ // 폼 제출 핸들러 - 개선된 버전
+ async function onSubmit(data: FilterFormValues) {
+ // 초기화 중이면 제출 방지
+ if (isInitializing) return;
+
+ startTransition(async () => {
+ try {
+ // 필터 배열 생성
+ const newFilters = []
+
+ if (data.picCode?.trim()) {
+ newFilters.push({
+ id: "picCode",
+ value: data.picCode.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.projectCode?.trim()) {
+ newFilters.push({
+ id: "projectCode",
+ value: data.projectCode.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.rfqCode?.trim()) {
+ newFilters.push({
+ id: "rfqCode",
+ value: data.rfqCode.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.itemCode?.trim()) {
+ newFilters.push({
+ id: "itemCode",
+ value: data.itemCode.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.majorItemMaterialCode?.trim()) {
+ newFilters.push({
+ id: "majorItemMaterialCode",
+ value: data.majorItemMaterialCode.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.status?.trim()) {
+ newFilters.push({
+ id: "status",
+ value: data.status.trim(),
+ type: "select",
+ operator: "eq",
+ rowId: generateId()
+ })
+ }
+
+ // Add date range to params if it exists
+ if (data.dateRange?.from) {
+ newFilters.push({
+ id: "rfqSendDate",
+ value: [
+ data.dateRange.from.toISOString().split('T')[0],
+ data.dateRange.to ? data.dateRange.to.toISOString().split('T')[0] : undefined
+ ].filter(Boolean),
+ type: "date",
+ operator: "isBetween",
+ rowId: generateId()
+ })
+ }
+
+ console.log("기본 필터 적용:", newFilters);
+
+ // 마지막 적용된 필터 업데이트
+ lastAppliedFilters.current = JSON.stringify(newFilters);
+
+ // 먼저 필터를 설정
+ await setFilters(newFilters.length > 0 ? newFilters : null);
+
+ // 그 다음 페이지를 1로 설정
+ await setPage("1");
+
+ // 필터 업데이트 후 조회 핸들러 호출 (제공된 경우)
+ if (onSearch) {
+ onSearch();
+ }
+ } catch (error) {
+ console.error("필터 적용 오류:", error);
+ }
+ })
+ }
+
+ // 필터 초기화 핸들러 - 개선된 버전
+ async function handleReset() {
+ try {
+ setIsInitializing(true);
+
+ form.reset({
+ picCode: "",
+ projectCode: "",
+ rfqCode: "",
+ itemCode: "",
+ majorItemMaterialCode: "",
+ status: "",
+ dateRange: { from: undefined, to: undefined },
+ });
+
+ // 필터와 조인 연산자를 초기화
+ await setFilters(null);
+ await setJoinOperator("and");
+ await setPage("1");
+
+ // 마지막 적용된 필터 초기화
+ lastAppliedFilters.current = "";
+
+ console.log("필터 초기화 완료");
+ setIsInitializing(false);
+ } catch (error) {
+ console.error("필터 초기화 오류:", error);
+ setIsInitializing(false);
+ }
+ }
+
+ // Don't render if not open (for side panel use)
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+ <div className="flex flex-col h-full max-h-full bg-[#F5F7FB]">
+ {/* Filter Panel Header - 보더 제거, 배경 색상 적용 */}
+ <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0">
+ <h3 className="text-lg font-semibold whitespace-nowrap">검색 필터</h3>
+ </div>
+
+ {/* Join Operator Selection - 보더 제거, 배경 색상 적용 */}
+ <div className="px-6 shrink-0">
+ <label className="text-sm font-medium">조건 결합 방식</label>
+ <Select
+ value={joinOperator}
+ onValueChange={(value: "and" | "or") => setJoinOperator(value)}
+ disabled={isInitializing}
+ >
+ <SelectTrigger className="h-8 w-[180px] mt-2 bg-white">
+ <SelectValue placeholder="조건 결합 방식" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="and">모든 조건 충족 (AND)</SelectItem>
+ <SelectItem value="or">하나라도 충족 (OR)</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0">
+ {/* Scrollable content area - 헤더와 버튼 사이에서 스크롤 */}
+ <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4">
+ <div className="space-y-6 pt-4">
+ {/* 발주 담당 */}
+ <FormField
+ control={form.control}
+ name="picCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("발주담당")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("발주담당 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("picCode", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 프로젝트 코드 */}
+ <FormField
+ control={form.control}
+ name="projectCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("프로젝트 코드")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("프로젝트 코드 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("projectCode", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* RFQ NO. */}
+ <FormField
+ control={form.control}
+ name="rfqCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("RFQ NO.")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("RFQ 번호 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("rfqCode", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 자재그룹 */}
+ <FormField
+ control={form.control}
+ name="itemCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("자재그룹")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("자재그룹 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("itemCode", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 자재코드 */}
+ <FormField
+ control={form.control}
+ name="majorItemMaterialCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("자재코드")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("자재코드 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("majorItemMaterialCode", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Status */}
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("Status")}</FormLabel>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ disabled={isInitializing}
+ >
+ <FormControl>
+ <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
+ <div className="flex justify-between w-full">
+ <SelectValue placeholder={t("Select status")} />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 -mr-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("status", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {statusOptions.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* RFQ 전송일 */}
+ <FormField
+ control={form.control}
+ name="dateRange"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("RFQ 전송일")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <DateRangePicker
+ triggerSize="default"
+ triggerClassName="w-full bg-white"
+ align="start"
+ showClearButton={true}
+ placeholder={t("RFQ 전송일 범위를 고르세요")}
+ value={field.value || undefined}
+ onChange={field.onChange}
+ disabled={isInitializing}
+ />
+ {(field.value?.from || field.value?.to) && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-10 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("dateRange", { from: undefined, to: undefined });
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ {/* Fixed buttons at bottom - 보더 제거, 배경 색상 적용 */}
+ <div className="p-4 shrink-0">
+ <div className="flex gap-2 justify-end">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleReset}
+ disabled={isPending || getActiveFilterCount() === 0 || isInitializing}
+ className="px-4"
+ >
+ {t("초기화")}
+ </Button>
+ <Button
+ type="submit"
+ variant="samsung"
+ disabled={isPending || isLoading || isInitializing}
+ className="px-4"
+ >
+ <Search className="size-4 mr-2" />
+ {isPending || isLoading ? t("조회 중...") : t("조회")}
+ </Button>
+ </div>
+ </div>
+ </form>
+ </Form>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/hooks/use-local-storage.ts b/hooks/use-local-storage.ts
new file mode 100644
index 00000000..f24c80db
--- /dev/null
+++ b/hooks/use-local-storage.ts
@@ -0,0 +1,48 @@
+"use client"
+
+import { useState, useEffect } from 'react'
+
+export function useLocalStorage<T>(
+ key: string,
+ initialValue: T
+): [T, (value: T) => void] {
+ // State to store our value
+ const [storedValue, setStoredValue] = useState<T>(initialValue)
+
+ // Initialize with stored value or initial value
+ useEffect(() => {
+ try {
+ // Get from local storage by key
+ if (typeof window !== 'undefined') {
+ const item = window.localStorage.getItem(key)
+ // Parse stored json or if none return initialValue
+ setStoredValue(item ? JSON.parse(item) : initialValue)
+ }
+ } catch (error) {
+ // If error also return initialValue
+ console.error('Error reading from localStorage:', error)
+ setStoredValue(initialValue)
+ }
+ }, [key, initialValue])
+
+ // Return a wrapped version of useState's setter function that
+ // persists the new value to localStorage.
+ const setValue = (value: T) => {
+ try {
+ // Allow value to be a function so we have same API as useState
+ const valueToStore =
+ value instanceof Function ? value(storedValue) : value
+ // Save state
+ setStoredValue(valueToStore)
+ // Save to local storage
+ if (typeof window !== 'undefined') {
+ window.localStorage.setItem(key, JSON.stringify(valueToStore))
+ }
+ } catch (error) {
+ // A more advanced implementation would handle the error case
+ console.error('Error writing to localStorage:', error)
+ }
+ }
+
+ return [storedValue, setValue]
+} \ No newline at end of file
diff --git a/hooks/useAutoSizeColumns.ts b/hooks/useAutoSizeColumns.ts
index 3750de97..6e9cc7dc 100644
--- a/hooks/useAutoSizeColumns.ts
+++ b/hooks/useAutoSizeColumns.ts
@@ -1,6 +1,13 @@
-// hooks/useAutoSizeColumns.ts
import { useEffect, useRef } from "react"
-import { Table } from "@tanstack/react-table"
+import { Table, ColumnDef } from "@tanstack/react-table"
+
+// 커스텀 메타 타입 정의
+declare module '@tanstack/react-table' {
+ interface ColumnMeta<TData, TValue> {
+ paddingFactor?: number;
+ excelHeader?: string;
+ }
+}
export function useAutoSizeColumns<T>(
table: Table<T>,
@@ -29,7 +36,7 @@ export function useAutoSizeColumns<T>(
const columnId = column.id
// Get column-specific padding from meta if available
- const paddingFactor = column.columnDef.meta?.paddingFactor as number || 1
+ const paddingFactor = column.columnDef.meta?.paddingFactor || 1
const extraPadding = paddingFactor * defaultPadding
// Get all cells for this column
@@ -41,15 +48,30 @@ export function useAutoSizeColumns<T>(
measureElement.style.position = 'absolute'
measureElement.style.visibility = 'hidden'
measureElement.style.whiteSpace = 'nowrap'
- measureElement.style.font = headerElement
- ? window.getComputedStyle(headerElement).font
+ measureElement.style.font = headerElement
+ ? window.getComputedStyle(headerElement).font
: window.getComputedStyle(document.body).font
document.body.appendChild(measureElement)
- // Measure header
- const headerText = headerElement?.textContent || ""
- measureElement.textContent = headerText
- let maxWidth = measureElement.getBoundingClientRect().width
+ // Measure header - 헤더 요소 자체의 너비를 직접 측정
+ let headerWidth = 0
+ if (headerElement) {
+ // 1. 먼저 텍스트 콘텐츠 측정
+ const headerText = headerElement.textContent || ""
+ measureElement.textContent = headerText
+ headerWidth = measureElement.getBoundingClientRect().width
+
+ // 2. 헤더에 아이콘이나 다른 요소가 있을 경우를 고려
+ // 헤더 요소의 실제 너비 확인 (텍스트외 요소 포함)
+ const headerClientWidth = headerElement.querySelector('div')?.clientWidth ||
+ headerElement.clientWidth || 0
+
+ // 텍스트 너비와 실제 요소 너비 중 큰 값 선택
+ headerWidth = Math.max(headerWidth, headerClientWidth)
+ }
+
+ // 초기 최대 너비를 헤더 너비로 설정
+ let maxWidth = headerWidth
// Measure cells (limit to first 20 for performance)
Array.from(cells).slice(0, 20).forEach(cell => {
diff --git a/lib/procurement-rfqs/services.ts b/lib/procurement-rfqs/services.ts
index 7179b213..facdc9c9 100644
--- a/lib/procurement-rfqs/services.ts
+++ b/lib/procurement-rfqs/services.ts
@@ -1654,11 +1654,14 @@ export async function getVendorQuotations(input: GetQuotationsSchema, vendorId:
offset,
limit: perPage,
with: {
- rfq: true,
+ rfq: {
+ with: {
+ item: true, // 여기서 item 정보도 가져옴
+ }
+ },
vendor: true,
}
});
-
// 전체 개수 조회
const { totalCount } = await db
.select({ totalCount: count() })
diff --git a/lib/procurement-rfqs/table/rfq-table copy.tsx b/lib/procurement-rfqs/table/rfq-table copy.tsx
new file mode 100644
index 00000000..510f474d
--- /dev/null
+++ b/lib/procurement-rfqs/table/rfq-table copy.tsx
@@ -0,0 +1,209 @@
+"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { getColumns, EditingCellState } from "./rfq-table-column"
+import { useEffect } from "react"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { RFQTableToolbarActions } from "./rfq-table-toolbar-actions"
+import { ProcurementRfqsView } from "@/db/schema"
+import { getPORfqs } from "../services"
+import { toast } from "sonner"
+import { updateRfqRemark } from "@/lib/procurement-rfqs/services" // 구현 필요
+
+interface RFQListTableProps {
+ data?: Awaited<ReturnType<typeof getPORfqs>>;
+ onSelectRFQ?: (rfq: ProcurementRfqsView | null) => void;
+ // 데이터 새로고침을 위한 콜백 추가
+ onDataRefresh?: () => void;
+ maxHeight?: string | number; // Add this prop
+}
+
+// 보다 유연한 타입 정의
+type LocalDataType = Awaited<ReturnType<typeof getPORfqs>>;
+
+export function RFQListTable({
+ data,
+ onSelectRFQ,
+ onDataRefresh,
+ maxHeight
+}: RFQListTableProps) {
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<ProcurementRfqsView> | null>(null)
+ // 인라인 에디팅을 위한 상태 추가
+ const [editingCell, setEditingCell] = React.useState<EditingCellState | null>(null)
+ // 로컬 데이터를 관리하기 위한 상태 추가
+ const [localData, setLocalData] = React.useState<LocalDataType>(data || { data: [], pageCount: 0, total: 0 });
+
+ // 데이터가 변경될 때 로컬 데이터도 업데이트
+ useEffect(() => {
+ setLocalData(data || { data: [], pageCount: 0, total: 0 })
+ }, [data])
+
+
+ // 비고 업데이트 함수
+ const updateRemark = async (rfqId: number, remark: string) => {
+ try {
+ // 낙관적 UI 업데이트 (로컬 데이터 먼저 갱신)
+ if (localData && localData.data) {
+ // 로컬 데이터에서 해당 행 찾기
+ const rowIndex = localData.data.findIndex(row => row.id === rfqId);
+ if (rowIndex >= 0) {
+ // 불변성을 유지하면서 로컬 데이터 업데이트
+ const newData = [...localData.data];
+ newData[rowIndex] = { ...newData[rowIndex], remark };
+
+ // 전체 데이터 구조 복사하여 업데이트
+ setLocalData({ ...localData, data: newData } as typeof localData);
+ }
+ }
+
+ const result = await updateRfqRemark(rfqId, remark);
+
+ if (result.success) {
+ toast.success("비고가 업데이트되었습니다");
+
+ // 서버 데이터 리프레시 호출
+ if (onDataRefresh) {
+ onDataRefresh();
+ }
+ } else {
+ toast.error(result.message || "업데이트 중 오류가 발생했습니다");
+ }
+ } catch (error) {
+ console.error("비고 업데이트 오류:", error);
+ toast.error("업데이트 중 오류가 발생했습니다");
+ }
+ }
+
+ // 행 액션 처리
+ useEffect(() => {
+ if (rowAction) {
+ // 액션 유형에 따라 처리
+ switch (rowAction.type) {
+ case "select":
+ // 선택된 문서 처리
+ if (onSelectRFQ) {
+ onSelectRFQ(rowAction.row.original)
+ }
+ break;
+ case "update":
+ // 업데이트 처리 로직
+ console.log("Update rfq:", rowAction.row.original)
+ break;
+ case "delete":
+ // 삭제 처리 로직
+ console.log("Delete rfq:", rowAction.row.original)
+ break;
+ }
+
+ // 액션 처리 후 rowAction 초기화
+ setRowAction(null)
+ }
+ }, [rowAction, onSelectRFQ])
+
+ const columns = React.useMemo(
+ () => getColumns({
+ setRowAction,
+ editingCell,
+ setEditingCell,
+ updateRemark
+ }),
+ [setRowAction, editingCell, setEditingCell, updateRemark]
+ )
+
+
+ // Filter fields
+ const filterFields: DataTableFilterField<ProcurementRfqsView>[] = []
+
+ const advancedFilterFields: DataTableAdvancedFilterField<ProcurementRfqsView>[] = [
+ {
+ id: "rfqCode",
+ label: "RFQ No.",
+ type: "text",
+ },
+ {
+ id: "projectCode",
+ label: "프로젝트",
+ type: "text",
+ },
+ {
+ id: "itemCode",
+ label: "자재그룹",
+ type: "text",
+ },
+ {
+ id: "itemName",
+ label: "자재명",
+ type: "text",
+ },
+
+ {
+ id: "rfqSealedYn",
+ label: "RFQ 밀봉여부",
+ type: "text",
+ },
+ {
+ id: "majorItemMaterialCode",
+ label: "자재코드",
+ type: "text",
+ },
+ {
+ id: "rfqSendDate",
+ label: "RFQ 전송일",
+ type: "date",
+ },
+ {
+ id: "dueDate",
+ label: "RFQ 마감일",
+ type: "date",
+ },
+ {
+ id: "createdByUserName",
+ label: "요청자",
+ type: "text",
+ },
+ ]
+
+ // useDataTable 훅으로 react-table 구성 - 로컬 데이터 사용하도록 수정
+ const { table } = useDataTable({
+ data: localData?.data || [],
+ columns,
+ pageCount: localData?.pageCount || 0,
+ rowCount: localData?.total || 0, // 총 레코드 수 추가
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "updatedAt", desc: true }],
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ columnResizeMode: "onEnd",
+ })
+
+ return (
+ <div className="w-full overflow-auto">
+ <DataTable table={table} maxHeight={maxHeight}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <RFQTableToolbarActions
+ table={table}
+ localData={localData}
+ setLocalData={setLocalData}
+ onSuccess={onDataRefresh}
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/rfq-table.tsx b/lib/procurement-rfqs/table/rfq-table.tsx
index 510f474d..23cd66fa 100644
--- a/lib/procurement-rfqs/table/rfq-table.tsx
+++ b/lib/procurement-rfqs/table/rfq-table.tsx
@@ -3,63 +3,96 @@
import * as React from "react"
import type {
DataTableAdvancedFilterField,
- DataTableFilterField,
DataTableRowAction,
} from "@/types/table"
import { useDataTable } from "@/hooks/use-data-table"
import { DataTable } from "@/components/data-table/data-table"
import { getColumns, EditingCellState } from "./rfq-table-column"
-import { useEffect } from "react"
+import { useEffect, useCallback, useRef, useMemo } from "react"
import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
import { RFQTableToolbarActions } from "./rfq-table-toolbar-actions"
import { ProcurementRfqsView } from "@/db/schema"
import { getPORfqs } from "../services"
import { toast } from "sonner"
-import { updateRfqRemark } from "@/lib/procurement-rfqs/services" // 구현 필요
+import { updateRfqRemark } from "@/lib/procurement-rfqs/services"
+import { useSearchParams } from "next/navigation"
+import { useTablePresets } from "@/components/data-table/use-table-presets"
+import { TablePresetManager } from "@/components/data-table/data-table-preset"
+import { Loader2 } from "lucide-react"
interface RFQListTableProps {
data?: Awaited<ReturnType<typeof getPORfqs>>;
onSelectRFQ?: (rfq: ProcurementRfqsView | null) => void;
- // 데이터 새로고침을 위한 콜백 추가
onDataRefresh?: () => void;
- maxHeight?: string | number; // Add this prop
+ maxHeight?: string | number;
}
-// 보다 유연한 타입 정의
-type LocalDataType = Awaited<ReturnType<typeof getPORfqs>>;
-
export function RFQListTable({
data,
onSelectRFQ,
onDataRefresh,
maxHeight
}: RFQListTableProps) {
+ const searchParams = useSearchParams()
const [rowAction, setRowAction] = React.useState<DataTableRowAction<ProcurementRfqsView> | null>(null)
- // 인라인 에디팅을 위한 상태 추가
const [editingCell, setEditingCell] = React.useState<EditingCellState | null>(null)
- // 로컬 데이터를 관리하기 위한 상태 추가
- const [localData, setLocalData] = React.useState<LocalDataType>(data || { data: [], pageCount: 0, total: 0 });
+ const [localData, setLocalData] = React.useState<typeof data>(data || { data: [], pageCount: 0, total: 0 })
+ const [isMounted, setIsMounted] = React.useState(false)
+
+ // 초기 설정 정의
+ const initialSettings = React.useMemo(() => ({
+ page: parseInt(searchParams.get('page') || '1'),
+ perPage: parseInt(searchParams.get('perPage') || '10'),
+ sort: searchParams.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "updatedAt", desc: true }],
+ filters: searchParams.get('filters') ? JSON.parse(searchParams.get('filters')!) : [],
+ joinOperator: (searchParams.get('joinOperator') as "and" | "or") || "and",
+ basicFilters: searchParams.get('basicFilters') ? JSON.parse(searchParams.get('basicFilters')!) : [],
+ basicJoinOperator: (searchParams.get('basicJoinOperator') as "and" | "or") || "and",
+ search: searchParams.get('search') || '',
+ from: searchParams.get('from') || undefined,
+ to: searchParams.get('to') || undefined,
+ columnVisibility: {},
+ columnOrder: [],
+ pinnedColumns: { left: [], right: [] },
+ groupBy: [],
+ expandedRows: []
+ }), [searchParams])
- // 데이터가 변경될 때 로컬 데이터도 업데이트
+ // DB 기반 프리셋 훅 사용
+ const {
+ presets,
+ activePresetId,
+ hasUnsavedChanges,
+ isLoading: presetsLoading,
+ createPreset,
+ applyPreset,
+ updatePreset,
+ deletePreset,
+ setDefaultPreset,
+ renamePreset,
+ updateClientState,
+ getCurrentSettings,
+ } = useTablePresets<ProcurementRfqsView>('rfq-list-table', initialSettings)
+
+ // 클라이언트 마운트 체크
+ useEffect(() => {
+ setIsMounted(true)
+ }, [])
+
+ // 데이터 변경 감지
useEffect(() => {
setLocalData(data || { data: [], pageCount: 0, total: 0 })
}, [data])
-
// 비고 업데이트 함수
const updateRemark = async (rfqId: number, remark: string) => {
try {
- // 낙관적 UI 업데이트 (로컬 데이터 먼저 갱신)
if (localData && localData.data) {
- // 로컬 데이터에서 해당 행 찾기
const rowIndex = localData.data.findIndex(row => row.id === rfqId);
if (rowIndex >= 0) {
- // 불변성을 유지하면서 로컬 데이터 업데이트
const newData = [...localData.data];
newData[rowIndex] = { ...newData[rowIndex], remark };
-
- // 전체 데이터 구조 복사하여 업데이트
- setLocalData({ ...localData, data: newData } as typeof localData);
+ setLocalData({ ...localData, data: newData });
}
}
@@ -67,8 +100,6 @@ export function RFQListTable({
if (result.success) {
toast.success("비고가 업데이트되었습니다");
-
- // 서버 데이터 리프레시 호출
if (onDataRefresh) {
onDataRefresh();
}
@@ -80,29 +111,23 @@ export function RFQListTable({
toast.error("업데이트 중 오류가 발생했습니다");
}
}
-
+
// 행 액션 처리
useEffect(() => {
if (rowAction) {
- // 액션 유형에 따라 처리
switch (rowAction.type) {
case "select":
- // 선택된 문서 처리
if (onSelectRFQ) {
onSelectRFQ(rowAction.row.original)
}
break;
case "update":
- // 업데이트 처리 로직
console.log("Update rfq:", rowAction.row.original)
break;
case "delete":
- // 삭제 처리 로직
console.log("Delete rfq:", rowAction.row.original)
break;
}
-
- // 액션 처리 후 rowAction 초기화
setRowAction(null)
}
}, [rowAction, onSelectRFQ])
@@ -116,11 +141,8 @@ export function RFQListTable({
}),
[setRowAction, editingCell, setEditingCell, updateRemark]
)
-
-
- // Filter fields
- const filterFields: DataTableFilterField<ProcurementRfqsView>[] = []
+ // 고급 필터 필드 정의
const advancedFilterFields: DataTableAdvancedFilterField<ProcurementRfqsView>[] = [
{
id: "rfqCode",
@@ -142,7 +164,6 @@ export function RFQListTable({
label: "자재명",
type: "text",
},
-
{
id: "rfqSealedYn",
label: "RFQ 밀봉여부",
@@ -170,38 +191,183 @@ export function RFQListTable({
},
]
- // useDataTable 훅으로 react-table 구성 - 로컬 데이터 사용하도록 수정
+ // 현재 설정 가져오기
+ const currentSettings = useMemo(() => {
+ return getCurrentSettings()
+ }, [getCurrentSettings])
+
+ // useDataTable 초기 상태 설정
+ const initialState = useMemo(() => {
+ console.log('Setting initial state:', currentSettings)
+ return {
+ sorting: initialSettings.sort.filter(sortItem => {
+ const columnExists = columns.some(col => col.accessorKey === sortItem.id)
+ return columnExists
+ }) as any,
+ columnVisibility: currentSettings.columnVisibility,
+ columnPinning: currentSettings.pinnedColumns,
+ }
+ }, [currentSettings, initialSettings.sort, columns])
+
+ // useDataTable 훅 설정
const { table } = useDataTable({
data: localData?.data || [],
columns,
pageCount: localData?.pageCount || 0,
- rowCount: localData?.total || 0, // 총 레코드 수 추가
- filterFields,
+ rowCount: localData?.total || 0,
+ filterFields: [],
enablePinning: true,
enableAdvancedFilter: true,
- initialState: {
- sorting: [{ id: "updatedAt", desc: true }],
- },
+ initialState,
getRowId: (originalRow) => String(originalRow.id),
shallow: false,
clearOnDefault: true,
columnResizeMode: "onEnd",
})
+
+ // 테이블 상태 변경 감지 및 자동 저장
+ const lastKnownStateRef = useRef<{
+ columnVisibility: string
+ columnPinning: string
+ columnOrder: string[]
+ }>({
+ columnVisibility: '{}',
+ columnPinning: '{"left":[],"right":[]}',
+ columnOrder: []
+ })
+
+ const checkAndUpdateTableState = useCallback(() => {
+ if (!presetsLoading && !activePresetId) return
+
+ try {
+ const currentVisibility = table.getState().columnVisibility
+ const currentPinning = table.getState().columnPinning
+
+ // 컬럼 순서 가져오기
+ const allColumns = table.getAllColumns()
+ const leftPinned = table.getLeftHeaderGroups()[0]?.headers.map(h => h.column.id) || []
+ const rightPinned = table.getRightHeaderGroups()[0]?.headers.map(h => h.column.id) || []
+ const center = table.getCenterHeaderGroups()[0]?.headers.map(h => h.column.id) || []
+ const currentOrder = [...leftPinned, ...center, ...rightPinned]
+
+ const visibilityString = JSON.stringify(currentVisibility)
+ const pinningString = JSON.stringify(currentPinning)
+ const orderString = JSON.stringify(currentOrder)
+
+ // 실제 변경이 있을 때만 업데이트
+ if (
+ visibilityString !== lastKnownStateRef.current.columnVisibility ||
+ pinningString !== lastKnownStateRef.current.columnPinning ||
+ orderString !== JSON.stringify(lastKnownStateRef.current.columnOrder)
+ ) {
+ console.log('Table state changed, updating preset...')
+
+ const newClientState = {
+ columnVisibility: currentVisibility,
+ columnOrder: currentOrder,
+ pinnedColumns: currentPinning,
+ }
+
+ // 상태 업데이트 전에 기록
+ lastKnownStateRef.current = {
+ columnVisibility: visibilityString,
+ columnPinning: pinningString,
+ columnOrder: currentOrder
+ }
+
+ updateClientState(newClientState)
+ }
+ } catch (error) {
+ console.error('Error checking table state:', error)
+ }
+ }, [activePresetId, table, updateClientState, presetsLoading ])
+
+ // 주기적으로 테이블 상태 체크
+ useEffect(() => {
+ if (!isMounted || !activePresetId) return
+
+ console.log('Starting table state polling')
+ const intervalId = setInterval(checkAndUpdateTableState, 500)
+
+ return () => {
+ clearInterval(intervalId)
+ console.log('Stopped table state polling')
+ }
+ }, [isMounted, activePresetId, checkAndUpdateTableState])
+
+ // 프리셋 적용 시 테이블 상태 업데이트
+ useEffect(() => {
+ if (isMounted && activePresetId && currentSettings) {
+ const settings = currentSettings
+ console.log('Applying preset settings to table:', settings)
+
+ const currentVisibility = table.getState().columnVisibility
+ const currentPinning = table.getState().columnPinning
+
+ if (
+ JSON.stringify(currentVisibility) !== JSON.stringify(settings.columnVisibility) ||
+ JSON.stringify(currentPinning) !== JSON.stringify(settings.pinnedColumns)
+ ) {
+ console.log('Updating table state to match preset...')
+
+ // 테이블 상태 업데이트
+ table.setColumnVisibility(settings.columnVisibility)
+ table.setColumnPinning(settings.pinnedColumns)
+
+ // 상태 저장소 업데이트
+ lastKnownStateRef.current = {
+ columnVisibility: JSON.stringify(settings.columnVisibility),
+ columnPinning: JSON.stringify(settings.pinnedColumns),
+ columnOrder: settings.columnOrder || []
+ }
+ }
+ }
+ }, [isMounted, activePresetId, currentSettings, table])
+ // 로딩 중일 때는 스켈레톤 표시
+ if (!isMounted) {
+ return (
+ <div className="w-full h-96 flex items-center justify-center">
+ <div className="flex flex-col items-center gap-2">
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
+ <span className="text-sm text-muted-foreground">테이블 설정을 로드하는 중...</span>
+ </div>
+ </div>
+ )
+ }
+
return (
- <div className="w-full overflow-auto">
+ <div className="w-full overflow-auto">
<DataTable table={table} maxHeight={maxHeight}>
<DataTableAdvancedToolbar
table={table}
filterFields={advancedFilterFields}
shallow={false}
>
- <RFQTableToolbarActions
- table={table}
- localData={localData}
- setLocalData={setLocalData}
- onSuccess={onDataRefresh}
- />
+ <div className="flex items-center gap-2">
+ {/* DB 기반 테이블 프리셋 매니저 */}
+ <TablePresetManager<ProcurementRfqsView>
+ presets={presets}
+ activePresetId={activePresetId}
+ currentSettings={currentSettings}
+ hasUnsavedChanges={hasUnsavedChanges}
+ isLoading={presetsLoading}
+ onCreatePreset={createPreset}
+ onUpdatePreset={updatePreset}
+ onDeletePreset={deletePreset}
+ onApplyPreset={applyPreset}
+ onSetDefaultPreset={setDefaultPreset}
+ onRenamePreset={renamePreset}
+ />
+
+ {/* 기존 툴바 액션들 */}
+ <RFQTableToolbarActions
+ table={table}
+ localData={localData}
+ setLocalData={setLocalData}
+ onSuccess={onDataRefresh}
+ />
+ </div>
</DataTableAdvancedToolbar>
</DataTable>
</div>
diff --git a/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx b/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx
index 9eecc72f..bad793c3 100644
--- a/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx
+++ b/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx
@@ -48,9 +48,21 @@ function StatusBadge({ status }: { status: string }) {
interface QuotationWithRfqCode extends ProcurementVendorQuotations {
rfqCode?: string;
rfq?: {
+ id?: number;
rfqCode?: string;
+ status?: string;
dueDate?: Date | string | null;
-
+ rfqSendDate?: Date | string | null;
+ item?: {
+ id?: number;
+ itemCode?: string;
+ itemName?: string;
+ } | null;
+ } | null;
+ vendor?: {
+ id?: number;
+ vendorName?: string;
+ vendorCode?: string;
} | null;
}
@@ -64,7 +76,7 @@ interface GetColumnsProps {
}
/**
- * tanstack table 컬럼 정의
+ * tanstack table 컬럼 정의 (RfqsTable 스타일)
*/
export function getColumns({
setRowAction,
@@ -99,13 +111,10 @@ export function getColumns({
enableHiding: false,
}
-
// ----------------------------------------------------------------
- // 3) 일반 컬럼들
+ // 2) actions 컬럼
// ----------------------------------------------------------------
-
- // 견적서 액션 컬럼 (아이콘 버튼으로 변경)
- const quotationActionColumn: ColumnDef<QuotationWithRfqCode> = {
+ const actionsColumn: ColumnDef<QuotationWithRfqCode> = {
id: "actions",
enableHiding: false,
cell: ({ row }) => {
@@ -134,106 +143,191 @@ export function getColumns({
</TooltipProvider>
)
},
- size: 50, // 아이콘으로 변경했으므로 크기 줄임
- }
-
- // RFQ 번호 컬럼
- const rfqCodeColumn: ColumnDef<QuotationWithRfqCode> = {
- accessorKey: "quotationCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 번호" />
- ),
- cell: ({ row }) => row.original.quotationCode || "-",
- size: 150,
+ size: 50,
}
- // RFQ 버전 컬럼
- const quotationVersionColumn: ColumnDef<QuotationWithRfqCode> = {
- accessorKey: "quotationVersion",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 버전" />
- ),
- cell: ({ row }) => row.original.quotationVersion || "-",
+ // ----------------------------------------------------------------
+ // 3) 컬럼 정의 배열
+ // ----------------------------------------------------------------
+ const columnDefinitions = [
+ {
+ id: "quotationCode",
+ label: "RFQ 번호",
+ group: null,
+ size: 150,
+ minSize: 100,
+ maxSize: 200,
+ },
+ {
+ id: "quotationVersion",
+ label: "RFQ 버전",
+ group: null,
+ size: 100,
+ minSize: 80,
+ maxSize: 120,
+ },
+ {
+ id: "itemCode",
+ label: "자재 그룹 코드",
+ group: "RFQ 정보",
+ size: 120,
+ minSize: 100,
+ maxSize: 150,
+ },
+ {
+ id: "itemName",
+ label: "자재 이름",
+ group: "RFQ 정보",
+ // size를 제거하여 유연한 크기 조정 허용
+ minSize: 150,
+ maxSize: 300,
+ },
+ {
+ id: "rfqSendDate",
+ label: "RFQ 송부일",
+ group: "날짜 정보",
+ size: 150,
+ minSize: 120,
+ maxSize: 180,
+ },
+ {
+ id: "dueDate",
+ label: "RFQ 마감일",
+ group: "날짜 정보",
+ size: 150,
+ minSize: 120,
+ maxSize: 180,
+ },
+ {
+ id: "status",
+ label: "상태",
+ group: null,
size: 100,
+ minSize: 80,
+ maxSize: 120,
+ },
+ {
+ id: "totalPrice",
+ label: "총액",
+ group: null,
+ size: 120,
+ minSize: 100,
+ maxSize: 150,
+ },
+ {
+ id: "submittedAt",
+ label: "제출일",
+ group: "날짜 정보",
+ size: 120,
+ minSize: 100,
+ maxSize: 150,
+ },
+ {
+ id: "validUntil",
+ label: "유효기간",
+ group: "날짜 정보",
+ size: 120,
+ minSize: 100,
+ maxSize: 150,
+ },
+ ];
+
+ // ----------------------------------------------------------------
+ // 4) 그룹별로 컬럼 정리 (중첩 헤더 생성)
+ // ----------------------------------------------------------------
+ const groupMap: Record<string, ColumnDef<QuotationWithRfqCode>[]> = {}
+
+ columnDefinitions.forEach((cfg) => {
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
}
- const dueDateColumn: ColumnDef<QuotationWithRfqCode> = {
- accessorKey: "dueDate",
+ // 개별 컬럼 정의
+ const columnDef: ColumnDef<QuotationWithRfqCode> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 마감일" />
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
),
- cell: ({ row }) => {
- // 타입 단언 사용
- const rfq = row.original.rfq as any;
- const date = rfq?.dueDate as string | null;
- return date ? formatDateTime(new Date(date)) : "-";
+ cell: ({ row, cell }) => {
+ // 각 컬럼별 특별한 렌더링 처리
+ switch (cfg.id) {
+ case "quotationCode":
+ return row.original.quotationCode || "-"
+
+ case "quotationVersion":
+ return row.original.quotationVersion || "-"
+
+ case "itemCode":
+ const itemCode = row.original.rfq?.item?.itemCode;
+ return itemCode ? itemCode : "-";
+
+ case "itemName":
+ const itemName = row.original.rfq?.item?.itemName;
+ return itemName ? itemName : "-";
+
+ case "rfqSendDate":
+ const sendDate = row.original.rfq?.rfqSendDate;
+ return sendDate ? formatDateTime(new Date(sendDate)) : "-";
+
+ case "dueDate":
+ const dueDate = row.original.rfq?.dueDate;
+ return dueDate ? formatDateTime(new Date(dueDate)) : "-";
+
+ case "status":
+ return <StatusBadge status={row.getValue("status") as string} />
+
+ case "totalPrice":
+ const price = parseFloat(row.getValue("totalPrice") as string || "0")
+ const currency = row.original.currency
+ return formatCurrency(price, currency)
+
+ case "submittedAt":
+ const submitDate = row.getValue("submittedAt") as string | null
+ return submitDate ? formatDate(new Date(submitDate)) : "-"
+
+ case "validUntil":
+ const validDate = row.getValue("validUntil") as string | null
+ return validDate ? formatDate(new Date(validDate)) : "-"
+
+ default:
+ return row.getValue(cfg.id) ?? ""
+ }
},
- size: 100,
+ size: cfg.size,
+ minSize: cfg.minSize,
+ maxSize: cfg.maxSize,
}
-
- // 상태 컬럼
- const statusColumn: ColumnDef<QuotationWithRfqCode> = {
- accessorKey: "status",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="상태" />
- ),
- cell: ({ row }) => <StatusBadge status={row.getValue("status") as string} />,
- size: 100,
- }
-
- // 총액 컬럼
- const totalPriceColumn: ColumnDef<QuotationWithRfqCode> = {
- accessorKey: "totalPrice",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="총액" />
- ),
- cell: ({ row }) => {
- const price = parseFloat(row.getValue("totalPrice") as string || "0")
- const currency = row.original.currency
-
- return formatCurrency(price, currency)
- },
- size: 120,
- }
-
- // 제출일 컬럼
- const submittedAtColumn: ColumnDef<QuotationWithRfqCode> = {
- accessorKey: "submittedAt",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="제출일" />
- ),
- cell: ({ row }) => {
- const date = row.getValue("submittedAt") as string | null
- return date ? formatDate(new Date(date)) : "-"
- },
- size: 120,
- }
-
- // 유효기간 컬럼
- const validUntilColumn: ColumnDef<QuotationWithRfqCode> = {
- accessorKey: "validUntil",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="유효기간" />
- ),
- cell: ({ row }) => {
- const date = row.getValue("validUntil") as string | null
- return date ? formatDate(new Date(date)) : "-"
- },
- size: 120,
- }
+
+ groupMap[groupName].push(columnDef)
+ })
+
+ // ----------------------------------------------------------------
+ // 5) 그룹별 중첩 컬럼 생성
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<QuotationWithRfqCode>[] = []
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ // 그룹이 없는 컬럼들은 직접 추가
+ nestedColumns.push(...colDefs)
+ } else {
+ // 그룹이 있는 컬럼들은 중첩 구조로 추가
+ nestedColumns.push({
+ id: groupName,
+ header: groupName,
+ columns: colDefs,
+ })
+ }
+ })
// ----------------------------------------------------------------
- // 4) 최종 컬럼 배열
+ // 6) 최종 컬럼 배열
// ----------------------------------------------------------------
return [
selectColumn,
- rfqCodeColumn,
- quotationVersionColumn,
- dueDateColumn,
- statusColumn,
- totalPriceColumn,
- submittedAtColumn,
- validUntilColumn,
- quotationActionColumn // 이름을 변경하고 마지막에 배치
+ ...nestedColumns,
+ actionsColumn,
]
} \ No newline at end of file
diff --git a/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table.tsx b/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table.tsx
index 92bda337..7ea0c69e 100644
--- a/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table.tsx
+++ b/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table.tsx
@@ -109,7 +109,7 @@ export function VendorQuotationsTable({ promises }: VendorQuotationsTableProps)
},
];
- // useDataTable 훅 사용
+ // useDataTable 훅 사용 (RfqsTable 스타일로 개선)
const { table } = useDataTable({
data,
columns,
@@ -117,6 +117,8 @@ export function VendorQuotationsTable({ promises }: VendorQuotationsTableProps)
filterFields,
enablePinning: true,
enableAdvancedFilter: true,
+ enableColumnResizing: true, // 컬럼 크기 조정 허용
+ columnResizeMode: 'onChange', // 실시간 크기 조정
initialState: {
sorting: [{ id: "updatedAt", desc: true }],
columnPinning: { right: ["actions"] },
@@ -124,22 +126,27 @@ export function VendorQuotationsTable({ promises }: VendorQuotationsTableProps)
getRowId: (originalRow) => String(originalRow.id),
shallow: false,
clearOnDefault: true,
+ defaultColumn: {
+ minSize: 50,
+ maxSize: 500,
+ },
});
return (
- <div style={{ maxWidth: '100vw' }}>
- <DataTable
- table={table}
- >
- <DataTableAdvancedToolbar
+ <div className="w-full">
+ <div className="overflow-x-auto">
+ <DataTable
table={table}
- filterFields={advancedFilterFields}
- shallow={false}
+ className="min-w-full"
>
- </DataTableAdvancedToolbar>
- </DataTable>
-
-
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
</div>
);
} \ No newline at end of file
diff --git a/lib/utils.ts b/lib/utils.ts
index af9df057..c7015638 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -11,6 +11,28 @@ export function formatDate(
opts: Intl.DateTimeFormatOptions = {},
includeTime: boolean = false
) {
+ const dateObj = new Date(date);
+
+ // 한국 로케일인 경우 하이픈 포맷 사용
+ if (locale === "ko-KR" || locale === "KR" || locale === "kr") {
+ const year = dateObj.getFullYear();
+ const month = String(dateObj.getMonth() + 1).padStart(2, "0");
+ const day = String(dateObj.getDate()).padStart(2, "0");
+
+ let result = `${year}-${month}-${day}`;
+
+ // 시간 포함 옵션이 활성화된 경우
+ if (includeTime) {
+ const hour = String(dateObj.getHours()).padStart(2, "0");
+ const minute = String(dateObj.getMinutes()).padStart(2, "0");
+ const second = String(dateObj.getSeconds()).padStart(2, "0");
+ result += ` ${hour}:${minute}:${second}`;
+ }
+
+ return result;
+ }
+
+ // 다른 로케일은 기존 방식 유지
return new Intl.DateTimeFormat(locale, {
month: opts.month ?? "long",
day: opts.day ?? "numeric",
@@ -23,20 +45,34 @@ export function formatDate(
hour12: opts.hour12 ?? false, // Use 24-hour format by default
}),
...opts, // This allows overriding any of the above defaults
- }).format(new Date(date))
+ }).format(dateObj);
}
-// Alternative: Create a separate function for date and time
+// formatDateTime 함수도 같은 방식으로 수정
export function formatDateTime(
- date: Date | string | number| null | undefined,
+ date: Date | string | number | null | undefined,
locale: string = "en-US",
opts: Intl.DateTimeFormatOptions = {}
) {
-
if (date === null || date === undefined || date === '') {
return ''; // 또는 '-', 'N/A' 등 원하는 기본값 반환
}
-
+
+ const dateObj = new Date(date);
+
+ // 한국 로케일인 경우 하이픈 포맷 사용
+ if (locale === "ko-KR" || locale === "KR" || locale === "kr") {
+ const year = dateObj.getFullYear();
+ const month = String(dateObj.getMonth() + 1).padStart(2, "0");
+ const day = String(dateObj.getDate()).padStart(2, "0");
+ const hour = String(dateObj.getHours()).padStart(2, "0");
+ const minute = String(dateObj.getMinutes()).padStart(2, "0");
+ const second = String(dateObj.getSeconds()).padStart(2, "0");
+
+ return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
+ }
+
+ // 다른 로케일은 기존 방식 유지
return new Intl.DateTimeFormat(locale, {
month: opts.month ?? "long",
day: opts.day ?? "numeric",
@@ -46,7 +82,7 @@ export function formatDateTime(
second: opts.second ?? "2-digit",
hour12: opts.hour12 ?? false,
...opts,
- }).format(new Date(date))
+ }).format(dateObj);
}
export function toSentenceCase(str: string) {
@@ -78,3 +114,39 @@ export function composeEventHandlers<E>(
}
}
}
+
+
+/**
+ * 바이트 단위의 파일 크기를 사람이 읽기 쉬운 형식으로 변환합니다.
+ * (예: 1024 -> "1 KB", 1536 -> "1.5 KB")
+ *
+ * @param bytes 변환할 바이트 크기
+ * @param decimals 소수점 자릿수 (기본값: 1)
+ * @returns 포맷된 파일 크기 문자열
+ */
+export const formatFileSize = (bytes: number, decimals: number = 1): string => {
+ if (bytes === 0) return '0 Bytes';
+
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
+
+ // 로그 계산으로 적절한 단위 찾기
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ // 단위에 맞게 값 계산 (소수점 반올림)
+ const value = parseFloat((bytes / Math.pow(k, i)).toFixed(decimals));
+
+ return `${value} ${sizes[i]}`;
+};
+export function formatCurrency(
+ value: number,
+ currency: string | null | undefined = "KRW",
+ locale: string = "ko-KR"
+): string {
+ return new Intl.NumberFormat(locale, {
+ style: "currency",
+ currency: currency ?? "KRW", // null이나 undefined면 "KRW" 사용
+ // minimumFractionDigits: 0,
+ // maximumFractionDigits: 2,
+ }).format(value)
+} \ No newline at end of file