From 9eb8e80f4f736c4edffa650c685d1f170ca51aa1 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 15 May 2025 01:19:49 +0000 Subject: (대표님) 구매 요청사항 반영한 통합 rfq / 필터 개인화 / po-rfq MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/[lng]/evcp/(evcp)/po-rfq/page.tsx | 86 +++ .../partners/(partners)/rfq-all/[id]/page.tsx | 80 +++ app/[lng]/partners/(partners)/rfq-all/page.tsx | 171 ++++++ .../[rfqId]/vendors/[vendorId]/comments/route.ts | 145 +++++ app/api/table-presets/[id]/route.ts | 57 ++ app/api/table-presets/route.ts | 98 +++ components/data-table/data-table-preset.tsx | 373 ++++++++++++ components/data-table/use-table-presets.tsx | 338 +++++++++++ .../po-rfq/detail-table/add-vendor-dialog.tsx | 512 ++++++++++++++++ .../po-rfq/detail-table/delete-vendor-dialog.tsx | 150 +++++ .../po-rfq/detail-table/rfq-detail-column.tsx | 369 ++++++++++++ .../po-rfq/detail-table/rfq-detail-table.tsx | 519 ++++++++++++++++ .../po-rfq/detail-table/update-vendor-sheet.tsx | 449 ++++++++++++++ .../detail-table/vendor-communication-drawer.tsx | 518 ++++++++++++++++ .../vendor-quotation-comparison-dialog.tsx | 665 +++++++++++++++++++++ components/po-rfq/po-rfq-container.tsx | 261 ++++++++ components/po-rfq/rfq-filter-sheet.tsx | 643 ++++++++++++++++++++ hooks/use-local-storage.ts | 48 ++ hooks/useAutoSizeColumns.ts | 40 +- lib/procurement-rfqs/services.ts | 7 +- lib/procurement-rfqs/table/rfq-table copy.tsx | 209 +++++++ lib/procurement-rfqs/table/rfq-table.tsx | 258 ++++++-- .../table/vendor-quotations-table-columns.tsx | 282 ++++++--- .../table/vendor-quotations-table.tsx | 31 +- lib/utils.ts | 84 ++- 25 files changed, 6224 insertions(+), 169 deletions(-) create mode 100644 app/[lng]/evcp/(evcp)/po-rfq/page.tsx create mode 100644 app/[lng]/partners/(partners)/rfq-all/[id]/page.tsx create mode 100644 app/[lng]/partners/(partners)/rfq-all/page.tsx create mode 100644 app/api/procurement-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts create mode 100644 app/api/table-presets/[id]/route.ts create mode 100644 app/api/table-presets/route.ts create mode 100644 components/data-table/data-table-preset.tsx create mode 100644 components/data-table/use-table-presets.tsx create mode 100644 components/po-rfq/detail-table/add-vendor-dialog.tsx create mode 100644 components/po-rfq/detail-table/delete-vendor-dialog.tsx create mode 100644 components/po-rfq/detail-table/rfq-detail-column.tsx create mode 100644 components/po-rfq/detail-table/rfq-detail-table.tsx create mode 100644 components/po-rfq/detail-table/update-vendor-sheet.tsx create mode 100644 components/po-rfq/detail-table/vendor-communication-drawer.tsx create mode 100644 components/po-rfq/detail-table/vendor-quotation-comparison-dialog.tsx create mode 100644 components/po-rfq/po-rfq-container.tsx create mode 100644 components/po-rfq/rfq-filter-sheet.tsx create mode 100644 hooks/use-local-storage.ts create mode 100644 lib/procurement-rfqs/table/rfq-table copy.tsx 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 ( + +
+
+
+

+ {title} +

+
+
+
+ + + } + > + + +
+ ) +} \ 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 { + 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 ( +
+
+

로그인이 필요합니다

+

견적서 응답을 위해 로그인해주세요.

+
+
+ ) + } + + // 견적서 정보 가져오기 + 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 ( +
+
+

접근 권한이 없습니다

+

이 견적서에 대한 권한이 없습니다.

