diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-04-28 02:13:30 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-04-28 02:13:30 +0000 |
| commit | ef4c533ebacc2cdc97e518f30e9a9350004fcdfb (patch) | |
| tree | 345251a3ed0f4429716fa5edaa31024d8f4cb560 /lib/rfqs/tbe-table | |
| parent | 9ceed79cf32c896f8a998399bf1b296506b2cd4a (diff) | |
~20250428 작업사항
Diffstat (limited to 'lib/rfqs/tbe-table')
| -rw-r--r-- | lib/rfqs/tbe-table/comments-sheet.tsx | 145 | ||||
| -rw-r--r-- | lib/rfqs/tbe-table/invite-vendors-dialog.tsx | 39 | ||||
| -rw-r--r-- | lib/rfqs/tbe-table/tbe-result-dialog.tsx | 208 | ||||
| -rw-r--r-- | lib/rfqs/tbe-table/tbe-table-columns.tsx | 99 | ||||
| -rw-r--r-- | lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx | 23 | ||||
| -rw-r--r-- | lib/rfqs/tbe-table/tbe-table.tsx | 66 | ||||
| -rw-r--r-- | lib/rfqs/tbe-table/vendor-contact-dialog.tsx | 71 | ||||
| -rw-r--r-- | lib/rfqs/tbe-table/vendor-contact/vendor-contact-table-column.tsx | 70 | ||||
| -rw-r--r-- | lib/rfqs/tbe-table/vendor-contact/vendor-contact-table.tsx | 89 |
9 files changed, 683 insertions, 127 deletions
diff --git a/lib/rfqs/tbe-table/comments-sheet.tsx b/lib/rfqs/tbe-table/comments-sheet.tsx index bea1fc8e..6efd631f 100644 --- a/lib/rfqs/tbe-table/comments-sheet.tsx +++ b/lib/rfqs/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 { Download, X, Loader2 } from "lucide-react" import prettyBytes from "pretty-bytes" import { toast } from "sonner" @@ -26,41 +26,34 @@ import { FormLabel, FormMessage, } from "@/components/ui/form" -import { - Textarea, -} from "@/components/ui/textarea" - +import { Textarea } from "@/components/ui/textarea" import { Dropzone, DropzoneZone, DropzoneUploadIcon, DropzoneTitle, DropzoneDescription, - DropzoneInput + DropzoneInput, } from "@/components/ui/dropzone" - import { Table, TableHeader, TableRow, TableHead, TableBody, - TableCell + TableCell, } from "@/components/ui/table" -// DB 스키마에서 필요한 타입들을 가져온다고 가정 -// (실제 프로젝트에 맞춰 import를 수정하세요.) -import { RfqWithAll } from "@/db/schema/rfq" import { createRfqCommentWithAttachments } from "../service" import { formatDate } from "@/lib/utils" -// 코멘트 + 첨부파일 구조 (단순 예시) -// 실제 DB 스키마에 맞춰 조정 + export interface TbeComment { id: number commentText: string commentedBy?: number - createdAt?: string | Date + commentedByEmail?: string + createdAt?: Date attachments?: { id: number fileName: string @@ -68,23 +61,21 @@ export interface TbeComment { }[] } +// 1) props 정의 interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { - /** 코멘트를 작성할 RFQ 정보 */ - /** 이미 존재하는 모든 코멘트 목록 (서버에서 불러와 주입) */ initialComments?: TbeComment[] - - /** 사용자(작성자) ID (로그인 세션 등에서 가져옴) */ currentUserId: number - rfqId:number - vendorId:number - /** 댓글 저장 후 갱신용 콜백 (옵션) */ + rfqId: number + tbeId: number + vendorId: number onCommentsUpdated?: (comments: TbeComment[]) => void + isLoading?: boolean // New prop } -// 새 코멘트 작성 폼 스키마 +// 2) 폼 스키마 const commentFormSchema = z.object({ commentText: z.string().min(1, "댓글을 입력하세요."), - newFiles: z.array(z.any()).optional() // File[] + newFiles: z.array(z.any()).optional(), // File[] }) type CommentFormValues = z.infer<typeof commentFormSchema> @@ -95,40 +86,48 @@ export function CommentSheet({ vendorId, initialComments = [], currentUserId, + tbeId, onCommentsUpdated, + isLoading = false, // Default to false ...props }: CommentSheetProps) { + console.log("tbeId", tbeId) + const [comments, setComments] = React.useState<TbeComment[]>(initialComments) const [isPending, startTransition] = React.useTransition() React.useEffect(() => { setComments(initialComments) }, [initialComments]) - - // RHF 세팅 const form = useForm<CommentFormValues>({ resolver: zodResolver(commentFormSchema), defaultValues: { commentText: "", - newFiles: [] - } + newFiles: [], + }, }) - // formFieldArray 예시 (파일 목록) const { fields: newFileFields, append, remove } = useFieldArray({ control: form.control, - name: "newFiles" + name: "newFiles", }) - // 1) 기존 코멘트 + 첨부 보여주기 - // 간단히 테이블 하나로 표현 - // 실제로는 Bubble 형태의 UI, Accordion, Timeline 등 다양하게 구성할 수 있음 + // (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> @@ -144,16 +143,15 @@ export function CommentSheet({ <TableRow key={c.id}> <TableCell>{c.commentText}</TableCell> <TableCell> - {/* 첨부파일 표시 */} - {(!c.attachments || c.attachments.length === 0) && ( + {!c.attachments?.length && ( <span className="text-sm text-muted-foreground">No files</span> )} - {c.attachments && c.attachments.length > 0 && ( + {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={att.filePath} + href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`} download target="_blank" rel="noreferrer" @@ -167,10 +165,8 @@ export function CommentSheet({ </div> )} </TableCell> - <TableCell> { c.createdAt ? formatDate(c.createdAt): "-"}</TableCell> - <TableCell> - {c.commentedBy ?? "-"} - </TableCell> + <TableCell> {c.createdAt ? formatDate(c.createdAt) : "-"}</TableCell> + <TableCell>{c.commentedByEmail ?? "-"}</TableCell> </TableRow> ))} </TableBody> @@ -178,28 +174,28 @@ export function CommentSheet({ ) } - // 2) 새 파일 Drop + // (B) 파일 드롭 function handleDropAccepted(files: File[]) { - // 드롭된 File[]을 RHF field array에 추가 - const toAppend = files.map((f) => f) - append(toAppend) + append(files) } - - // 3) 저장(Submit) + // (C) Submit async function onSubmit(data: CommentFormValues) { - if (!rfqId) return startTransition(async () => { try { - // 서버 액션 호출 + console.log("rfqId", rfqId) + console.log("vendorId", vendorId) + console.log("tbeId", tbeId) + console.log("currentUserId", currentUserId) const res = await createRfqCommentWithAttachments({ - rfqId: rfqId, - vendorId: vendorId, // 필요시 세팅 + rfqId, + vendorId, commentText: data.commentText, commentedBy: currentUserId, - evaluationId: null, // 필요시 세팅 - files: data.newFiles + evaluationId: tbeId, + cbeId: null, + files: data.newFiles, }) if (!res.ok) { @@ -208,23 +204,22 @@ export function CommentSheet({ toast.success("Comment created") - // 새 코멘트를 다시 불러오거나, - // 여기서는 임시로 "새로운 코멘트가 추가됐다" 라고 가정하여 클라이언트에서 상태 업데이트 + // 임시로 새 코멘트 추가 const newComment: TbeComment = { - id: res.commentId, // 서버에서 반환된 commentId + id: res.commentId, // 서버 응답 commentText: data.commentText, commentedBy: currentUserId, - createdAt: new Date().toISOString(), - attachments: (data.newFiles?.map((f, idx) => ({ - id: Math.random() * 100000, - fileName: f.name, - filePath: "/uploads/" + f.name, - })) || []) + 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) @@ -243,12 +238,8 @@ export function CommentSheet({ </SheetDescription> </SheetHeader> - {/* 기존 코멘트 목록 */} - <div className="max-h-[300px] overflow-y-auto"> - {renderExistingComments()} - </div> + <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div> - {/* 새 코멘트 작성 Form */} <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> <FormField @@ -258,17 +249,13 @@ export function CommentSheet({ <FormItem> <FormLabel>New Comment</FormLabel> <FormControl> - <Textarea - placeholder="Enter your comment..." - {...field} - /> + <Textarea placeholder="Enter your comment..." {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> - {/* Dropzone (파일 첨부) */} <Dropzone maxSize={MAX_FILE_SIZE} onDropAccepted={handleDropAccepted} @@ -292,15 +279,19 @@ export function CommentSheet({ )} </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> + <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" @@ -322,7 +313,7 @@ export function CommentSheet({ </Button> </SheetClose> <Button disabled={isPending}> - {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} + {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} Save </Button> </SheetFooter> diff --git a/lib/rfqs/tbe-table/invite-vendors-dialog.tsx b/lib/rfqs/tbe-table/invite-vendors-dialog.tsx index e38e0ede..935d2bf3 100644 --- a/lib/rfqs/tbe-table/invite-vendors-dialog.tsx +++ b/lib/rfqs/tbe-table/invite-vendors-dialog.tsx @@ -32,6 +32,9 @@ import { Input } from "@/components/ui/input" import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" import { inviteTbeVendorsAction } from "../service" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Badge } from "@/components/ui/badge" +import { Label } from "@/components/ui/label" interface InviteVendorsDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { @@ -94,6 +97,23 @@ export function InviteVendorsDialog({ // 파일 선택 UI const fileInput = ( +<> + <div className="space-y-2"> + <Label>선택된 협력업체 ({vendors.length})</Label> + <ScrollArea className="h-20 border rounded-md p-2"> + <div className="flex flex-wrap gap-2"> + {vendors.map((vendor, index) => ( + <Badge key={index} variant="secondary" className="py-1"> + {vendor.vendorName || `협력업체 #${vendor.vendorCode}`} + </Badge> + ))} + </div> + </ScrollArea> + <p className="text-[0.8rem] font-medium text-muted-foreground"> + 선택된 모든 협력업체의 등록된 연락처에게 TBE 평가 알림이 전송됩니다. + </p> + </div> + <div className="mb-4"> <label className="mb-2 block font-medium">TBE Sheets</label> <Input @@ -104,6 +124,7 @@ export function InviteVendorsDialog({ }} /> </div> + </> ) // Desktop Dialog @@ -114,17 +135,15 @@ export function InviteVendorsDialog({ <DialogTrigger asChild> <Button variant="outline" size="sm"> <Send className="mr-2 size-4" aria-hidden="true" /> - Invite ({vendors.length}) + TBE 평가 생성 ({vendors.length}) </Button> </DialogTrigger> ) : null} <DialogContent> <DialogHeader> - <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogTitle>TBE 평가 시트 전송</DialogTitle> <DialogDescription> - This action cannot be undone. This will permanently invite{" "} - <span className="font-medium">{vendors.length}</span> - {vendors.length === 1 ? " vendor" : " vendors"}. 파일 첨부가 필수이므로 파일을 첨부해야지 버튼이 활성화됩니다. + 선택한 {vendors.length}개 협력업체에 대한 기술 평가 시트와 알림을 전송합니다. 파일 첨부가 필수이므로 파일을 첨부해야지 버튼이 활성화됩니다. </DialogDescription> </DialogHeader> @@ -169,12 +188,10 @@ export function InviteVendorsDialog({ ) : null} <DrawerContent> <DrawerHeader> - <DrawerTitle>Are you absolutely sure?</DrawerTitle> - <DrawerDescription> - This action cannot be undone. This will permanently invite{" "} - <span className="font-medium">{vendors.length}</span> - {vendors.length === 1 ? " vendor" : " vendors"}. - </DrawerDescription> + <DialogTitle>TBE 평가 시트 전송</DialogTitle> + <DialogDescription> + 선택한 {vendors.length}개 협력업체에 대한 기술 평가 시트와 알림을 전송합니다. 파일 첨부가 필수이므로 파일을 첨부해야지 버튼이 활성화됩니다. + </DialogDescription> </DrawerHeader> {/* 파일 첨부 */} diff --git a/lib/rfqs/tbe-table/tbe-result-dialog.tsx b/lib/rfqs/tbe-table/tbe-result-dialog.tsx new file mode 100644 index 00000000..8400ecac --- /dev/null +++ b/lib/rfqs/tbe-table/tbe-result-dialog.tsx @@ -0,0 +1,208 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Textarea } from "@/components/ui/textarea" +import { Label } from "@/components/ui/label" +import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" +import { getErrorMessage } from "@/lib/handle-error" +import { saveTbeResult } from "../service" + +// Define the props for the TbeResultDialog component +interface TbeResultDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + tbe: VendorWithTbeFields | null + onRefresh?: () => void +} + +// Define TBE result options +const TBE_RESULT_OPTIONS = [ + { value: "pass", label: "Pass", badgeVariant: "default" }, + { value: "non-pass", label: "Non-Pass", badgeVariant: "destructive" }, + { value: "conditional pass", label: "Conditional Pass", badgeVariant: "secondary" }, +] as const + +type TbeResultOption = typeof TBE_RESULT_OPTIONS[number]["value"] + +export function TbeResultDialog({ + open, + onOpenChange, + tbe, + onRefresh, +}: TbeResultDialogProps) { + // Initialize state for form inputs + const [result, setResult] = React.useState<TbeResultOption | "">("") + const [note, setNote] = React.useState("") + const [isSubmitting, setIsSubmitting] = React.useState(false) + + // Update form values when the tbe prop changes + React.useEffect(() => { + if (tbe) { + setResult((tbe.tbeResult as TbeResultOption) || "") + setNote(tbe.tbeNote || "") + } + }, [tbe]) + + // Reset form when dialog closes + React.useEffect(() => { + if (!open) { + // Small delay to avoid visual glitches when dialog is closing + const timer = setTimeout(() => { + if (!tbe) { + setResult("") + setNote("") + } + }, 300) + return () => clearTimeout(timer) + } + }, [open, tbe]) + + // Handle form submission with server action + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!tbe || !result) return + + setIsSubmitting(true) + + try { + // Call the server action to save the TBE result + const response = await saveTbeResult({ + id: tbe.tbeId ?? 0, // This is the id in the rfq_evaluations table + vendorId: tbe.vendorId, // This is the vendorId in the rfq_evaluations table + result: result, // The selected evaluation result + notes: note, // The evaluation notes + }) + + if (!response.success) { + throw new Error(response.message || "Failed to save TBE result") + } + + // Show success toast + toast.success("TBE result saved successfully") + + // Close the dialog + onOpenChange(false) + + // Refresh the data if refresh callback is provided + if (onRefresh) { + onRefresh() + } + } catch (error) { + // Show error toast + toast.error(`Failed to save: ${getErrorMessage(error)}`) + } finally { + setIsSubmitting(false) + } + } + + // Find the selected result option + const selectedOption = TBE_RESULT_OPTIONS.find(option => option.value === result) + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle className="text-xl font-semibold"> + {tbe?.tbeResult ? "Edit TBE Result" : "Enter TBE Result"} + </DialogTitle> + {tbe && ( + <DialogDescription className="text-sm text-muted-foreground mt-1"> + <div className="flex flex-col gap-1"> + <span> + <strong>Vendor:</strong> {tbe.vendorName} + </span> + <span> + <strong>RFQ Code:</strong> {tbe.rfqCode} + </span> + {tbe.email && ( + <span> + <strong>Email:</strong> {tbe.email} + </span> + )} + </div> + </DialogDescription> + )} + </DialogHeader> + + <form onSubmit={handleSubmit} className="space-y-6 py-2"> + <div className="space-y-2"> + <Label htmlFor="tbe-result" className="text-sm font-medium"> + Evaluation Result + </Label> + <Select + value={result} + onValueChange={(value) => setResult(value as TbeResultOption)} + required + > + <SelectTrigger id="tbe-result" className="w-full"> + <SelectValue placeholder="Select a result" /> + </SelectTrigger> + <SelectContent> + {TBE_RESULT_OPTIONS.map((option) => ( + <SelectItem key={option.value} value={option.value}> + <div className="flex items-center"> + <Badge variant={option.badgeVariant as any} className="mr-2"> + {option.label} + </Badge> + </div> + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + + <div className="space-y-2"> + <Label htmlFor="tbe-note" className="text-sm font-medium"> + Evaluation Note + </Label> + <Textarea + id="tbe-note" + placeholder="Enter evaluation notes..." + value={note} + onChange={(e) => setNote(e.target.value)} + className="min-h-[120px] resize-y" + /> + </div> + + <DialogFooter className="gap-2 sm:gap-0"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isSubmitting} + > + Cancel + </Button> + <Button + type="submit" + disabled={!result || isSubmitting} + className="min-w-[100px]" + > + {isSubmitting ? "Saving..." : "Save"} + </Button> + </DialogFooter> + </form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/tbe-table/tbe-table-columns.tsx b/lib/rfqs/tbe-table/tbe-table-columns.tsx index 0e9b7064..e8566831 100644 --- a/lib/rfqs/tbe-table/tbe-table-columns.tsx +++ b/lib/rfqs/tbe-table/tbe-table-columns.tsx @@ -11,21 +11,11 @@ import { formatDate } from "@/lib/utils" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" + import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" import { useRouter } from "next/navigation" import { - VendorTbeColumnConfig, vendorTbeColumnsConfig, VendorWithTbeFields, } from "@/config/vendorTbeColumnsConfig" @@ -39,6 +29,8 @@ interface GetColumnsProps { router: NextRouter openCommentSheet: (vendorId: number) => void openFilesDialog: (tbeId:number , vendorId: number) => void + openVendorContactsDialog: (vendorId: number, vendor: VendorWithTbeFields) => void // 수정된 시그니처 + } /** @@ -46,9 +38,9 @@ interface GetColumnsProps { */ export function getColumns({ setRowAction, - router, openCommentSheet, - openFilesDialog + openFilesDialog, + openVendorContactsDialog }: GetColumnsProps): ColumnDef<VendorWithTbeFields>[] { // ---------------------------------------------------------------- // 1) Select 컬럼 (체크박스) @@ -107,6 +99,85 @@ export function getColumns({ // 1) 필드값 가져오기 const val = getValue() + if (cfg.id === "vendorName") { + const vendor = row.original; + const vendorId = vendor.vendorId; + + // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링 + const handleVendorNameClick = () => { + if (vendorId) { + openVendorContactsDialog(vendorId, vendor); // 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 === "tbeResult") { + const vendor = row.original; + const tbeResult = vendor.tbeResult; + const filesCount = vendor.files?.length ?? 0; + + // Only show button or link if there are files + if (filesCount > 0) { + // Function to handle clicking on the result + const handleTbeResultClick = () => { + setRowAction({ row, type: "tbeResult" }); + }; + + if (!tbeResult) { + // No result yet, but files exist - show "결과 입력" button + return ( + <Button + variant="outline" + size="sm" + onClick={handleTbeResultClick} + > + 결과 입력 + </Button> + ); + } else { + // Result exists - show as a hyperlink + let badgeVariant: "default" | "outline" | "destructive" | "secondary" = "outline"; + + // Set badge variant based on result + if (tbeResult === "pass") { + badgeVariant = "default"; + } else if (tbeResult === "non-pass") { + badgeVariant = "destructive"; + } else if (tbeResult === "conditional pass") { + badgeVariant = "secondary"; + } + + return ( + <Button + variant="link" + className="p-0 h-auto underline" + onClick={handleTbeResultClick} + > + <Badge variant={badgeVariant}> + {tbeResult} + </Badge> + </Button> + ); + } + } + + // No files available, return empty cell + return null; + } + + if (cfg.id === "vendorStatus") { const statusVal = row.original.vendorStatus if (!statusVal) return null @@ -131,6 +202,8 @@ export function getColumns({ ) } + + // 예) TBE Updated (날짜) if (cfg.id === "tbeUpdated") { const dateVal = val as Date | undefined diff --git a/lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx b/lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx index 6a336135..a8f8ea82 100644 --- a/lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx +++ b/lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx @@ -28,18 +28,25 @@ export function VendorsTableToolbarActions({ table,rfqId }: VendorsTableToolbarA fileInputRef.current?.click() } + const invitationPossibeVendors = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(vendor => vendor.technicalResponseStatus === null); + }, [table.getFilteredSelectedRowModel().rows]); + return ( <div className="flex items-center gap-2"> - {table.getFilteredSelectedRowModel().rows.length > 0 ? ( - <InviteVendorsDialog - vendors={table - .getFilteredSelectedRowModel() - .rows.map((row) => row.original)} + {invitationPossibeVendors.length > 0 && + ( + <InviteVendorsDialog + vendors={invitationPossibeVendors} rfqId = {rfqId} onSuccess={() => table.toggleAllRowsSelected(false)} - /> - ) : null} - + /> + ) + } <Button variant="outline" diff --git a/lib/rfqs/tbe-table/tbe-table.tsx b/lib/rfqs/tbe-table/tbe-table.tsx index 41eff0dc..0add8927 100644 --- a/lib/rfqs/tbe-table/tbe-table.tsx +++ b/lib/rfqs/tbe-table/tbe-table.tsx @@ -21,6 +21,9 @@ import { InviteVendorsDialog } from "./invite-vendors-dialog" import { CommentSheet, TbeComment } from "./comments-sheet" import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" import { TBEFileDialog } from "./file-dialog" +import { TbeResultDialog } from "./tbe-result-dialog" +import { VendorContactsDialog } from "./vendor-contact-dialog" +import { useSession } from "next-auth/react" // Next-auth session hook 추가 interface VendorsTableProps { promises: Promise< @@ -37,8 +40,11 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) { // Suspense로 받아온 데이터 const [{ data, pageCount }] = React.use(promises) + console.log("data", data) + const { data: session } = useSession() // 세션 정보 가져오기 + + const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0 - console.log(data) const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithTbeFields> | null>(null) @@ -48,13 +54,12 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) { const [initialComments, setInitialComments] = React.useState<TbeComment[]>([]) const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) - + const [isFileDialogOpen, setIsFileDialogOpen] = React.useState(false) const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null) const [selectedTbeId, setSelectedTbeId] = React.useState<number | null>(null) - - console.log(selectedVendorId,"selectedVendorId") - console.log(rfqId,"rfqId") + const [isContactDialogOpen, setIsContactDialogOpen] = React.useState(false) + const [selectedVendor, setSelectedVendor] = React.useState<VendorWithTbeFields | null>(null) // Add handleRefresh function const handleRefresh = React.useCallback(() => { @@ -73,11 +78,14 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) { } }, [rowAction]) - async function openCommentSheet(vendorId: number) { + async function openCommentSheet(a: number) { setInitialComments([]) - + const comments = rowAction?.row.original.comments - + const rfqId = rowAction?.row.original.rfqId + const vendorId = rowAction?.row.original.vendorId + const tbeId = rowAction?.row.original.tbeId + console.log("original", rowAction?.row.original) if (comments && comments.length > 0) { const commentWithAttachments: TbeComment[] = await Promise.all( comments.map(async (c) => { @@ -85,7 +93,7 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) { return { ...c, - commentedBy: 1, // DB나 API 응답에 있다고 가정 + commentedBy: currentUserId, // DB나 API 응답에 있다고 가정 attachments, } }) @@ -93,8 +101,9 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) { // 3) state에 저장 -> CommentSheet에서 initialComments로 사용 setInitialComments(commentWithAttachments) } - - setSelectedRfqIdForComments(vendorId) + setSelectedTbeId(tbeId ?? 0) + setSelectedVendorId(vendorId ?? 0) + setSelectedRfqIdForComments(rfqId ?? 0) setCommentSheetOpen(true) } @@ -103,11 +112,15 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) { setSelectedVendorId(vendorId) setIsFileDialogOpen(true) } - + const openVendorContactsDialog = (vendorId: number, vendor: VendorWithTbeFields) => { + setSelectedVendorId(vendorId) + setSelectedVendor(vendor) + setIsContactDialogOpen(true) + } // getColumns() 호출 시, router를 주입 const columns = React.useMemo( - () => getColumns({ setRowAction, router, openCommentSheet, openFilesDialog }), + () => getColumns({ setRowAction, router, openCommentSheet, openFilesDialog, openVendorContactsDialog }), [setRowAction, router] ) @@ -141,18 +154,20 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) { enableAdvancedFilter: true, initialState: { sorting: [{ id: "rfqVendorUpdated", desc: true }], - columnPinning: { right: ["actions"] }, + columnPinning: { right: ["comments"] }, }, getRowId: (originalRow) => String(originalRow.id), shallow: false, clearOnDefault: true, }) + + return ( -<div style={{ maxWidth: '80vw' }}> + <div style={{ maxWidth: '80vw' }}> <DataTable table={table} - > + > <DataTableAdvancedToolbar table={table} filterFields={advancedFilterFields} @@ -169,11 +184,12 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) { showTrigger={false} /> <CommentSheet - currentUserId={1} + currentUserId={currentUserId} open={commentSheetOpen} onOpenChange={setCommentSheetOpen} rfqId={rfqId} - vendorId={selectedRfqIdForComments ?? 0} + tbeId={selectedTbeId ?? 0} + vendorId={selectedVendorId ?? 0} initialComments={initialComments} /> @@ -185,6 +201,20 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) { rfqId={rfqId} // Use the prop directly instead of data[0]?.rfqId onRefresh={handleRefresh} /> + + <TbeResultDialog + open={rowAction?.type === "tbeResult"} + onOpenChange={() => setRowAction(null)} + tbe={rowAction?.row.original ?? null} + /> + + <VendorContactsDialog + isOpen={isContactDialogOpen} + onOpenChange={setIsContactDialogOpen} + vendorId={selectedVendorId} + vendor={selectedVendor} + /> + </div> ) }
\ No newline at end of file diff --git a/lib/rfqs/tbe-table/vendor-contact-dialog.tsx b/lib/rfqs/tbe-table/vendor-contact-dialog.tsx new file mode 100644 index 00000000..3619fe77 --- /dev/null +++ b/lib/rfqs/tbe-table/vendor-contact-dialog.tsx @@ -0,0 +1,71 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { VendorContactsTable } from "./vendor-contact/vendor-contact-table" +import { Badge } from "@/components/ui/badge" +import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" + +interface VendorContactsDialogProps { + isOpen: boolean + onOpenChange: (open: boolean) => void + vendorId: number | null + vendor: VendorWithTbeFields | null +} + +export function VendorContactsDialog({ + isOpen, + onOpenChange, + vendorId, + vendor, +}: VendorContactsDialogProps) { + 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>협력업체 연락처</DialogTitle> + {vendor && ( + <div className="flex flex-col space-y-1 mt-2"> + <div className="text-sm text-muted-foreground"> + <span className="font-medium text-foreground">{vendor.vendorName}</span> + {vendor.vendorCode && ( + <span className="ml-2 text-xs text-muted-foreground">({vendor.vendorCode})</span> + )} + </div> + <div className="flex items-center"> + {vendor.vendorStatus && ( + <Badge variant="outline" className="mr-2"> + {vendor.vendorStatus} + </Badge> + )} + {vendor.rfqVendorStatus && ( + <Badge + variant={ + vendor.rfqVendorStatus === "INVITED" ? "default" : + vendor.rfqVendorStatus === "DECLINED" ? "destructive" : + vendor.rfqVendorStatus === "ACCEPTED" ? "secondary" : "outline" + } + > + {vendor.rfqVendorStatus} + </Badge> + )} + </div> + </div> + )} + </div> + </DialogHeader> + {vendorId && ( + <div className="py-4"> + <VendorContactsTable vendorId={vendorId} /> + </div> + )} + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table-column.tsx b/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table-column.tsx new file mode 100644 index 00000000..fcd0c3fb --- /dev/null +++ b/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table-column.tsx @@ -0,0 +1,70 @@ +"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 { VendorData } from "./vendor-contact-table" + + +/** getColumns: return array of ColumnDef for 'vendors' data */ +export function getColumns(): ColumnDef<VendorData>[] { + return [ + + // Vendor Name + { + accessorKey: "contactName", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Contact Name" /> + ), + cell: ({ row }) => row.getValue("contactName"), + }, + + // Vendor Code + { + accessorKey: "contactPosition", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Position" /> + ), + cell: ({ row }) => row.getValue("contactPosition"), + }, + + // Status + { + accessorKey: "contactEmail", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Email" /> + ), + cell: ({ row }) => row.getValue("contactEmail"), + }, + + // Country + { + accessorKey: "contactPhone", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Phone" /> + ), + cell: ({ row }) => row.getValue("contactPhone"), + }, + + // 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/rfqs/tbe-table/vendor-contact/vendor-contact-table.tsx b/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table.tsx new file mode 100644 index 00000000..c079da02 --- /dev/null +++ b/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table.tsx @@ -0,0 +1,89 @@ +'use client' + +import * as React from "react" +import { ClientDataTable } from "@/components/client-data-table/data-table" +import { getColumns } from "./vendor-contact-table-column" +import { DataTableAdvancedFilterField } from "@/types/table" +import { Loader2 } from "lucide-react" +import { useToast } from "@/hooks/use-toast" +import { getVendorContactsByVendorId } from "../../service" + +export interface VendorData { + id: number + contactName: string + contactPosition: string | null + contactEmail: string + contactPhone: string | null + isPrimary: boolean | null + createdAt: Date + updatedAt: Date +} + +interface VendorContactsTableProps { + vendorId: number +} + +export function VendorContactsTable({ vendorId }: VendorContactsTableProps) { + const { toast } = useToast() + + const columns = React.useMemo( + () => getColumns(), + [] + ) + + const [vendorContacts, setVendorContacts] = React.useState<VendorData[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + + React.useEffect(() => { + async function loadVendorContacts() { + setIsLoading(true) + try { + const result = await getVendorContactsByVendorId(vendorId) + if (result.success && result.data) { + // undefined 체크 추가 및 타입 캐스팅 + setVendorContacts(result.data as VendorData[]) + } else { + throw new Error(result.error || "Unknown error occurred") + } + } catch (error) { + console.error("협력업체 연락처 로드 오류:", error) + toast({ + title: "Error", + description: "Failed to load vendor contacts", + variant: "destructive", + }) + } finally { + setIsLoading(false) + } + } + loadVendorContacts() + }, [toast, vendorId]) + + const advancedFilterFields: DataTableAdvancedFilterField<VendorData>[] = [ + { id: "contactName", label: "Contact Name", type: "text" }, + { id: "contactPosition", label: "Posiotion", type: "text" }, + { id: "contactEmail", label: "Email", type: "text" }, + { id: "contactPhone", label: "Phone", 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={vendorContacts} + columns={columns} + advancedFilterFields={advancedFilterFields} + > + </ClientDataTable> + ) +}
\ No newline at end of file |
