diff options
Diffstat (limited to 'lib/vendor-rfq-response/vendor-tbe-table')
6 files changed, 199 insertions, 150 deletions
diff --git a/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx b/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx index 1eee54f5..e0bf9727 100644 --- a/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx +++ b/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx @@ -4,7 +4,7 @@ import * as React from "react" import { useForm, useFieldArray } from "react-hook-form" import { z } from "zod" import { zodResolver } from "@hookform/resolvers/zod" -import { Loader, Download, X } from "lucide-react" +import { Loader, Download, X, Loader2 } from "lucide-react" import prettyBytes from "pretty-bytes" import { toast } from "sonner" @@ -79,6 +79,8 @@ interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { vendorId:number /** 댓글 저장 후 갱신용 콜백 (옵션) */ onCommentsUpdated?: (comments: TbeComment[]) => void + isLoading?: boolean // New prop + } // 새 코멘트 작성 폼 스키마 @@ -96,6 +98,7 @@ export function CommentSheet({ initialComments = [], currentUserId, onCommentsUpdated, + isLoading = false, // Default to false ...props }: CommentSheetProps) { const [comments, setComments] = React.useState<TbeComment[]>(initialComments) @@ -125,6 +128,15 @@ export function CommentSheet({ // 간단히 테이블 하나로 표현 // 실제로는 Bubble 형태의 UI, Accordion, Timeline 등 다양하게 구성할 수 있음 function renderExistingComments() { + if (isLoading) { + return ( + <div className="flex justify-center items-center h-32"> + <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> + <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span> + </div> + ) + } + if (comments.length === 0) { return <p className="text-sm text-muted-foreground">No comments yet</p> } diff --git a/lib/vendor-rfq-response/vendor-tbe-table/feature-flags-provider.tsx b/lib/vendor-rfq-response/vendor-tbe-table/feature-flags-provider.tsx deleted file mode 100644 index 81131894..00000000 --- a/lib/vendor-rfq-response/vendor-tbe-table/feature-flags-provider.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"use client" - -import * as React from "react" -import { useQueryState } from "nuqs" - -import { dataTableConfig, type DataTableConfig } from "@/config/data-table" -import { cn } from "@/lib/utils" -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip" - -type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] - -interface FeatureFlagsContextProps { - featureFlags: FeatureFlagValue[] - setFeatureFlags: (value: FeatureFlagValue[]) => void -} - -const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ - featureFlags: [], - setFeatureFlags: () => {}, -}) - -export function useFeatureFlags() { - const context = React.useContext(FeatureFlagsContext) - if (!context) { - throw new Error( - "useFeatureFlags must be used within a FeatureFlagsProvider" - ) - } - return context -} - -interface FeatureFlagsProviderProps { - children: React.ReactNode -} - -export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { - const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( - "flags", - { - defaultValue: [], - parse: (value) => value.split(",") as FeatureFlagValue[], - serialize: (value) => value.join(","), - eq: (a, b) => - a.length === b.length && a.every((value, index) => value === b[index]), - clearOnDefault: true, - shallow: false, - } - ) - - return ( - <FeatureFlagsContext.Provider - value={{ - featureFlags, - setFeatureFlags: (value) => void setFeatureFlags(value), - }} - > - <div className="w-full overflow-x-auto"> - <ToggleGroup - type="multiple" - variant="outline" - size="sm" - value={featureFlags} - onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} - className="w-fit gap-0" - > - {dataTableConfig.featureFlags.map((flag, index) => ( - <Tooltip key={flag.value}> - <ToggleGroupItem - value={flag.value} - className={cn( - "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", - { - "rounded-l-sm border-r-0": index === 0, - "rounded-r-sm": - index === dataTableConfig.featureFlags.length - 1, - } - )} - asChild - > - <TooltipTrigger> - <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> - {flag.label} - </TooltipTrigger> - </ToggleGroupItem> - <TooltipContent - align="start" - side="bottom" - sideOffset={6} - className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" - > - <div>{flag.tooltipTitle}</div> - <div className="text-xs text-muted-foreground"> - {flag.tooltipDescription} - </div> - </TooltipContent> - </Tooltip> - ))} - </ToggleGroup> - </div> - {children} - </FeatureFlagsContext.Provider> - ) -} diff --git a/lib/vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx b/lib/vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx new file mode 100644 index 00000000..2056a48f --- /dev/null +++ b/lib/vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx @@ -0,0 +1,86 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Badge } from "@/components/ui/badge" +import { formatDateTime } from "@/lib/utils" +import { CalendarClock } from "lucide-react" +import { RfqItemsTable } from "../vendor-cbe-table/rfq-items-table/rfq-items-table" +import { TbeVendorFields } from "@/config/vendorTbeColumnsConfig" + +interface RfqDeailDialogProps { + isOpen: boolean + onOpenChange: (open: boolean) => void + rfqId: number | null + rfq: TbeVendorFields | null +} + +export function RfqDeailDialog({ + isOpen, + onOpenChange, + rfqId, + rfq, +}: RfqDeailDialogProps) { + return ( + <Dialog open={isOpen} onOpenChange={onOpenChange}> + <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1000, height:480}}> + <DialogHeader> + <div className="flex flex-col space-y-2"> + <DialogTitle>{rfq && rfq.rfqCode} Detail</DialogTitle> + {rfq && ( + <div className="flex flex-col space-y-3 mt-2"> + <div className="text-sm text-muted-foreground"> + <span className="font-medium text-foreground">{rfq.rfqDescription && rfq.rfqDescription}</span> + </div> + + {/* 정보를 두 행으로 나누어 표시 */} + <div className="flex flex-col space-y-2 sm:space-y-0 sm:flex-row sm:justify-between sm:items-center"> + {/* 첫 번째 행: 상태 배지 */} + <div className="flex items-center flex-wrap gap-2"> + {rfq.vendorStatus && ( + <Badge variant="outline"> + {rfq.rfqStatus} + </Badge> + )} + {rfq.rfqType && ( + <Badge + variant={ + rfq.rfqType === "BUDGETARY" ? "default" : + rfq.rfqType === "PURCHASE" ? "destructive" : + rfq.rfqType === "PURCHASE_BUDGETARY" ? "secondary" : "outline" + } + > + {rfq.rfqType} + </Badge> + )} + </div> + + {/* 두 번째 행: Due Date를 강조 표시 */} + {rfq.rfqDueDate && ( + <div className="flex items-center"> + <Badge variant="secondary" className="flex gap-1 text-xs py-1 px-3"> + <CalendarClock className="h-3.5 w-3.5" /> + <span className="font-semibold">Due Date:</span> + <span>{formatDateTime(rfq.rfqDueDate)}</span> + </Badge> + </div> + )} + </div> + </div> + )} + </div> + </DialogHeader> + {rfqId && ( + <div className="py-4"> + <RfqItemsTable rfqId={rfqId} /> + </div> + )} + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx index 7a95d7ed..f664d9a3 100644 --- a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx +++ b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx @@ -31,6 +31,8 @@ interface GetColumnsProps { openCommentSheet: (vendorId: number) => void handleDownloadTbeTemplate: (tbeId: number, vendorId: number, rfqId: number) => void handleUploadTbeResponse: (tbeId: number, vendorId: number, rfqId: number, vendorResponseId:number) => void + openVendorContactsDialog: (rfqId: number, rfq: TbeVendorFields) => void // 수정된 시그니처 + } /** @@ -42,6 +44,7 @@ export function getColumns({ openCommentSheet, handleDownloadTbeTemplate, handleUploadTbeResponse, + openVendorContactsDialog }: GetColumnsProps): ColumnDef<TbeVendorFields>[] { // ---------------------------------------------------------------- // 1) Select 컬럼 (체크박스) @@ -112,7 +115,30 @@ export function getColumns({ ) } - + + if (cfg.id === "rfqCode") { + const rfq = row.original; + const rfqId = rfq.rfqId; + + // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링 + const handleVendorNameClick = () => { + if (rfqId) { + openVendorContactsDialog(rfqId, rfq); // vendor 전체 객체 전달 + } else { + toast.error("협력업체 ID를 찾을 수 없습니다."); + } + }; + + return ( + <Button + variant="link" + className="p-0 h-auto text-left font-normal justify-start hover:underline" + onClick={handleVendorNameClick} + > + {val as string} + </Button> + ); + } if (cfg.id === "rfqVendorStatus") { const statusVal = row.original.rfqVendorStatus if (!statusVal) return null @@ -173,21 +199,28 @@ export function getColumns({ } return ( - <div> - <Button - variant="ghost" - size="sm" - className="h-8 w-8 p-0 group relative" - onClick={handleClick} - aria-label={commCount > 0 ? `View ${commCount} comments` : "Add comment"} - > - <div className="flex items-center justify-center relative"> - <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - </div> - {commCount > 0 && <span className="absolute -top-1 -right-1 inline-flex h-2 w-2 rounded-full bg-red-500"></span>} - <span className="sr-only">{commCount > 0 ? `${commCount} Comments` : "Add Comment"}</span> - </Button> - </div> + <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, diff --git a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx index 3450a643..13d5dc64 100644 --- a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx +++ b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx @@ -7,19 +7,17 @@ import type { DataTableFilterField, DataTableRowAction, } from "@/types/table" - -import { toSentenceCase } from "@/lib/utils" +import { toast } from "sonner" import { useDataTable } from "@/hooks/use-data-table" import { DataTable } from "@/components/data-table/data-table" import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" -import { useFeatureFlags } from "./feature-flags-provider" import { getColumns } from "./tbe-table-columns" -import { Vendor, vendors } from "@/db/schema/vendors" import { fetchRfqAttachmentsbyCommentId, getTBEforVendor } from "../../rfqs/service" import { CommentSheet, TbeComment } from "./comments-sheet" import { TbeVendorFields } from "@/config/vendorTbeColumnsConfig" import { useTbeFileHandlers } from "./tbeFileHandler" import { useSession } from "next-auth/react" +import { RfqDeailDialog } from "./rfq-detail-dialog" interface VendorsTableProps { promises: Promise< @@ -30,7 +28,6 @@ interface VendorsTableProps { } export function TbeVendorTable({ promises }: VendorsTableProps) { - const { featureFlags } = useFeatureFlags() const { data: session } = useSession() const userVendorId = session?.user?.companyId const userId = Number(session?.user?.id) @@ -43,8 +40,20 @@ export function TbeVendorTable({ promises }: VendorsTableProps) { const router = useRouter() const [initialComments, setInitialComments] = React.useState<TbeComment[]>([]) + const [isLoadingComments, setIsLoadingComments] = React.useState(false) + const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) + const [isRfqDetailDialogOpen, setIsRfqDetailDialogOpen] = React.useState(false) + + const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null) + const [selectedRfq, setSelectedRfq] = React.useState<TbeVendorFields | null>(null) + + const openVendorContactsDialog = (rfqId: number, rfq: TbeVendorFields) => { + setSelectedRfqId(rfqId) + setSelectedRfq(rfq) + setIsRfqDetailDialogOpen(true) + } // TBE 파일 핸들러 훅 사용 const { @@ -62,9 +71,11 @@ export function TbeVendorTable({ promises }: VendorsTableProps) { async function openCommentSheet(vendorId: number) { setInitialComments([]) + setIsLoadingComments(true) const comments = rowAction?.row.original.comments + try { if (comments && comments.length > 0) { const commentWithAttachments: TbeComment[] = await Promise.all( comments.map(async (c) => { @@ -73,18 +84,26 @@ export function TbeVendorTable({ promises }: VendorsTableProps) { return { ...c, - commentedBy: 1, // DB나 API 응답에 있다고 가정 + commentedBy: userId, // DB나 API 응답에 있다고 가정 attachments, } }) ) - + setInitialComments(commentWithAttachments) } setSelectedRfqIdForComments(vendorId) setCommentSheetOpen(true) + + } catch (error) { + console.error("Error loading comments:", error) + toast.error("Failed to load comments") + } finally { + // End loading regardless of success/failure + setIsLoadingComments(false) } +} // getColumns() 호출 시, 필요한 모든 핸들러 함수 주입 const columns = React.useMemo( @@ -94,27 +113,25 @@ export function TbeVendorTable({ promises }: VendorsTableProps) { openCommentSheet, handleDownloadTbeTemplate, handleUploadTbeResponse, + openVendorContactsDialog }), - [setRowAction, router, openCommentSheet, handleDownloadTbeTemplate, handleUploadTbeResponse] + [setRowAction, router, openCommentSheet, handleDownloadTbeTemplate, handleUploadTbeResponse, openVendorContactsDialog] ) const filterFields: DataTableFilterField<TbeVendorFields>[] = [] const advancedFilterFields: DataTableAdvancedFilterField<TbeVendorFields>[] = [ - { id: "vendorName", label: "Vendor Name", type: "text" }, - { id: "vendorCode", label: "Vendor Code", type: "text" }, - { id: "email", label: "Email", type: "text" }, - { id: "country", label: "Country", type: "text" }, - { - id: "vendorStatus", - label: "Vendor Status", - type: "multi-select", - options: vendors.status.enumValues.map((status) => ({ - label: toSentenceCase(status), - value: status, - })), - }, + { id: "rfqCode", label: "RFQ Code", type: "text" }, + { id: "projectCode", label: "Project Code", type: "text" }, + { id: "projectName", label: "Project Name", type: "text" }, + { id: "rfqCode", label: "RFQ Code", type: "text" }, + { id: "tbeResult", label: "TBE Result", type: "text" }, + { id: "tbeNote", label: "TBE Note", type: "text" }, + { id: "rfqCode", label: "RFQ Code", type: "text" }, + { id: "hasResponse", label: "Response?", type: "boolean" }, { id: "rfqVendorUpdated", label: "Updated at", type: "date" }, + { id: "dueDate", label: "Project Name", type: "date" }, + ] const { table } = useDataTable({ @@ -150,11 +167,20 @@ export function TbeVendorTable({ promises }: VendorsTableProps) { onOpenChange={setCommentSheetOpen} rfqId={selectedRfqIdForComments} initialComments={initialComments} - vendorId={userVendorId||0} - currentUserId={userId||0} + vendorId={userVendorId || 0} + currentUserId={userId || 0} + isLoading={isLoadingComments} // Pass the loading state + /> )} + <RfqDeailDialog + isOpen={isRfqDetailDialogOpen} + onOpenChange={setIsRfqDetailDialogOpen} + rfqId={selectedRfqId} + rfq={selectedRfq} + /> + {/* TBE 파일 다이얼로그 */} <UploadDialog /> </> diff --git a/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx index 4efaee77..a0b6f805 100644 --- a/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx +++ b/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx @@ -13,9 +13,9 @@ import { import { Button } from "@/components/ui/button"; import { fetchTbeTemplateFiles, - getTbeTemplateFileInfo, uploadTbeResponseFile, getTbeSubmittedFiles, + getFileFromRfqAttachmentsbyid, } from "../../rfqs/service"; import { Dropzone, @@ -118,7 +118,7 @@ export function useTbeFileHandlers() { // 실제 다운로드 로직 const downloadFile = useCallback(async (fileId: number) => { try { - const { file, error } = await getTbeTemplateFileInfo(fileId); + const { file, error } = await getFileFromRfqAttachmentsbyid(fileId); if (error || !file) { throw new Error(error || "파일 정보를 가져오는 데 실패했습니다"); } |