+
+
+ ) + } + + return ( +
+ +
+ ) +} \ 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 +} + + +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 ( + +
+
+

+ 견적 목록 +

+

+ 진행 중인 견적서 목록을 확인하고 관리합니다. +

+
+
+ +
+
+

로그인이 필요합니다

+

+ 견적서를 확인하려면 먼저 로그인하세요. +

+ +
+
+
+ ); + } + + // 벤더 ID 확인 + const vendorId = session.user.companyId ? String(session.user.companyId) : "0"; + + // 벤더 권한 확인 + if (session.user.domain !== "partners") { + return ( + +
+
+

+ 접근 권한 없음 +

+
+
+
+
+

벤더 계정이 필요합니다

+

+ 벤더 계정으로 로그인해주세요. +

+
+
+
+ ); + } + + // 데이터 가져오기 + const quotationsPromise = getVendorQuotations({ + ...search, + filters: validFilters + }, vendorId); + + // 상태별 개수 가져오기 + const statusCountsPromise = getQuotationStatusCounts(vendorId); + + // 모든 프로미스 병렬 실행 + const promises = Promise.all([quotationsPromise]); + const statusCounts = await statusCountsPromise; + + return ( + +
+
+

견적 목록

+

+ 진행 중인 견적서 목록을 확인하고 관리합니다. +

+
+
+ +
+ + + 전체 견적 + + +
+ {Object.values(statusCounts).reduce((sum, count) => sum + count, 0)}건 +
+
+
+ + + 작성 중 + + +
{statusCounts.Draft || 0}건
+
+
+ + + 제출됨 + + +
+ {(statusCounts.Submitted || 0) + (statusCounts.Revised || 0)}건 +
+
+
+ + + 승인됨 + + +
{statusCounts.Accepted || 0}건
+
+
+
+ + + } + > + + +
+ ); +} \ 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 { + presets: TablePreset[] + activePresetId: string | null + currentSettings: TableSettings + hasUnsavedChanges: boolean + isLoading: boolean + onCreatePreset: (name: string, settings: TableSettings, isDefault: boolean) => Promise + onUpdatePreset: (presetId: string, settings: TableSettings) => Promise + onDeletePreset: (presetId: string) => Promise + onApplyPreset: (presetId: string) => Promise + onSetDefaultPreset: (presetId: string) => Promise + onRenamePreset: (presetId: string, newName: string) => Promise +} + +export function TablePresetManager({ + presets, + activePresetId, + currentSettings, + hasUnsavedChanges, + isLoading, + onCreatePreset, + onUpdatePreset, + onDeletePreset, + onApplyPreset, + onSetDefaultPreset, + onRenamePreset, +}: TablePresetManagerProps) { + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false) + const [newPresetName, setNewPresetName] = useState('') + const [isDefaultPreset, setIsDefaultPreset] = useState(false) + const [renamingPresetId, setRenamingPresetId] = useState(null) + const [processingPresetId, setProcessingPresetId] = useState(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 ( + + ) + } + + return ( + <> + + + + + + + + 테이블 맞춤 설정 + + + + {/* 활성 맞춤 설정 표시 */} + {activePreset && ( + <> +
+
현재 활성
+
+ {activePreset.name} +
+ {activePreset.isDefault && } + {hasUnsavedChanges && 수정됨} +
+
+
+ + + )} + + {/* 빠른 액션 */} + setIsCreateDialogOpen(true)}> + + 현재 설정으로 새 맞춤 설정 만들기 + + + {activePresetId && ( + + + {hasUnsavedChanges ? '변경 내용 저장' : '현재 설정 업데이트'} + + )} + + + + {/* 맞춤 설정 목록 */} + + 저장된 맞춤 설정 + + + {presets.length === 0 ? ( +
+ 저장된 프리셋이 없습니다 +
+ ) : ( + presets.map((preset) => ( + + +
+ {preset.id === activePresetId ? ( + + ) : ( +
+ )} + + {preset.name} + + {preset.isDefault && } +
+ + + handleApplyPreset(preset.id)}> + {processingPresetId === preset.id ? ( + + ) : ( + + )} + 적용 + + openRenameDialog(preset)}> + + 이름 변경 + + onSetDefaultPreset(preset.id)}> + + {preset.isDefault ? '기본 해제' : '기본으로 설정'} + + + {presets.length > 1 && ( + onDeletePreset(preset.id)} + className="text-red-600 focus:text-red-600" + > + + 삭제 + + )} + + + )) + )} + + + + {/* Create Dialog */} + + + + 새 맞춤 설정 저장 + + 현재 테이블 설정을 새로운 프리셋으로 저장합니다. + + +
+
+ +
+ setNewPresetName(e.target.value)} + placeholder="맞춤 설정 이름을 입력하세요" + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleCreatePreset() + } + }} + /> +
+
+
+ +
+
+ setIsDefaultPreset(checked as boolean)} + /> + +
+

