diff options
Diffstat (limited to 'lib/vendor-rfq-response/vendor-cbe-table')
7 files changed, 0 insertions, 1624 deletions
diff --git a/lib/vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx b/lib/vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx deleted file mode 100644 index c7be0bf4..00000000 --- a/lib/vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx +++ /dev/null @@ -1,365 +0,0 @@ -"use client" - -import * as React from "react" -import { type DataTableRowAction } from "@/types/table" -import { type ColumnDef } from "@tanstack/react-table" -import { Download, Loader2, MessageSquare, FileEdit } from "lucide-react" -import { formatDate } from "@/lib/utils" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { useRouter } from "next/navigation" -import { VendorWithCbeFields, vendorResponseCbeColumnsConfig } from "@/config/vendorCbeColumnsConfig" -import { toast } from "sonner" - - -type NextRouter = ReturnType<typeof useRouter> - -interface GetColumnsProps { - setRowAction: React.Dispatch< - React.SetStateAction<DataTableRowAction<VendorWithCbeFields> | null> - > - router: NextRouter - openCommentSheet: (vendorId: number) => void - handleDownloadCbeFiles: (vendorId: number, rfqId: number) => void - loadingVendors: Record<string, boolean> - openVendorContactsDialog: (rfqId: number, rfq: VendorWithCbeFields) => void - // New prop for handling commercial response - openCommercialResponseSheet: (responseId: number, rfq: VendorWithCbeFields) => void -} - -/** - * tanstack table 컬럼 정의 (중첩 헤더 버전) - */ -export function getColumns({ - setRowAction, - router, - openCommentSheet, - handleDownloadCbeFiles, - loadingVendors, - openVendorContactsDialog, - openCommercialResponseSheet -}: GetColumnsProps): ColumnDef<VendorWithCbeFields>[] { - // ---------------------------------------------------------------- - // 1) Select 컬럼 (체크박스) - // ---------------------------------------------------------------- - const selectColumn: ColumnDef<VendorWithCbeFields> = { - id: "select", - header: ({ table }) => ( - <Checkbox - checked={ - table.getIsAllPageRowsSelected() || - (table.getIsSomePageRowsSelected() && "indeterminate") - } - onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - className="translate-y-0.5" - /> - ), - cell: ({ row }) => ( - <Checkbox - checked={row.getIsSelected()} - onCheckedChange={(value) => row.toggleSelected(!!value)} - aria-label="Select row" - className="translate-y-0.5" - /> - ), - size: 40, - enableSorting: false, - enableHiding: false, - } - - // ---------------------------------------------------------------- - // 2) 그룹화(Nested) 컬럼 구성 - // ---------------------------------------------------------------- - const groupMap: Record<string, ColumnDef<VendorWithCbeFields>[]> = {} - - vendorResponseCbeColumnsConfig.forEach((cfg) => { - const groupName = cfg.group || "_noGroup" - if (!groupMap[groupName]) { - groupMap[groupName] = [] - } - - // childCol: ColumnDef<VendorWithCbeFields> - const childCol: ColumnDef<VendorWithCbeFields> = { - accessorKey: cfg.id, - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={cfg.label} /> - ), - meta: { - excelHeader: cfg.excelHeader, - group: cfg.group, - type: cfg.type, - }, - maxSize: 120, - // 셀 렌더링 - cell: ({ row, getValue }) => { - // 1) 필드값 가져오기 - const val = getValue() - - - if (cfg.id === "rfqCode") { - const rfq = row.original; - const rfqId = rfq.rfqId; - - // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링 - const handleVendorNameClick = () => { - if (rfqId) { - openVendorContactsDialog(rfqId, rfq); // vendor 전체 객체 전달 - } else { - toast.error("협력업체 ID를 찾을 수 없습니다."); - } - }; - - return ( - <Button - variant="link" - className="p-0 h-auto text-left font-normal justify-start hover:underline" - onClick={handleVendorNameClick} - > - {val as string} - </Button> - ); - } - - // Commercial Response Status에 배지 적용 - if (cfg.id === "commercialResponseStatus") { - const status = val as string; - - if (!status) return <span className="text-muted-foreground">-</span>; - - let variant: "default" | "outline" | "secondary" | "destructive" = "outline"; - - switch (status) { - case "SUBMITTED": - variant = "default"; // Green - break; - case "IN_PROGRESS": - variant = "secondary"; // Orange/Yellow - break; - case "PENDING": - variant = "outline"; // Gray - break; - default: - variant = "outline"; - } - - return ( - <Badge variant={variant} className="capitalize"> - {status.toLowerCase().replace("_", " ")} - </Badge> - ); - } - - // 예) TBE Updated (날짜) - if (cfg.id === "respondedAt" || cfg.id === "rfqDueDate" ) { - const dateVal = val as Date | undefined - if (!dateVal) return null - return formatDate(dateVal) - } - - // 그 외 필드는 기본 값 표시 - return val ?? "" - }, - } - - groupMap[groupName].push(childCol) - }) - - // groupMap → nestedColumns - const nestedColumns: ColumnDef<VendorWithCbeFields>[] = [] - Object.entries(groupMap).forEach(([groupName, colDefs]) => { - if (groupName === "_noGroup") { - nestedColumns.push(...colDefs) - } else { - nestedColumns.push({ - id: groupName, - header: groupName, - columns: colDefs, - }) - } - }) - - // ---------------------------------------------------------------- - // 3) Respond 컬럼 (새로 추가) - // ---------------------------------------------------------------- - const respondColumn: ColumnDef<VendorWithCbeFields> = { - id: "respond", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Response" /> - ), - cell: ({ row }) => { - const vendor = row.original - const responseId = vendor.responseId - - if (!responseId) { - return <div className="text-center text-muted-foreground">-</div> - } - - const handleClick = () => { - openCommercialResponseSheet(responseId, vendor) - } - - // Status에 따라 버튼 variant 변경 - let variant: "default" | "outline" | "ghost" | "secondary" = "default" - let buttonText = "Respond" - - if (vendor.commercialResponseStatus === "SUBMITTED") { - variant = "outline" - buttonText = "Update" - } else if (vendor.commercialResponseStatus === "IN_PROGRESS") { - variant = "secondary" - buttonText = "Continue" - } - - return ( - <Button - variant={variant} - size="sm" - // className="w-20" - onClick={handleClick} - > - <FileEdit className="h-3.5 w-3.5 mr-1" /> - {buttonText} - </Button> - ) - }, - enableSorting: false, - maxSize: 200, - minSize: 115, - } - - // ---------------------------------------------------------------- - // 4) Comments 컬럼 - // ---------------------------------------------------------------- - const commentsColumn: ColumnDef<VendorWithCbeFields> = { - id: "comments", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Comments" /> - ), - cell: ({ row }) => { - const vendor = row.original - const commCount = vendor.comments?.length ?? 0 - - function handleClick() { - // rowAction + openCommentSheet - setRowAction({ row, type: "comments" }) - openCommentSheet(vendor.responseId ?? 0) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={ - commCount > 0 ? `View ${commCount} comments` : "No comments" - } - > - <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {commCount > 0 && ( - <Badge - variant="secondary" - className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" - > - {commCount} - </Badge> - )} - <span className="sr-only"> - {commCount > 0 ? `${commCount} Comments` : "No Comments"} - </span> - </Button> - ) - }, - enableSorting: false, - maxSize: 80 - } - - // ---------------------------------------------------------------- - // 5) 파일 다운로드 컬럼 (개별 로딩 상태 적용) - // ---------------------------------------------------------------- - const downloadColumn: ColumnDef<VendorWithCbeFields> = { - id: "attachDownload", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Attach Download" /> - ), - cell: ({ row }) => { - const vendor = row.original - const vendorId = vendor.vendorId - const rfqId = vendor.rfqId - const files = vendor.files?.length || 0 - - if (!vendorId || !rfqId) { - return <div className="text-center text-muted-foreground">-</div> - } - - // 각 행별로 로딩 상태 확인 (vendorId_rfqId 형식의 키 사용) - const rowKey = `${vendorId}_${rfqId}` - const isRowLoading = loadingVendors[rowKey] === true - - // 템플릿 파일이 없으면 다운로드 버튼 비활성화 - const isDisabled = files <= 0 || isRowLoading - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={ - isDisabled - ? undefined - : () => handleDownloadCbeFiles(vendorId, rfqId) - } - aria-label={ - isRowLoading - ? "다운로드 중..." - : files > 0 - ? `CBE 첨부 다운로드 (${files}개)` - : "다운로드할 파일 없음" - } - disabled={isDisabled} - > - {isRowLoading ? ( - <Loader2 className="h-4 w-4 animate-spin" /> - ) : ( - <Download className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - )} - - {/* 파일이 1개 이상인 경우 뱃지로 개수 표시 (로딩 중이 아닐 때만) */} - {!isRowLoading && files > 0 && ( - <Badge - variant="secondary" - className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" - > - {files} - </Badge> - )} - - <span className="sr-only"> - {isRowLoading - ? "다운로드 중..." - : files > 0 - ? `CBE 첨부 다운로드 (${files}개)` - : "다운로드할 파일 없음"} - </span> - </Button> - ) - }, - enableSorting: false, - maxSize: 80, - } - - // ---------------------------------------------------------------- - // 6) 최종 컬럼 배열 (respondColumn 추가) - // ---------------------------------------------------------------- - return [ - selectColumn, - ...nestedColumns, - respondColumn, // 응답 컬럼 추가 - downloadColumn, - commentsColumn, - ] -}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-cbe-table/cbe-table.tsx b/lib/vendor-rfq-response/vendor-cbe-table/cbe-table.tsx deleted file mode 100644 index 8477f550..00000000 --- a/lib/vendor-rfq-response/vendor-cbe-table/cbe-table.tsx +++ /dev/null @@ -1,272 +0,0 @@ -"use client" - -import * as React from "react" -import { useRouter } from "next/navigation" -import type { - DataTableAdvancedFilterField, - DataTableFilterField, - DataTableRowAction, -} from "@/types/table" - -import { useDataTable } from "@/hooks/use-data-table" -import { DataTable } from "@/components/data-table/data-table" -import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" -import { getColumns } from "./cbe-table-columns" -import { - fetchRfqAttachmentsbyCommentId, - getCBEbyVendorId, - getFileFromRfqAttachmentsbyid, - fetchCbeFiles -} from "../../rfqs/service" -import { useSession } from "next-auth/react" -import { CbeComment, CommentSheet } from "./comments-sheet" -import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" -import { toast } from "sonner" -import { RfqDeailDialog } from "./rfq-detail-dialog" -import { CommercialResponseSheet } from "./respond-cbe-sheet" - -interface VendorsTableProps { - promises: Promise< - [ - Awaited<ReturnType<typeof getCBEbyVendorId>>, - ] - > -} - -export function CbeVendorTable({ promises }: VendorsTableProps) { - const { data: session } = useSession() - const userVendorId = session?.user?.companyId - const userId = Number(session?.user?.id) - // Suspense로 받아온 데이터 - const [{ data, pageCount }] = React.use(promises) - const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithCbeFields> | null>(null) - const [selectedCbeId, setSelectedCbeId] = React.useState<number | null>(null) - - // 개별 협력업체별 로딩 상태를 관리하는 맵 - const [loadingVendors, setLoadingVendors] = React.useState<Record<string, boolean>>({}) - - const router = useRouter() - - // 코멘트 관련 상태 - const [initialComments, setInitialComments] = React.useState<CbeComment[]>([]) - const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) - const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) - - // 상업 응답 관련 상태 - const [commercialResponseSheetOpen, setCommercialResponseSheetOpen] = React.useState(false) - const [selectedResponseId, setSelectedResponseId] = React.useState<number | null>(null) - const [selectedRfq, setSelectedRfq] = React.useState<VendorWithCbeFields | null>(null) - - // RFQ 상세 관련 상태 - const [rfqDetailDialogOpen, setRfqDetailDialogOpen] = React.useState(false) - const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null) - const [selectedRfqDetail, setSelectedRfqDetail] = React.useState<VendorWithCbeFields | null>(null) - - React.useEffect(() => { - if (rowAction?.type === "comments") { - // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행 - openCommentSheet(Number(rowAction.row.original.responseId)) - } - }, [rowAction]) - - async function openCommentSheet(responseId: number) { - setInitialComments([]) - - const comments = rowAction?.row.original.comments - const rfqId = rowAction?.row.original.rfqId - - if (comments && comments.length > 0) { - const commentWithAttachments: CbeComment[] = await Promise.all( - comments.map(async (c) => { - // 서버 액션을 사용하여 코멘트 첨부 파일 가져오기 - const attachments = await fetchRfqAttachmentsbyCommentId(c.id) - - return { - ...c, - commentedBy: userId, // DB나 API 응답에 있다고 가정 - attachments, - } - }) - ) - - setInitialComments(commentWithAttachments) - } - - if(rfqId) { - setSelectedRfqIdForComments(rfqId) - } - setSelectedCbeId(responseId) - setCommentSheetOpen(true) - } - - // 상업 응답 시트 열기 - function openCommercialResponseSheet(responseId: number, rfq: VendorWithCbeFields) { - setSelectedResponseId(responseId) - setSelectedRfq(rfq) - setCommercialResponseSheetOpen(true) - } - - // RFQ 상세 대화상자 열기 - function openRfqDetailDialog(rfqId: number, rfq: VendorWithCbeFields) { - setSelectedRfqId(rfqId) - setSelectedRfqDetail(rfq) - setRfqDetailDialogOpen(true) - } - - const handleDownloadCbeFiles = React.useCallback( - async (vendorId: number, rfqId: number) => { - // 고유 키 생성: vendorId_rfqId - const rowKey = `${vendorId}_${rfqId}` - - // 해당 협력업체의 로딩 상태만 true로 설정 - setLoadingVendors(prev => ({ - ...prev, - [rowKey]: true - })) - - try { - const { files, error } = await fetchCbeFiles(vendorId, rfqId); - if (error) { - toast.error(error); - return; - } - if (files.length === 0) { - toast.warning("다운로드할 CBE 파일이 없습니다"); - return; - } - // 순차적으로 파일 다운로드 - for (const file of files) { - await downloadFile(file.id); - } - toast.success(`${files.length}개의 CBE 파일이 다운로드되었습니다`); - } catch (error) { - toast.error("CBE 파일을 다운로드하는 데 실패했습니다"); - console.error(error); - } finally { - // 해당 협력업체의 로딩 상태만 false로 되돌림 - setLoadingVendors(prev => ({ - ...prev, - [rowKey]: false - })) - } - }, - [] - ); - - const downloadFile = React.useCallback(async (fileId: number) => { - try { - const { file, error } = await getFileFromRfqAttachmentsbyid(fileId); - if (error || !file) { - throw new Error(error || "파일 정보를 가져오는 데 실패했습니다"); - } - - const link = document.createElement("a"); - link.href = `/api/rfq-download?path=${encodeURIComponent(file.filePath)}`; - link.download = file.fileName; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - return true; - } catch (error) { - console.error(error); - return false; - } - }, []); - - // 응답 성공 후 데이터 갱신 - const handleResponseSuccess = React.useCallback(() => { - // 필요한 경우 데이터 다시 가져오기 - router.refresh() - }, [router]); - - // getColumns() 호출 시 필요한 핸들러들 주입 - const columns = React.useMemo( - () => getColumns({ - setRowAction, - router, - openCommentSheet, - handleDownloadCbeFiles, - loadingVendors, - openVendorContactsDialog: openRfqDetailDialog, - openCommercialResponseSheet, - }), - [ - setRowAction, - router, - openCommentSheet, - handleDownloadCbeFiles, - loadingVendors, - openRfqDetailDialog, - openCommercialResponseSheet - ] - ); - - // 필터 필드 정의 - const filterFields: DataTableFilterField<VendorWithCbeFields>[] = [] - const advancedFilterFields: DataTableAdvancedFilterField<VendorWithCbeFields>[] = [ - - ] - - const { table } = useDataTable({ - data, - columns, - pageCount, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: "respondedAt", desc: true }], - columnPinning: { right: ["respond", "comments"] }, // respond 컬럼을 오른쪽에 고정 - }, - getRowId: (originalRow) => String(originalRow.responseId), - shallow: false, - clearOnDefault: true, - }) - - return ( - <> - <DataTable table={table}> - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - /> - </DataTable> - - {/* 코멘트 시트 */} - {commentSheetOpen && selectedRfqIdForComments && selectedCbeId !== null && ( - <CommentSheet - open={commentSheetOpen} - onOpenChange={setCommentSheetOpen} - rfqId={selectedRfqIdForComments} - initialComments={initialComments} - vendorId={userVendorId || 0} - currentUserId={userId || 0} - cbeId={selectedCbeId} - /> - )} - - {/* 상업 응답 시트 */} - {commercialResponseSheetOpen && selectedResponseId !== null && selectedRfq && ( - <CommercialResponseSheet - open={commercialResponseSheetOpen} - onOpenChange={setCommercialResponseSheetOpen} - responseId={selectedResponseId} - rfq={selectedRfq} - onSuccess={handleResponseSuccess} - /> - )} - - {/* RFQ 상세 대화상자 */} - {rfqDetailDialogOpen && selectedRfqId !== null && ( - <RfqDeailDialog - isOpen={rfqDetailDialogOpen} - onOpenChange={setRfqDetailDialogOpen} - rfqId={selectedRfqId} - rfq={selectedRfqDetail} - /> - )} - </> - ) -}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx b/lib/vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx deleted file mode 100644 index 40d38145..00000000 --- a/lib/vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx +++ /dev/null @@ -1,323 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm, useFieldArray } from "react-hook-form" -import { z } from "zod" -import { zodResolver } from "@hookform/resolvers/zod" -import { Download, X, Loader2 } from "lucide-react" -import prettyBytes from "pretty-bytes" -import { toast } from "sonner" - -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Textarea } from "@/components/ui/textarea" -import { - Dropzone, - DropzoneZone, - DropzoneUploadIcon, - DropzoneTitle, - DropzoneDescription, - DropzoneInput, -} from "@/components/ui/dropzone" -import { - Table, - TableHeader, - TableRow, - TableHead, - TableBody, - TableCell, -} from "@/components/ui/table" - -import { formatDate } from "@/lib/utils" -import { createRfqCommentWithAttachments } from "@/lib/rfqs/service" - - -export interface CbeComment { - id: number - commentText: string - commentedBy?: number - commentedByEmail?: string - createdAt?: Date - attachments?: { - id: number - fileName: string - filePath: string - }[] -} - -// 1) props 정의 -interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { - initialComments?: CbeComment[] - currentUserId: number - rfqId: number - tbeId?: number - cbeId?: number - vendorId: number - onCommentsUpdated?: (comments: CbeComment[]) => void - isLoading?: boolean // New prop -} - -// 2) 폼 스키마 -const commentFormSchema = z.object({ - commentText: z.string().min(1, "댓글을 입력하세요."), - newFiles: z.array(z.any()).optional(), // File[] -}) -type CommentFormValues = z.infer<typeof commentFormSchema> - -const MAX_FILE_SIZE = 30e6 // 30MB - -export function CommentSheet({ - rfqId, - vendorId, - initialComments = [], - currentUserId, - tbeId, - cbeId, - onCommentsUpdated, - isLoading = false, // Default to false - ...props -}: CommentSheetProps) { - - - const [comments, setComments] = React.useState<CbeComment[]>(initialComments) - const [isPending, startTransition] = React.useTransition() - - React.useEffect(() => { - setComments(initialComments) - }, [initialComments]) - - const form = useForm<CommentFormValues>({ - resolver: zodResolver(commentFormSchema), - defaultValues: { - commentText: "", - newFiles: [], - }, - }) - - const { fields: newFileFields, append, remove } = useFieldArray({ - control: form.control, - name: "newFiles", - }) - - // (A) 기존 코멘트 렌더링 - function renderExistingComments() { - - if (isLoading) { - return ( - <div className="flex justify-center items-center h-32"> - <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> - <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span> - </div> - ) - } - - if (comments.length === 0) { - return <p className="text-sm text-muted-foreground">No comments yet</p> - } - return ( - <Table> - <TableHeader> - <TableRow> - <TableHead className="w-1/2">Comment</TableHead> - <TableHead>Attachments</TableHead> - <TableHead>Created At</TableHead> - <TableHead>Created By</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {comments.map((c) => ( - <TableRow key={c.id}> - <TableCell>{c.commentText}</TableCell> - <TableCell> - {!c.attachments?.length && ( - <span className="text-sm text-muted-foreground">No files</span> - )} - {c.attachments?.length && ( - <div className="flex flex-col gap-1"> - {c.attachments.map((att) => ( - <div key={att.id} className="flex items-center gap-2"> - <a - href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`} - download - target="_blank" - rel="noreferrer" - className="inline-flex items-center gap-1 text-blue-600 underline" - > - <Download className="h-4 w-4" /> - {att.fileName} - </a> - </div> - ))} - </div> - )} - </TableCell> - <TableCell> {c.createdAt ? formatDate(c.createdAt) : "-"}</TableCell> - <TableCell>{c.commentedByEmail ?? "-"}</TableCell> - </TableRow> - ))} - </TableBody> - </Table> - ) - } - - // (B) 파일 드롭 - function handleDropAccepted(files: File[]) { - append(files) - } - - // (C) Submit - async function onSubmit(data: CommentFormValues) { - if (!rfqId) return - startTransition(async () => { - try { - const res = await createRfqCommentWithAttachments({ - rfqId, - vendorId, - commentText: data.commentText, - commentedBy: currentUserId, - evaluationId: null, - cbeId: cbeId, - files: data.newFiles, - }) - - if (!res.ok) { - throw new Error("Failed to create comment") - } - - toast.success("Comment created") - - // 임시로 새 코멘트 추가 - const newComment: CbeComment = { - id: res.commentId, // 서버 응답 - commentText: data.commentText, - commentedBy: currentUserId, - createdAt: res.createdAt, - attachments: - data.newFiles?.map((f) => ({ - id: Math.floor(Math.random() * 1e6), - fileName: f.name, - filePath: "/uploads/" + f.name, - })) || [], - } - setComments((prev) => [...prev, newComment]) - onCommentsUpdated?.([...comments, newComment]) - - form.reset() - } catch (err: any) { - console.error(err) - toast.error("Error: " + err.message) - } - }) - } - - return ( - <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-lg"> - <SheetHeader className="text-left"> - <SheetTitle>Comments</SheetTitle> - <SheetDescription> - 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다. - </SheetDescription> - </SheetHeader> - - <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> - <FormField - control={form.control} - name="commentText" - render={({ field }) => ( - <FormItem> - <FormLabel>New Comment</FormLabel> - <FormControl> - <Textarea placeholder="Enter your comment..." {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <Dropzone - maxSize={MAX_FILE_SIZE} - onDropAccepted={handleDropAccepted} - onDropRejected={(rej) => { - toast.error("File rejected: " + (rej[0]?.file?.name || "")) - }} - > - {({ maxSize }) => ( - <DropzoneZone className="flex justify-center"> - <DropzoneInput /> - <div className="flex items-center gap-6"> - <DropzoneUploadIcon /> - <div className="grid gap-0.5"> - <DropzoneTitle>Drop to attach files</DropzoneTitle> - <DropzoneDescription> - Max size: {prettyBytes(maxSize || 0)} - </DropzoneDescription> - </div> - </div> - </DropzoneZone> - )} - </Dropzone> - - {newFileFields.length > 0 && ( - <div className="flex flex-col gap-2"> - {newFileFields.map((field, idx) => { - const file = form.getValues(`newFiles.${idx}`) - if (!file) return null - return ( - <div - key={field.id} - className="flex items-center justify-between border rounded p-2" - > - <span className="text-sm"> - {file.name} ({prettyBytes(file.size)}) - </span> - <Button - variant="ghost" - size="icon" - type="button" - onClick={() => remove(idx)} - > - <X className="h-4 w-4" /> - </Button> - </div> - ) - })} - </div> - )} - - <SheetFooter className="gap-2 pt-4"> - <SheetClose asChild> - <Button type="button" variant="outline"> - Cancel - </Button> - </SheetClose> - <Button disabled={isPending}> - {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - Save - </Button> - </SheetFooter> - </form> - </Form> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx b/lib/vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx deleted file mode 100644 index 8cc4fa6f..00000000 --- a/lib/vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx +++ /dev/null @@ -1,427 +0,0 @@ -"use client" - -import * as React from "react" -import { zodResolver } from "@hookform/resolvers/zod" -import { Loader } from "lucide-react" -import { useForm } from "react-hook-form" -import { toast } from "sonner" -import { z } from "zod" - -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { Input } from "@/components/ui/input" -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { Textarea } from "@/components/ui/textarea" -import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" -import { getCommercialResponseByResponseId, updateCommercialResponse } from "../service" - -// Define schema for form validation (client-side) -const commercialResponseFormSchema = z.object({ - responseStatus: z.enum(["PENDING", "IN_PROGRESS", "SUBMITTED", "REJECTED", "ACCEPTED"]), - totalPrice: z.coerce.number().optional(), - currency: z.string().default("USD"), - paymentTerms: z.string().optional(), - incoterms: z.string().optional(), - deliveryPeriod: z.string().optional(), - warrantyPeriod: z.string().optional(), - validityPeriod: z.string().optional(), - priceBreakdown: z.string().optional(), - commercialNotes: z.string().optional(), -}) - -type CommercialResponseFormInput = z.infer<typeof commercialResponseFormSchema> - -interface CommercialResponseSheetProps - extends React.ComponentPropsWithRef<typeof Sheet> { - rfq: VendorWithCbeFields | null - responseId: number | null // This is the vendor_responses.id - onSuccess?: () => void -} - -export function CommercialResponseSheet({ - rfq, - responseId, - onSuccess, - ...props -}: CommercialResponseSheetProps) { - const [isSubmitting, startSubmitTransition] = React.useTransition() - const [isLoading, setIsLoading] = React.useState(true) - - const form = useForm<CommercialResponseFormInput>({ - resolver: zodResolver(commercialResponseFormSchema), - defaultValues: { - responseStatus: "PENDING", - totalPrice: undefined, - currency: "USD", - paymentTerms: "", - incoterms: "", - deliveryPeriod: "", - warrantyPeriod: "", - validityPeriod: "", - priceBreakdown: "", - commercialNotes: "", - }, - }) - - // Load existing commercial response data when sheet opens - React.useEffect(() => { - async function loadCommercialResponse() { - if (!responseId) return - - setIsLoading(true) - try { - // Use the helper function to get existing data - const existingResponse = await getCommercialResponseByResponseId(responseId) - - if (existingResponse) { - // If we found existing data, populate the form - form.reset({ - responseStatus: existingResponse.responseStatus, - totalPrice: existingResponse.totalPrice, - currency: existingResponse.currency || "USD", - paymentTerms: existingResponse.paymentTerms || "", - incoterms: existingResponse.incoterms || "", - deliveryPeriod: existingResponse.deliveryPeriod || "", - warrantyPeriod: existingResponse.warrantyPeriod || "", - validityPeriod: existingResponse.validityPeriod || "", - priceBreakdown: existingResponse.priceBreakdown || "", - commercialNotes: existingResponse.commercialNotes || "", - }) - } else if (rfq) { - // If no existing data but we have rfq data with some values already - form.reset({ - responseStatus: rfq.commercialResponseStatus as any || "PENDING", - totalPrice: rfq.totalPrice || undefined, - currency: rfq.currency || "USD", - paymentTerms: rfq.paymentTerms || "", - incoterms: rfq.incoterms || "", - deliveryPeriod: rfq.deliveryPeriod || "", - warrantyPeriod: rfq.warrantyPeriod || "", - validityPeriod: rfq.validityPeriod || "", - priceBreakdown: "", - commercialNotes: "", - }) - } - } catch (error) { - console.error("Failed to load commercial response data:", error) - toast.error("상업 응답 데이터를 불러오는데 실패했습니다") - } finally { - setIsLoading(false) - } - } - - loadCommercialResponse() - }, [responseId, rfq, form]) - - function onSubmit(formData: CommercialResponseFormInput) { - if (!responseId) { - toast.error("응답 ID를 찾을 수 없습니다") - return - } - - if (!rfq?.vendorId) { - toast.error("협력업체 ID를 찾을 수 없습니다") - return - } - - startSubmitTransition(async () => { - try { - // Pass both responseId and vendorId to the server action - const result = await updateCommercialResponse({ - responseId, - vendorId: rfq.vendorId, // Include vendorId for revalidateTag - ...formData, - }) - - if (!result.success) { - toast.error(result.error || "응답 제출 중 오류가 발생했습니다") - return - } - - toast.success("Commercial response successfully submitted") - props.onOpenChange?.(false) - - if (onSuccess) { - onSuccess() - } - } catch (error) { - console.error("Error submitting response:", error) - toast.error("응답 제출 중 오류가 발생했습니다") - } - }) - } - - return ( - <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-md"> - <SheetHeader className="text-left"> - <SheetTitle>Commercial Response</SheetTitle> - <SheetDescription> - {rfq?.rfqCode && <span className="font-medium">{rfq.rfqCode}</span>} - <div className="mt-1">Please provide your commercial response for this RFQ</div> - </SheetDescription> - </SheetHeader> - - {isLoading ? ( - <div className="flex items-center justify-center py-8"> - <Loader className="h-8 w-8 animate-spin text-muted-foreground" /> - </div> - ) : ( - <Form {...form}> - <form - onSubmit={form.handleSubmit(onSubmit)} - className="flex flex-col gap-4 overflow-y-auto max-h-[calc(100vh-200px)] pr-2" - > - <FormField - control={form.control} - name="responseStatus" - render={({ field }) => ( - <FormItem> - <FormLabel>Response Status</FormLabel> - <Select - onValueChange={field.onChange} - defaultValue={field.value} - > - <FormControl> - <SelectTrigger className="capitalize"> - <SelectValue placeholder="Select response status" /> - </SelectTrigger> - </FormControl> - <SelectContent> - <SelectGroup> - <SelectItem value="PENDING">Pending</SelectItem> - <SelectItem value="IN_PROGRESS">In Progress</SelectItem> - <SelectItem value="SUBMITTED">Submitted</SelectItem> - </SelectGroup> - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="totalPrice" - render={({ field }) => ( - <FormItem> - <FormLabel>Total Price</FormLabel> - <FormControl> - <Input - type="number" - placeholder="0.00" - {...field} - value={field.value || ''} - onChange={(e) => { - const value = e.target.value === '' ? undefined : parseFloat(e.target.value); - field.onChange(value); - }} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="currency" - render={({ field }) => ( - <FormItem> - <FormLabel>Currency</FormLabel> - <Select - onValueChange={field.onChange} - defaultValue={field.value} - > - <FormControl> - <SelectTrigger> - <SelectValue placeholder="Select currency" /> - </SelectTrigger> - </FormControl> - <SelectContent> - <SelectGroup> - <SelectItem value="USD">USD</SelectItem> - <SelectItem value="EUR">EUR</SelectItem> - <SelectItem value="GBP">GBP</SelectItem> - <SelectItem value="KRW">KRW</SelectItem> - <SelectItem value="JPY">JPY</SelectItem> - </SelectGroup> - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - </div> - - {/* Other form fields remain the same */} - <FormField - control={form.control} - name="paymentTerms" - render={({ field }) => ( - <FormItem> - <FormLabel>Payment Terms</FormLabel> - <FormControl> - <Input placeholder="e.g. Net 30" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="incoterms" - render={({ field }) => ( - <FormItem> - <FormLabel>Incoterms</FormLabel> - <Select - onValueChange={field.onChange} - defaultValue={field.value || ''} - > - <FormControl> - <SelectTrigger> - <SelectValue placeholder="Select incoterms" /> - </SelectTrigger> - </FormControl> - <SelectContent> - <SelectGroup> - <SelectItem value="EXW">EXW (Ex Works)</SelectItem> - <SelectItem value="FCA">FCA (Free Carrier)</SelectItem> - <SelectItem value="FOB">FOB (Free On Board)</SelectItem> - <SelectItem value="CIF">CIF (Cost, Insurance & Freight)</SelectItem> - <SelectItem value="DAP">DAP (Delivered At Place)</SelectItem> - <SelectItem value="DDP">DDP (Delivered Duty Paid)</SelectItem> - </SelectGroup> - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="deliveryPeriod" - render={({ field }) => ( - <FormItem> - <FormLabel>Delivery Period</FormLabel> - <FormControl> - <Input placeholder="e.g. 4-6 weeks" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="warrantyPeriod" - render={({ field }) => ( - <FormItem> - <FormLabel>Warranty Period</FormLabel> - <FormControl> - <Input placeholder="e.g. 12 months" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="validityPeriod" - render={({ field }) => ( - <FormItem> - <FormLabel>Validity Period</FormLabel> - <FormControl> - <Input placeholder="e.g. 30 days" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="priceBreakdown" - render={({ field }) => ( - <FormItem> - <FormLabel>Price Breakdown (Optional)</FormLabel> - <FormControl> - <Textarea - placeholder="Enter price breakdown details here" - className="min-h-[100px]" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="commercialNotes" - render={({ field }) => ( - <FormItem> - <FormLabel>Additional Notes (Optional)</FormLabel> - <FormControl> - <Textarea - placeholder="Any additional comments or notes" - className="min-h-[100px]" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <SheetFooter className="gap-2 pt-4 sm:space-x-0"> - <SheetClose asChild> - <Button type="button" variant="outline"> - Cancel - </Button> - </SheetClose> - <Button disabled={isSubmitting} type="submit"> - {isSubmitting && ( - <Loader - className="mr-2 size-4 animate-spin" - aria-hidden="true" - /> - )} - Submit Response - </Button> - </SheetFooter> - </form> - </Form> - )} - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx b/lib/vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx deleted file mode 100644 index e9328641..00000000 --- a/lib/vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx +++ /dev/null @@ -1,89 +0,0 @@ -"use client" - -import * as React from "react" -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { Badge } from "@/components/ui/badge" -import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" -import { RfqItemsTable } from "./rfq-items-table/rfq-items-table" -import { formatDateTime } from "@/lib/utils" -import { CalendarClock } from "lucide-react" - -interface RfqDeailDialogProps { - isOpen: boolean - onOpenChange: (open: boolean) => void - rfqId: number | null - rfq: VendorWithCbeFields | null -} - -export function RfqDeailDialog({ - isOpen, - onOpenChange, - rfqId, - rfq, -}: RfqDeailDialogProps) { - return ( - <Dialog open={isOpen} onOpenChange={onOpenChange}> - <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{ maxWidth: 1000, height: 480 }}> - <DialogHeader> - <div className="flex flex-col space-y-2"> - <DialogTitle>프로젝트: {rfq && rfq.projectName}({rfq && rfq.projectCode}) / RFQ: {rfq && rfq.rfqCode} Detail</DialogTitle> - {rfq && ( - <div className="flex flex-col space-y-3 mt-2"> - <div className="text-sm text-muted-foreground"> - <span className="font-medium text-foreground">{rfq.rfqDescription && rfq.rfqDescription}</span> - </div> - - {/* 정보를 두 행으로 나누어 표시 */} - <div className="flex flex-col space-y-2 sm:space-y-0 sm:flex-row sm:justify-between sm:items-center"> - {/* 첫 번째 행: 상태 배지 */} - <div className="flex items-center flex-wrap gap-2"> - {rfq.rfqType && ( - <Badge - variant={ - rfq.rfqType === "BUDGETARY" ? "default" : - rfq.rfqType === "PURCHASE" ? "destructive" : - rfq.rfqType === "PURCHASE_BUDGETARY" ? "secondary" : "outline" - } - > - RFQ 유형: {rfq.rfqType} - </Badge> - )} - - - {rfq.vendorStatus && ( - <Badge variant="outline"> - RFQ 상태: {rfq.rfqStatus} - </Badge> - )} - - </div> - - {/* 두 번째 행: Due Date를 강조 표시 */} - {rfq.rfqDueDate && ( - <div className="flex items-center"> - <Badge variant="secondary" className="flex gap-1 text-xs py-1 px-3"> - <CalendarClock className="h-3.5 w-3.5" /> - <span className="font-semibold">Due Date:</span> - <span>{formatDateTime(rfq.rfqDueDate)}</span> - </Badge> - </div> - )} - </div> - </div> - )} - </div> - </DialogHeader> - {rfqId && ( - <div className="py-4"> - <RfqItemsTable rfqId={rfqId} /> - </div> - )} - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx b/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx deleted file mode 100644 index bf4ae709..00000000 --- a/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"use client" -// Because columns rely on React state/hooks for row actions - -import * as React from "react" -import { ColumnDef, Row } from "@tanstack/react-table" -import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header" -import { formatDate } from "@/lib/utils" -import { Checkbox } from "@/components/ui/checkbox" -import { ItemData } from "./rfq-items-table" - - -/** getColumns: return array of ColumnDef for 'vendors' data */ -export function getColumns(): ColumnDef<ItemData>[] { - return [ - - // Vendor Name - { - accessorKey: "itemCode", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Item Code" /> - ), - cell: ({ row }) => row.getValue("itemCode"), - }, - - // Vendor Code - { - accessorKey: "description", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Description" /> - ), - cell: ({ row }) => row.getValue("description"), - }, - - // Status - { - accessorKey: "quantity", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Quantity" /> - ), - cell: ({ row }) => row.getValue("quantity"), - }, - - - // Created At - { - accessorKey: "createdAt", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Created At" /> - ), - cell: ({ cell }) => formatDate(cell.getValue() as Date), - }, - - // Updated At - { - accessorKey: "updatedAt", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Updated At" /> - ), - cell: ({ cell }) => formatDate(cell.getValue() as Date), - }, - ] -}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx b/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx deleted file mode 100644 index c5c67e54..00000000 --- a/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx +++ /dev/null @@ -1,86 +0,0 @@ -'use client' - -import * as React from "react" -import { ClientDataTable } from "@/components/client-data-table/data-table" -import { getColumns } from "./rfq-items-table-column" -import { DataTableAdvancedFilterField } from "@/types/table" -import { Loader2 } from "lucide-react" -import { useToast } from "@/hooks/use-toast" -import { getItemsByRfqId } from "../../service" - -export interface ItemData { - id: number - itemCode: string - description: string | null - quantity: number - uom: string | null - createdAt: Date - updatedAt: Date -} - -interface RFQItemsTableProps { - rfqId: number -} - -export function RfqItemsTable({ rfqId }: RFQItemsTableProps) { - const { toast } = useToast() - - const columns = React.useMemo( - () => getColumns(), - [] - ) - - const [rfqItems, setRfqItems] = React.useState<ItemData[]>([]) - const [isLoading, setIsLoading] = React.useState(false) - - React.useEffect(() => { - async function loadItems() { - setIsLoading(true) - try { - // Use the correct function name (camelCase) - const result = await getItemsByRfqId(rfqId) - if (result.success && result.data) { - setRfqItems(result.data as ItemData[]) - } else { - throw new Error(result.error || "Unknown error occurred") - } - } catch (error) { - console.error("RFQ 아이템 로드 오류:", error) - toast({ - title: "Error", - description: "Failed to load RFQ items", - variant: "destructive", - }) - } finally { - setIsLoading(false) - } - } - loadItems() - }, [toast, rfqId]) - - const advancedFilterFields: DataTableAdvancedFilterField<ItemData>[] = [ - { id: "itemCode", label: "Item Code", type: "text" }, - { id: "description", label: "Description", type: "text" }, - { id: "quantity", label: "Quantity", type: "number" }, - { id: "uom", label: "UoM", type: "text" }, - ] - - // If loading, show a flex container that fills the parent and centers the spinner - if (isLoading) { - return ( - <div className="flex h-full w-full items-center justify-center"> - <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> - </div> - ) - } - - // Otherwise, show the table - return ( - <ClientDataTable - data={rfqItems} - columns={columns} - advancedFilterFields={advancedFilterFields} - > - </ClientDataTable> - ) -}
\ No newline at end of file |
