diff options
Diffstat (limited to 'lib/tech-vendor-rfq-response/vendor-rfq-table')
7 files changed, 1405 insertions, 0 deletions
diff --git a/lib/tech-vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx b/lib/tech-vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx new file mode 100644 index 00000000..da656356 --- /dev/null +++ b/lib/tech-vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx @@ -0,0 +1,127 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { RfqWithAll } from "../types" +/** + * 아이템 구조 예시 + * - API 응답에서 quantity가 "string" 형태이므로, + * 숫자로 사용하실 거라면 parse 과정이 필요할 수 있습니다. + */ +export interface RfqItem { + id: number + itemCode: string + itemName: string + itemList: string | null + subItemList: string | null + quantity: string + description: string + uom: string +} + +/** + * 첨부파일 구조 예시 + */ +export interface RfqAttachment { + id: number + fileName: string + filePath: string + vendorId: number | null + evaluationId: number | null +} + + +/** + * 다이얼로그 내에서만 사용할 단순 아이템 구조 (예: 임시/기본값 표출용) + */ +export interface DefaultItem { + id?: number + itemCode: string + description?: string | null + quantity?: number | null + uom?: string | null +} + +/** + * RfqsItemsDialog 컴포넌트 Prop 타입 + */ +export interface RfqsItemsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + rfq: RfqWithAll + defaultItems?: DefaultItem[] +} + +export function RfqsItemsDialog({ + open, + onOpenChange, + rfq, +}: RfqsItemsDialogProps) { + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-none w-[1200px]"> + <DialogHeader> + <DialogTitle>Items for RFQ {rfq?.rfqCode}</DialogTitle> + <DialogDescription> + Below is the list of items for this RFQ. + </DialogDescription> + </DialogHeader> + + <div className="overflow-x-auto w-full space-y-4"> + {rfq && rfq.items.length === 0 && ( + <p className="text-sm text-muted-foreground">No items found.</p> + )} + {rfq && rfq.items.length > 0 && ( + <Table> + {/* 필요에 따라 TableCaption 등을 추가해도 좋습니다. */} + <TableHeader> + <TableRow> + <TableHead>Item Code</TableHead> + <TableHead>Item List</TableHead> + <TableHead>Sub Item List</TableHead> + <TableHead>Description</TableHead> + <TableHead>Qty</TableHead> + <TableHead>UoM</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {rfq.items.map((it, idx) => ( + <TableRow key={it.id ?? idx}> + <TableCell>{it.itemCode || "No Code"}</TableCell> + <TableCell>{it.itemList || "-"}</TableCell> + <TableCell>{it.subItemList || "-"}</TableCell> + <TableCell>{it.description || "-"}</TableCell> + <TableCell>{it.quantity ?? 1}</TableCell> + <TableCell>{it.uom ?? "each"}</TableCell> + </TableRow> + ))} + </TableBody> + </Table> + )} + </div> + + <DialogFooter className="mt-4"> + <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> + Close + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/tech-vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx b/lib/tech-vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx new file mode 100644 index 00000000..6c51c12c --- /dev/null +++ b/lib/tech-vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx @@ -0,0 +1,106 @@ +"use client" + +import * as React from "react" +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, + SheetFooter, + SheetClose, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +import { Download } from "lucide-react" +import { formatDate } from "@/lib/utils" + +// 첨부파일 구조 +interface RfqAttachment { + id: number + fileName: string + filePath: string + createdAt?: Date // or Date + vendorId?: number | null + size?: number +} + +// 컴포넌트 Prop +interface RfqAttachmentsSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { + rfqId: number + attachments?: RfqAttachment[] +} + +/** + * RfqAttachmentsSheet: + * - 단순히 첨부파일 리스트 + 다운로드 버튼만 + */ +export function RfqAttachmentsSheet({ + rfqId, + attachments = [], + ...props +}: RfqAttachmentsSheetProps) { + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-sm"> + <SheetHeader> + <SheetTitle>Attachments</SheetTitle> + <SheetDescription>RFQ #{rfqId}에 대한 첨부파일 목록</SheetDescription> + </SheetHeader> + + <div className="space-y-2"> + {/* 첨부파일이 없을 경우 */} + {attachments.length === 0 && ( + <p className="text-sm text-muted-foreground"> + No attachments + </p> + )} + + {/* 첨부파일 목록 */} + {attachments.map((att) => ( + <div + key={att.id} + className="flex items-center justify-between rounded border p-2" + > + <div className="flex flex-col text-sm"> + <span className="font-medium">{att.fileName}</span> + {att.size && ( + <span className="text-xs text-muted-foreground"> + {Math.round(att.size / 1024)} KB + </span> + )} + {att.createdAt && ( + <span className="text-xs text-muted-foreground"> + Created at {formatDate(att.createdAt)} + </span> + )} + </div> + {/* 파일 다운로드 버튼 */} + {att.filePath && ( + <a + href={att.filePath} + download + target="_blank" + rel="noreferrer" + className="text-sm" + > + <Button variant="ghost" size="icon" type="button"> + <Download className="h-4 w-4" /> + </Button> + </a> + )} + </div> + ))} + </div> + + <SheetFooter className="gap-2 pt-2"> + {/* 닫기 버튼 */} + <SheetClose asChild> + <Button type="button" variant="outline"> + Close + </Button> + </SheetClose> + </SheetFooter> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/tech-vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx b/lib/tech-vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx new file mode 100644 index 00000000..8904fcff --- /dev/null +++ b/lib/tech-vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx @@ -0,0 +1,320 @@ +"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-tech/service" + + +export interface MatchedVendorComment { + id: number + commentText: string + commentedBy?: number + commentedByEmail?: string + createdAt?: Date + attachments?: { + id: number + fileName: string + filePath: string + }[] +} + +// 1) props 정의 +interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { + initialComments?: MatchedVendorComment[] + currentUserId: number + rfqId: number + vendorId: number + onCommentsUpdated?: (comments: MatchedVendorComment[]) => void + isLoading?: boolean // New prop +} + +// 2) 폼 스키마 +const commentFormSchema = z.object({ + commentText: z.string().min(1, "댓글을 입력하세요."), + newFiles: z.array(z.any()).optional(), // File[] +}) +type CommentFormValues = z.infer<typeof commentFormSchema> + +const MAX_FILE_SIZE = 30e6 // 30MB + +export function CommentSheet({ + rfqId, + vendorId, + initialComments = [], + currentUserId, + onCommentsUpdated, + isLoading = false, // Default to false + ...props +}: CommentSheetProps) { + + console.log(initialComments) + + const [comments, setComments] = React.useState<MatchedVendorComment[]>(initialComments) + const [isPending, startTransition] = React.useTransition() + + React.useEffect(() => { + setComments(initialComments) + }, [initialComments]) + + const form = useForm<CommentFormValues>({ + resolver: zodResolver(commentFormSchema), + defaultValues: { + commentText: "", + newFiles: [], + }, + }) + + const { fields: newFileFields, append, remove } = useFieldArray({ + control: form.control, + name: "newFiles", + }) + + // (A) 기존 코멘트 렌더링 + function renderExistingComments() { + + if (isLoading) { + return ( + <div className="flex justify-center items-center h-32"> + <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> + <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span> + </div> + ) + } + + if (comments.length === 0) { + return <p className="text-sm text-muted-foreground">No comments yet</p> + } + return ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-1/2">Comment</TableHead> + <TableHead>Attachments</TableHead> + <TableHead>Created At</TableHead> + <TableHead>Created By</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {comments.map((c) => ( + <TableRow key={c.id}> + <TableCell>{c.commentText}</TableCell> + <TableCell> + {!c.attachments?.length && ( + <span className="text-sm text-muted-foreground">No files</span> + )} + {c.attachments?.length && ( + <div className="flex flex-col gap-1"> + {c.attachments.map((att) => ( + <div key={att.id} className="flex items-center gap-2"> + <a + href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`} + download + target="_blank" + rel="noreferrer" + className="inline-flex items-center gap-1 text-blue-600 underline" + > + <Download className="h-4 w-4" /> + {att.fileName} + </a> + </div> + ))} + </div> + )} + </TableCell> + <TableCell> { c.createdAt ? formatDate(c.createdAt): "-"}</TableCell> + <TableCell>{c.commentedByEmail ?? "-"}</TableCell> + </TableRow> + ))} + </TableBody> + </Table> + ) + } + + // (B) 파일 드롭 + function handleDropAccepted(files: File[]) { + append(files) + } + + // (C) Submit + async function onSubmit(data: CommentFormValues) { + if (!rfqId) return + startTransition(async () => { + try { + const res = await createRfqCommentWithAttachments({ + rfqId, + vendorId, + commentText: data.commentText, + commentedBy: currentUserId, + evaluationId: null, + cbeId: null, + files: data.newFiles, + }) + + if (!res.ok) { + throw new Error("Failed to create comment") + } + + toast.success("Comment created") + + // 임시로 새 코멘트 추가 + const newComment: MatchedVendorComment = { + id: res.commentId, // 서버 응답 + commentText: data.commentText, + commentedBy: currentUserId, + createdAt: res.createdAt, + attachments: + data.newFiles?.map((f) => ({ + id: Math.floor(Math.random() * 1e6), + fileName: f.name, + filePath: "/uploads/" + f.name, + })) || [], + } + setComments((prev) => [...prev, newComment]) + onCommentsUpdated?.([...comments, newComment]) + + form.reset() + } catch (err: any) { + console.error(err) + toast.error("Error: " + err.message) + } + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-lg"> + <SheetHeader className="text-left"> + <SheetTitle>Comments</SheetTitle> + <SheetDescription> + 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다. + </SheetDescription> + </SheetHeader> + + <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + <FormField + control={form.control} + name="commentText" + render={({ field }) => ( + <FormItem> + <FormLabel>New Comment</FormLabel> + <FormControl> + <Textarea placeholder="Enter your comment..." {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <Dropzone + maxSize={MAX_FILE_SIZE} + onDropAccepted={handleDropAccepted} + onDropRejected={(rej) => { + toast.error("File rejected: " + (rej[0]?.file?.name || "")) + }} + > + {({ maxSize }) => ( + <DropzoneZone className="flex justify-center"> + <DropzoneInput /> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>Drop to attach files</DropzoneTitle> + <DropzoneDescription> + Max size: {prettyBytes(maxSize || 0)} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + )} + </Dropzone> + + {newFileFields.length > 0 && ( + <div className="flex flex-col gap-2"> + {newFileFields.map((field, idx) => { + const file = form.getValues(`newFiles.${idx}`) + if (!file) return null + return ( + <div + key={field.id} + className="flex items-center justify-between border rounded p-2" + > + <span className="text-sm"> + {file.name} ({prettyBytes(file.size)}) + </span> + <Button + variant="ghost" + size="icon" + type="button" + onClick={() => remove(idx)} + > + <X className="h-4 w-4" /> + </Button> + </div> + ) + })} + </div> + )} + + <SheetFooter className="gap-2 pt-4"> + <SheetClose asChild> + <Button type="button" variant="outline"> + Cancel + </Button> + </SheetClose> + <Button disabled={isPending}> + {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/tech-vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx b/lib/tech-vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/tech-vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"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/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx b/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx new file mode 100644 index 00000000..69a5e7e7 --- /dev/null +++ b/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx @@ -0,0 +1,424 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { ColumnDef } from "@tanstack/react-table" +import { + Ellipsis, + MessageSquare, + Package, + Paperclip, +} from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { Badge } from "@/components/ui/badge" + +import { getErrorMessage } from "@/lib/handle-error" +import { formatDate, formatDateTime } from "@/lib/utils" +import { modifyRfqVendor } from "../../rfqs-tech/service" +import type { RfqWithAll } from "../types" +import type { DataTableRowAction } from "@/types/table" + +type NextRouter = ReturnType<typeof useRouter> + +interface GetColumnsProps { + setRowAction: React.Dispatch< + React.SetStateAction<DataTableRowAction<RfqWithAll> | null> + > + router: NextRouter + openAttachmentsSheet: (rfqId: number) => void + openCommentSheet: (rfqId: number) => void +} + +/** + * tanstack table 컬럼 정의 (Nested Header) + */ +export function getColumns({ + setRowAction, + router, + openAttachmentsSheet, + openCommentSheet, +}: GetColumnsProps): ColumnDef<RfqWithAll>[] { + // 1) 체크박스(Select) 컬럼 + const selectColumn: ColumnDef<RfqWithAll> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // 2) Actions (Dropdown) + const actionsColumn: ColumnDef<RfqWithAll> = { + id: "actions", + enableHiding: false, + cell: ({ row }) => { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" size="icon"> + <Ellipsis className="h-4 w-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-56"> + <DropdownMenuSub> + <DropdownMenuSubTrigger>RFQ Response</DropdownMenuSubTrigger> + <DropdownMenuSubContent> + <DropdownMenuRadioGroup + value={row.original.responseStatus} + onValueChange={(value) => { + startUpdateTransition(async () => { + let newStatus: + | "ACCEPTED" + | "DECLINED" + | "REVIEWING" + + switch (value) { + case "ACCEPTED": + newStatus = "ACCEPTED" + break + case "DECLINED": + newStatus = "DECLINED" + break + default: + newStatus = "REVIEWING" + } + + await toast.promise( + modifyRfqVendor({ + id: row.original.responseId, + status: newStatus, + }), + { + loading: "Updating response status...", + success: "Response status updated", + error: (err) => getErrorMessage(err), + } + ) + }) + }} + > + {[ + { value: "ACCEPTED", label: "Accept RFQ" }, + { value: "DECLINED", label: "Decline RFQ" }, + ].map((rep) => ( + <DropdownMenuRadioItem + key={rep.value} + value={rep.value} + className="capitalize" + disabled={isUpdatePending} + > + {rep.label} + </DropdownMenuRadioItem> + ))} + </DropdownMenuRadioGroup> + </DropdownMenuSubContent> + </DropdownMenuSub> + {/* <DropdownMenuItem + onClick={() => { + router.push(`/vendor/rfqs/${row.original.rfqId}`) + }} + > + View Details + </DropdownMenuItem> */} + {/* <DropdownMenuItem onClick={() => openAttachmentsSheet(row.original.rfqId)}> + View Attachments + </DropdownMenuItem> + <DropdownMenuItem onClick={() => openCommentSheet(row.original.rfqId)}> + View Comments + </DropdownMenuItem> + <DropdownMenuItem onClick={() => setRowAction({ row, type: "items" })}> + View Items + </DropdownMenuItem> */} + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + + // 3) RFQ Code 컬럼 + const rfqCodeColumn: ColumnDef<RfqWithAll> = { + id: "rfqCode", + accessorKey: "rfqCode", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ Code" /> + ), + // cell: ({ row }) => { + // return ( + // <Button + // variant="link" + // className="p-0 h-auto font-medium" + // onClick={() => router.push(`/vendor/rfqs/${row.original.rfqId}`)} + // > + // {row.original.rfqCode} + // </Button> + // ) + // }, + cell: ({ row }) => row.original.rfqCode || "-", + size: 150, + } + + + + // 4) 응답 상태 컬럼 + const responseStatusColumn: ColumnDef<RfqWithAll> = { + id: "responseStatus", + accessorKey: "responseStatus", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Response Status" /> + ), + cell: ({ row }) => { + const status = row.original.responseStatus; + let variant: "default" | "secondary" | "destructive" | "outline"; + + switch (status) { + case "REVIEWING": + variant = "default"; + break; + case "ACCEPTED": + variant = "secondary"; + break; + case "DECLINED": + variant = "destructive"; + break; + default: + variant = "outline"; + } + + return <Badge variant={variant}>{status}</Badge>; + }, + size: 150, + } + + // 5) 프로젝트 이름 컬럼 + const projectNameColumn: ColumnDef<RfqWithAll> = { + id: "projectName", + accessorKey: "projectName", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Project" /> + ), + cell: ({ row }) => row.original.projectName || "-", + size: 150, + } + + // 6) RFQ Description 컬럼 + const descriptionColumn: ColumnDef<RfqWithAll> = { + id: "rfqDescription", + accessorKey: "rfqDescription", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Description" /> + ), + cell: ({ row }) => row.original.rfqDescription || "-", + size: 200, + } + + // 7) Due Date 컬럼 + const dueDateColumn: ColumnDef<RfqWithAll> = { + id: "rfqDueDate", + accessorKey: "rfqDueDate", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Due Date" /> + ), + cell: ({ row }) => { + const date = row.original.rfqDueDate; + return date ? formatDate(date) : "-"; + }, + size: 120, + } + + // 8) Last Updated 컬럼 + const updatedAtColumn: ColumnDef<RfqWithAll> = { + id: "respondedAt", + accessorKey: "respondedAt", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Last Updated" /> + ), + cell: ({ row }) => { + const date = row.original.respondedAt; + return date ? formatDateTime(date) : "-"; + }, + size: 150, + } + + // 9) Items 컬럼 - 뱃지로 아이템 개수 표시 + const itemsColumn: ColumnDef<RfqWithAll> = { + id: "items", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Items" /> + ), + cell: ({ row }) => { + const rfq = row.original + const count = rfq.items?.length ?? 0 + + function handleClick() { + setRowAction({ row, type: "items" }) + } + + return ( + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label={count > 0 ? `View ${count} items` : "No items"} + > + <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + {count > 0 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" + > + {count} + </Badge> + )} + + <span className="sr-only"> + {count > 0 ? `${count} Items` : "No Items"} + </span> + </Button> + ) + }, + enableSorting: false, + maxSize: 80, + } + + // 10) Attachments 컬럼 - 뱃지로 파일 개수 표시 + const attachmentsColumn: ColumnDef<RfqWithAll> = { + id: "attachments", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Attachments" /> + ), + cell: ({ row }) => { + const attachCount = row.original.attachments?.length ?? 0 + + function handleClick(e: React.MouseEvent<HTMLButtonElement>) { + e.preventDefault() + openAttachmentsSheet(row.original.rfqId) + } + + return ( + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label={ + attachCount > 0 ? `View ${attachCount} files` : "No files" + } + > + <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + {attachCount > 0 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" + > + {attachCount} + </Badge> + )} + <span className="sr-only"> + {attachCount > 0 ? `${attachCount} Files` : "No Files"} + </span> + </Button> + ) + }, + enableSorting: false, + maxSize: 80, + } + + // 11) Comments 컬럼 - 뱃지로 댓글 개수 표시 + const commentsColumn: ColumnDef<RfqWithAll> = { + id: "comments", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Comments" /> + ), + cell: ({ row }) => { + const commCount = row.original.comments?.length ?? 0 + + function handleClick() { + setRowAction({ row, type: "comments" }) + openCommentSheet(row.original.rfqId) + } + + return ( + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label={ + commCount > 0 ? `View ${commCount} comments` : "No comments" + } + > + <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + {commCount > 0 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" + > + {commCount} + </Badge> + )} + <span className="sr-only"> + {commCount > 0 ? `${commCount} Comments` : "No Comments"} + </span> + </Button> + ) + }, + enableSorting: false, + maxSize: 80, + } + + // 최종 컬럼 구성 - TBE/CBE 관련 컬럼 제외 + return [ + selectColumn, + rfqCodeColumn, + responseStatusColumn, + projectNameColumn, + descriptionColumn, + dueDateColumn, + itemsColumn, + attachmentsColumn, + commentsColumn, + updatedAtColumn, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx b/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx new file mode 100644 index 00000000..1bae99ef --- /dev/null +++ b/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx @@ -0,0 +1,40 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { RfqWithAll } from "../types" + + +interface RfqsTableToolbarActionsProps { + table: Table<RfqWithAll> +} + +export function RfqsVendorTableToolbarActions({ table }: RfqsTableToolbarActionsProps) { + + + return ( + <div className="flex items-center gap-2"> + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "tasks", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx b/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx new file mode 100644 index 00000000..2e5ae5dc --- /dev/null +++ b/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx @@ -0,0 +1,280 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" +import { useRouter } from "next/navigation" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" + +import { useFeatureFlags } from "./feature-flags-provider" +import { getColumns } from "./rfqs-table-columns" +import { RfqWithAll } from "../types" + +import { + fetchRfqAttachments, + fetchRfqAttachmentsbyCommentId, +} from "../../rfqs-tech/service" + +import { RfqsVendorTableToolbarActions } from "./rfqs-table-toolbar-actions" +import { RfqsItemsDialog } from "./ItemsDialog" +import { RfqAttachmentsSheet } from "./attachment-rfq-sheet" +import { CommentSheet } from "./comments-sheet" +import { getRfqResponsesForVendor } from "../service" +import { useSession } from "next-auth/react" // Next-auth session hook 추가 + +interface RfqsTableProps { + promises: Promise<[Awaited<ReturnType<typeof getRfqResponsesForVendor>>]> +} + +// 코멘트+첨부파일 구조 예시 +export interface RfqCommentWithAttachments { + id: number + commentText: string + commentedBy?: number + commentedByEmail?: string + createdAt?: Date + attachments?: { + id: number + fileName: string + filePath: string + }[] +} + +export interface ExistingAttachment { + id: number + fileName: string + filePath: string + createdAt?: Date + vendorId?: number | null + size?: number +} + +export interface ExistingItem { + id?: number + itemCode: string + description: string | null + quantity: number | null + uom: string | null +} + +export function RfqsVendorTable({ promises }: RfqsTableProps) { + const { featureFlags } = useFeatureFlags() + const { data: session } = useSession() // 세션 정보 가져오기 + + // 1) 테이블 데이터( RFQs ) + const [{ data: responseData, pageCount }] = React.use(promises) + + // 데이터를 RfqWithAll 타입으로 변환 (id 필드 추가) + const data: RfqWithAll[] = React.useMemo(() => { + return responseData.map(item => ({ + ...item, + id: item.rfqId, // id 필드를 rfqId와 동일하게 설정 + })); + }, [responseData]); + + const router = useRouter() + + // 2) 첨부파일 시트 + 관련 상태 + const [attachmentsOpen, setAttachmentsOpen] = React.useState(false) + const [selectedRfqIdForAttachments, setSelectedRfqIdForAttachments] = React.useState<number | null>(null) + const [attachDefault, setAttachDefault] = React.useState<ExistingAttachment[]>([]) + + // 3) 코멘트 시트 + 관련 상태 + const [initialComments, setInitialComments] = React.useState<RfqCommentWithAttachments[]>([]) + const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) + const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) + + // 4) rowAction으로 다양한 모달/시트 열기 + const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqWithAll> | null>(null) + + // 열리고 닫힐 때마다, rowAction 등을 확인해서 시트 열기/닫기 처리 + React.useEffect(() => { + if (rowAction?.type === "comments" && rowAction?.row.original) { + openCommentSheet(rowAction.row.original.id) + } + }, [rowAction]) + + /** + * (A) 코멘트 시트를 열기 전에, + * DB에서 (rfqId에 해당하는) 코멘트들 + 각 코멘트별 첨부파일을 조회. + */ + const openCommentSheet = React.useCallback(async (rfqId: number) => { + setInitialComments([]) + + // 여기서 rowAction을 직접 참조하지 않고, 필요한 데이터만 파라미터로 받기 + const comments = data.find(rfq => rfq.rfqId === rfqId)?.comments || [] + + if (comments && comments.length > 0) { + const commentWithAttachments = await Promise.all( + comments.map(async (c) => { + const attachments = await fetchRfqAttachmentsbyCommentId(c.id) + return { + ...c, + commentedBy: c.commentedBy || 1, + attachments, + } + }) + ) + + setInitialComments(commentWithAttachments) + } + + setSelectedRfqIdForComments(rfqId) + setCommentSheetOpen(true) + }, [data]) // data만 의존성으로 추가 + + /** + * (B) 첨부파일 시트 열기 + */ + const openAttachmentsSheet = React.useCallback(async (rfqId: number) => { + const list = await fetchRfqAttachments(rfqId) + setAttachDefault(list) + setSelectedRfqIdForAttachments(rfqId) + setAttachmentsOpen(true) + }, []) + + // 5) DataTable 컬럼 세팅 + const columns = React.useMemo( + () => + getColumns({ + setRowAction, + router, + openAttachmentsSheet, + openCommentSheet + }), + [setRowAction, router, openAttachmentsSheet, openCommentSheet] + ) + + /** + * 간단한 filterFields 예시 + */ + const filterFields: DataTableFilterField<RfqWithAll>[] = [ + { + id: "rfqCode", + label: "RFQ Code", + placeholder: "Filter RFQ Code...", + }, + { + id: "projectName", + label: "Project", + placeholder: "Filter Project...", + }, + { + id: "rfqDescription", + label: "Description", + placeholder: "Filter Description...", + }, + ] + + /** + * Advanced filter fields 예시 + */ + const advancedFilterFields: DataTableAdvancedFilterField<RfqWithAll>[] = [ + { + id: "rfqCode", + label: "RFQ Code", + type: "text", + }, + { + id: "rfqDescription", + label: "Description", + type: "text", + }, + { + id: "projectCode", + label: "Project Code", + type: "text", + }, + { + id: "projectName", + label: "Project Name", + type: "text", + }, + { + id: "rfqDueDate", + label: "Due Date", + type: "date", + }, + { + id: "responseStatus", + label: "Response Status", + type: "select", + options: [ + { label: "Reviewing", value: "REVIEWING" }, + { label: "Accepted", value: "ACCEPTED" }, + { label: "Declined", value: "DECLINED" }, + ], + } + ] + + // useDataTable() 훅 -> pagination, sorting 등 관리 + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "respondedAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0 + const currentVendorId = session?.user?.id ? session.user.companyId : 0 + + + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <RfqsVendorTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + </DataTable> + + {/* 1) 아이템 목록 Dialog */} + {rowAction?.type === "items" && rowAction?.row.original && ( + <RfqsItemsDialog + open={true} + onOpenChange={() => setRowAction(null)} + rfq={rowAction.row.original} + /> + )} + + {/* 2) 코멘트 시트 */} + {selectedRfqIdForComments && ( + <CommentSheet + open={commentSheetOpen} + onOpenChange={setCommentSheetOpen} + initialComments={initialComments} + rfqId={selectedRfqIdForComments} + vendorId={currentVendorId??0} + currentUserId={currentUserId} + /> + )} + + {/* 3) 첨부파일 시트 */} + <RfqAttachmentsSheet + open={attachmentsOpen} + onOpenChange={setAttachmentsOpen} + rfqId={selectedRfqIdForAttachments ?? 0} + attachments={attachDefault} + /> + </> + ) +}
\ No newline at end of file |