+ 기본 프리셋은 테이블 로드 시 자동으로 적용됩니다. +

+
+
+
+ + + + +
+
+ + {/* Rename Dialog */} + + + + 맞춤 설정 이름 변경 + + '{presets.find(p => p.id === renamingPresetId)?.name}' 프리셋의 이름을 변경합니다. + + +
+
+ +
+ setNewPresetName(e.target.value)} + placeholder="새 맞춤 설정 이름을 입력하세요" + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleRenamePreset() + } + }} + /> +
+
+
+ + + + +
+
+ + ) +} \ 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( + tableId: string, + initialSettings: TableSettings +) { + 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( + 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 => { + // 서버 렌더링 중이면 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, + 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 + ) => { + 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>) => { + 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 }) => ( + + {children} * + +); + +// 폼 유효성 검증 스키마 +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 + +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([]) + const [isSubmitting, setIsSubmitting] = useState(false) + + // 벤더 선택을 위한 팝오버 상태 + const [vendorOpen, setVendorOpen] = useState(false) + + const form = useForm({ + 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) => { + 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 ( + + {/* 커스텀 DialogContent - 고정 헤더, 스크롤 가능한 콘텐츠, 고정 푸터 */} + + {/* 고정 헤더 */} +
+ + 벤더 추가 + + {selectedRfq ? ( + <> + {selectedRfq.rfqCode} RFQ에 벤더를 추가합니다. + + ) : ( + "RFQ에 벤더를 추가합니다." + )} + + +
+ + {/* 스크롤 가능한 콘텐츠 영역 */} +
+
+ + {/* 검색 가능한 벤더 선택 필드 */} + ( + + 벤더 + + + + + + + + + + 검색 결과가 없습니다 + + + + {availableVendors.length > 0 ? ( + availableVendors.map((vendor) => ( + { + form.setValue("vendorId", String(vendor.id), { + shouldValidate: true, + }) + setVendorOpen(false) + }} + > + + {vendor.vendorName} ({vendor.vendorCode}) + + )) + ) : ( + 추가 가능한 벤더가 없습니다 + )} + + + + + + + + + )} + /> + + ( + + 통화 + + + + )} + /> + +
+ ( + + 지불 조건 + + + + )} + /> + + ( + + 인코텀즈 + + + + )} + /> +
+ + {/* 나머지 필드들은 동일하게 유지 */} + ( + + 인코텀즈 세부사항 + + + + + + )} + /> + +
+ ( + + 납품 예정일 + + + + + + )} + /> + + ( + + 세금 코드 + + + + + + )} + /> +
+ +
+ ( + + 선적지 + + + + + + )} + /> + + ( + + 도착지 + + + + + + )} + /> +
+ + ( + + + + +
+ 자재 가격 관련 여부 +
+
+ )} + /> + + {/* 파일 업로드 섹션 */} +
+ +
+
+ +
+ + {/* 업로드된 파일 목록 */} + {attachments.length > 0 && ( +
+

업로드된 파일

+
    + {attachments.map((file, index) => ( +
  • +
    + + {file.name} + + ({(file.size / 1024).toFixed(1)} KB) + +
    + +
  • + ))} +
+
+ )} +
+
+ + +
+ + {/* 고정 푸터 */} +
+ + + + +
+
+
+ ) +} \ 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 { + 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 ( + + {showTrigger ? ( + + + + ) : null} + + + 정말로 삭제하시겠습니까? + + 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다. + + + + + + + + + + + ) + } + + return ( + + {showTrigger ? ( + + + + ) : null} + + + 정말로 삭제하시겠습니까? + + 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다. + + + + + + + + + + + ) +} \ 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 { + row: Row; + 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 { + setRowAction: React.Dispatch< + React.SetStateAction | null> + >; + unreadMessages?: Record; // 벤더 ID별 읽지 않은 메시지 수 +} + +export function getRfqDetailColumns({ + setRowAction, + unreadMessages = {}, +}: GetColumnsProps): ColumnDef[] { + return [ + { + accessorKey: "quotationStatus", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("quotationStatus")}
, + meta: { + excelHeader: "견적 상태" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "quotationVersion", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("quotationVersion")}
, + meta: { + excelHeader: "견적 버전" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "vendorCode", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("vendorCode")}
, + meta: { + excelHeader: "벤더 코드" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "vendorName", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("vendorName")}
, + meta: { + excelHeader: "벤더명" + }, + enableResizing: true, + size: 160, + }, + { + accessorKey: "vendorType", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.original.vendorCountry === "KR"?"D":"F"}
, + meta: { + excelHeader: "내외자" + }, + enableResizing: true, + size: 80, + }, + { + accessorKey: "currency", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("currency")}
, + meta: { + excelHeader: "통화" + }, + enableResizing: true, + size: 80, + }, + { + accessorKey: "paymentTermsCode", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("paymentTermsCode")}
, + meta: { + excelHeader: "지불 조건 코드" + }, + enableResizing: true, + size: 140, + }, + { + accessorKey: "paymentTermsDescription", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("paymentTermsDescription")}
, + meta: { + excelHeader: "지불 조건" + }, + enableResizing: true, + size: 160, + }, + { + accessorKey: "incotermsCode", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("incotermsCode")}
, + meta: { + excelHeader: "인코텀스 코드" + }, + enableResizing: true, + size: 140, + }, + { + accessorKey: "incotermsDescription", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("incotermsDescription")}
, + meta: { + excelHeader: "인코텀스" + }, + enableResizing: true, + size: 160, + }, + { + accessorKey: "incotermsDetail", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("incotermsDetail")}
, + meta: { + excelHeader: "인코텀스 상세" + }, + enableResizing: true, + size: 160, + }, + { + accessorKey: "deliveryDate", + header: ({ column }) => ( + + ), + cell: ({ cell }) => { + const value = cell.getValue(); + return value ? formatDate(value as Date, "KR") : ""; + }, + meta: { + excelHeader: "납품일" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "taxCode", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("taxCode")}
, + meta: { + excelHeader: "세금 코드" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "placeOfShipping", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("placeOfShipping")}
, + meta: { + excelHeader: "선적지" + }, + enableResizing: true, + size: 140, + }, + { + accessorKey: "placeOfDestination", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("placeOfDestination")}
, + meta: { + excelHeader: "도착지" + }, + enableResizing: true, + size: 140, + }, + { + accessorKey: "materialPriceRelatedYn", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("materialPriceRelatedYn") ? "Y" : "N"}
, + meta: { + excelHeader: "원자재 가격 연동" + }, + enableResizing: true, + size: 140, + }, + { + accessorKey: "updatedByUserName", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("updatedByUserName")}
, + meta: { + excelHeader: "수정자" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => ( + + ), + cell: ({ cell }) => { + const value = cell.getValue(); + return value ? formatDateTime(value as Date, "KR") : ""; + }, + meta: { + excelHeader: "수정일시" + }, + enableResizing: true, + size: 140, + }, + // 커뮤니케이션 컬럼 추가 + { + id: "communication", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const vendorId = row.original.vendorId || 0; + const unreadCount = unreadMessages[vendorId] || 0; + + return ( + + ); + }, + enableResizing: false, + size: 80, + }, + { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + + + + + + setRowAction({ row, type: "update" })} + > + Edit + + + setRowAction({ row, type: "delete" })} + > + Delete + ⌘⌫ + + + + ) + }, + 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([]) + const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false) + const [updateSheetOpen, setUpdateSheetOpen] = React.useState(false) + const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false) + const [selectedDetail, setSelectedDetail] = React.useState(null) + + const [vendors, setVendors] = React.useState([]) + const [currencies, setCurrencies] = React.useState([]) + const [paymentTerms, setPaymentTerms] = React.useState([]) + const [incoterms, setIncoterms] = React.useState([]) + const [isAdddialogLoading, setIsAdddialogLoading] = useState(false) + + const [rowAction, setRowAction] = React.useState | null>(null) + + // 벤더 커뮤니케이션 상태 관리 + const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false) + const [selectedVendor, setSelectedVendor] = useState(null) + + // 읽지 않은 메시지 개수 + const [unreadMessages, setUnreadMessages] = useState>({}) + 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 ( +
+ RFQ를 선택하세요 +
+ ) + } + + // 로딩 중인 경우 + if (isLoading) { + return ( +
+ + + +
+ ) + } + + 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 ( +
+ + {/* 메시지 및 새로고침 영역 */} + + + {/* 테이블 또는 빈 상태 표시 */} + {details.length > 0 ? ( + + + +
+
+ {totalUnreadMessages > 0 && ( + + 읽지 않은 메시지: {totalUnreadMessages}건 + + )} + {vendorsWithQuotations > 0 && ( + + 견적 제출: {vendorsWithQuotations}개 벤더 + + )} +
+
+ {/* 견적 비교 버튼 추가 */} + + +
+
+ +
+ + ) : ( +
+
+

RFQ에 대한 세부 정보가 없습니다

+ +
+
+ )} + + {/* 벤더 추가 다이얼로그 */} + { + setVendorDialogOpen(open); + if (!open) setIsAdddialogLoading(false); + }} + selectedRfq={selectedRfq} + vendors={vendors} + currencies={currencies} + paymentTerms={paymentTerms} + incoterms={incoterms} + onSuccess={handleRefreshData} + existingVendorIds={existingVendorIds} + /> + + {/* 벤더 정보 수정 시트 */} + + + {/* 벤더 정보 삭제 다이얼로그 */} + + + {/* 벤더 커뮤니케이션 드로어 */} + { + setCommunicationDrawerOpen(open); + // 드로어가 닫힐 때 읽지 않은 메시지 개수 갱신 + if (!open) loadUnreadMessages(); + }} + selectedRfq={selectedRfq} + selectedVendor={selectedVendor} + onSuccess={handleRefreshData} + /> + + {/* 견적 비교 다이얼로그 추가 */} + +
+ ) +} \ 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 + +// 데이터 타입 정의 +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 { + 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({ + 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 ( + + + + RFQ 벤더 정보 수정 + + 벤더 정보를 수정하고 저장하세요 + + + +
+ + {/* 검색 가능한 벤더 선택 필드 */} + ( + + 벤더 * + + + + + + + + + + 검색 결과가 없습니다 + + + {vendors.map((vendor) => ( + { + form.setValue("vendorId", String(vendor.id), { + shouldValidate: true, + }) + setVendorOpen(false) + }} + > + + {vendor.vendorName} ({vendor.vendorCode}) + + ))} + + + + + + + + )} + /> + + ( + + 통화 * + + + + )} + /> + +
+ ( + + 지불 조건 * + + + + )} + /> + + ( + + 인코텀즈 * + + + + )} + /> +
+ + ( + + 인코텀즈 세부사항 + + + + + + )} + /> + +
+ ( + + 납품 예정일 + + + + + + )} + /> + + ( + + 세금 코드 + + + + + + )} + /> +
+ +
+ ( + + 선적지 + + + + + + )} + /> + + ( + + 도착지 + + + + + + )} + /> +
+ + ( + + + + +
+ 자재 가격 관련 여부 +
+
+ )} + /> + + +
+ + + + + + +
+
+ ) +} \ 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 { + 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([]); + const [newComment, setNewComment] = useState(""); + const [attachments, setAttachments] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const fileInputRef = useRef(null); + const messagesEndRef = useRef(null); + + // 첨부파일 관련 상태 + const [previewDialogOpen, setPreviewDialogOpen] = useState(false); + const [selectedAttachment, setSelectedAttachment] = useState(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) => { + 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 ; + if (fileType.includes("pdf")) return ; + if (fileType.includes("spreadsheet") || fileType.includes("excel")) + return ; + if (fileType.includes("document") || fileType.includes("word")) + return ; + return ; + }; + + // 첨부파일 미리보기 다이얼로그 + const renderAttachmentPreviewDialog = () => { + if (!selectedAttachment) return null; + + const isImage = selectedAttachment.fileType.startsWith("image/"); + const isPdf = selectedAttachment.fileType.includes("pdf"); + + return ( + + + + + {getFileIcon(selectedAttachment.fileType)} + {selectedAttachment.fileName} + + + {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt)} + + + +
+ {isImage ? ( + {selectedAttachment.fileName} + ) : isPdf ? ( +