diff options
Diffstat (limited to 'lib/tbe/table')
| -rw-r--r-- | lib/tbe/table/comments-sheet.tsx | 15 | ||||
| -rw-r--r-- | lib/tbe/table/invite-vendors-dialog.tsx | 8 | ||||
| -rw-r--r-- | lib/tbe/table/tbe-result-dialog.tsx | 208 | ||||
| -rw-r--r-- | lib/tbe/table/tbe-table-columns.tsx | 103 | ||||
| -rw-r--r-- | lib/tbe/table/tbe-table-toolbar-actions.tsx | 28 | ||||
| -rw-r--r-- | lib/tbe/table/tbe-table.tsx | 81 | ||||
| -rw-r--r-- | lib/tbe/table/vendor-contact-dialog.tsx | 71 |
7 files changed, 478 insertions, 36 deletions
diff --git a/lib/tbe/table/comments-sheet.tsx b/lib/tbe/table/comments-sheet.tsx index 7fcde35d..0952209d 100644 --- a/lib/tbe/table/comments-sheet.tsx +++ b/lib/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" @@ -50,7 +50,6 @@ import { // DB 스키마에서 필요한 타입들을 가져온다고 가정 // (실제 프로젝트에 맞춰 import를 수정하세요.) -import { RfqWithAll } from "@/db/schema/rfq" import { formatDate } from "@/lib/utils" import { createRfqCommentWithAttachments } from "@/lib/rfqs/service" @@ -77,6 +76,7 @@ interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { currentUserId: number rfqId:number vendorId:number + isLoading?: boolean // New prop /** 댓글 저장 후 갱신용 콜백 (옵션) */ onCommentsUpdated?: (comments: TbeComment[]) => void } @@ -96,6 +96,7 @@ export function CommentSheet({ initialComments = [], currentUserId, onCommentsUpdated, + isLoading = false, ...props }: CommentSheetProps) { const [comments, setComments] = React.useState<TbeComment[]>(initialComments) @@ -125,6 +126,16 @@ 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/tbe/table/invite-vendors-dialog.tsx b/lib/tbe/table/invite-vendors-dialog.tsx index 87467e57..59535278 100644 --- a/lib/tbe/table/invite-vendors-dialog.tsx +++ b/lib/tbe/table/invite-vendors-dialog.tsx @@ -39,6 +39,7 @@ interface InviteVendorsDialogProps rfqId: number showTrigger?: boolean onSuccess?: () => void + hasMultipleRfqIds?: boolean } export function InviteVendorsDialog({ @@ -46,6 +47,7 @@ export function InviteVendorsDialog({ rfqId, showTrigger = true, onSuccess, + hasMultipleRfqIds, ...props }: InviteVendorsDialogProps) { const [isInvitePending, startInviteTransition] = React.useTransition() @@ -105,10 +107,14 @@ export function InviteVendorsDialog({ /> </div> ) - + if (hasMultipleRfqIds) { + toast.error("동일한 RFQ에 대해 선택해주세요"); + return; + } // Desktop Dialog if (isDesktop) { return ( + <Dialog {...props}> {showTrigger ? ( <DialogTrigger asChild> diff --git a/lib/tbe/table/tbe-result-dialog.tsx b/lib/tbe/table/tbe-result-dialog.tsx new file mode 100644 index 00000000..59e2f49b --- /dev/null +++ b/lib/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 "@/lib/rfqs/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/tbe/table/tbe-table-columns.tsx b/lib/tbe/table/tbe-table-columns.tsx index 3b62fe06..8f0de88c 100644 --- a/lib/tbe/table/tbe-table-columns.tsx +++ b/lib/tbe/table/tbe-table-columns.tsx @@ -37,6 +37,8 @@ interface GetColumnsProps { router: NextRouter openCommentSheet: (vendorId: number, rfqId: number) => void openFilesDialog: (tbeId: number, vendorId: number, rfqId: number) => void + openVendorContactsDialog: (vendorId: number, vendor: VendorWithTbeFields) => void // 수정된 시그니처 + } @@ -47,7 +49,8 @@ export function getColumns({ setRowAction, router, openCommentSheet, - openFilesDialog + openFilesDialog, + openVendorContactsDialog }: GetColumnsProps): ColumnDef<VendorWithTbeFields>[] { // ---------------------------------------------------------------- // 1) Select 컬럼 (체크박스) @@ -105,6 +108,84 @@ export function getColumns({ cell: ({ row, getValue }) => { // 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 @@ -222,13 +303,27 @@ const commentsColumn: ColumnDef<VendorWithTbeFields> = { } return ( - <Button variant="ghost" size="sm" className="h-8 w-8 p-0 group relative" onClick={handleClick}> - <MessageSquare className="h-4 w-4" /> + <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="absolute -top-1 -right-1 h-4 min-w-[1rem] text-[0.625rem] p-0 flex items-center justify-center"> + <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> ) }, diff --git a/lib/tbe/table/tbe-table-toolbar-actions.tsx b/lib/tbe/table/tbe-table-toolbar-actions.tsx index 6a336135..cf6a041e 100644 --- a/lib/tbe/table/tbe-table-toolbar-actions.tsx +++ b/lib/tbe/table/tbe-table-toolbar-actions.tsx @@ -28,19 +28,31 @@ export function VendorsTableToolbarActions({ table,rfqId }: VendorsTableToolbarA fileInputRef.current?.click() } + // 선택된 행이 있는 경우 rfqId 확인 + const uniqueRfqIds = table.getFilteredSelectedRowModel().rows.length > 0 + ? [...new Set(table.getFilteredSelectedRowModel().rows.map(row => row.original.rfqId))] + : []; + + const hasMultipleRfqIds = uniqueRfqIds.length > 1; + + 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 ? ( + {invitationPossibeVendors.length > 0 && ( <InviteVendorsDialog - vendors={table - .getFilteredSelectedRowModel() - .rows.map((row) => row.original)} - rfqId = {rfqId} + vendors={invitationPossibeVendors} + rfqId={rfqId} onSuccess={() => table.toggleAllRowsSelected(false)} + hasMultipleRfqIds={hasMultipleRfqIds} /> - ) : null} - - + )} <Button variant="outline" size="sm" diff --git a/lib/tbe/table/tbe-table.tsx b/lib/tbe/table/tbe-table.tsx index e67b1d3d..83d601b3 100644 --- a/lib/tbe/table/tbe-table.tsx +++ b/lib/tbe/table/tbe-table.tsx @@ -15,12 +15,14 @@ import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-adv import { useFeatureFlags } from "./feature-flags-provider" import { getColumns } from "./tbe-table-columns" import { Vendor, vendors } from "@/db/schema/vendors" -import { InviteVendorsDialog } from "./invite-vendors-dialog" import { CommentSheet, TbeComment } from "./comments-sheet" import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" import { TBEFileDialog } from "./file-dialog" import { fetchRfqAttachmentsbyCommentId, getAllTBE } from "@/lib/rfqs/service" import { VendorsTableToolbarActions } from "./tbe-table-toolbar-actions" +import { TbeResultDialog } from "./tbe-result-dialog" +import { toast } from "sonner" +import { VendorContactsDialog } from "./vendor-contact-dialog" interface VendorsTableProps { promises: Promise<[ @@ -39,6 +41,8 @@ export function AllTbeTable({ promises }: VendorsTableProps) { // 댓글 시트 관련 state const [initialComments, setInitialComments] = React.useState<TbeComment[]>([]) + const [isLoadingComments, setIsLoadingComments] = React.useState(false) + const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) const [selectedVendorIdForComments, setSelectedVendorIdForComments] = React.useState<number | null>(null) const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) @@ -49,6 +53,10 @@ export function AllTbeTable({ promises }: VendorsTableProps) { const [selectedTbeIdForFiles, setSelectedTbeIdForFiles] = React.useState<number | null>(null) const [selectedRfqIdForFiles, setSelectedRfqIdForFiles] = React.useState<number | null>(null) + const [isContactDialogOpen, setIsContactDialogOpen] = React.useState(false) + const [selectedVendor, setSelectedVendor] = React.useState<VendorWithTbeFields | null>(null) + const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null) + // 테이블 리프레시용 const handleRefresh = React.useCallback(() => { router.refresh(); @@ -81,25 +89,33 @@ export function AllTbeTable({ promises }: VendorsTableProps) { // ----------------------------------------------------------- async function openCommentSheet(vendorId: number, rfqId: number) { setInitialComments([]) - + setIsLoadingComments(true) const comments = rowAction?.row.original.comments - if (comments && comments.length > 0) { - const commentWithAttachments: TbeComment[] = await Promise.all( - comments.map(async (c) => { - const attachments = await fetchRfqAttachmentsbyCommentId(c.id) - return { - ...c, - commentedBy: 1, // DB나 API 응답에 있다고 가정 - attachments, - } - }) - ) - setInitialComments(commentWithAttachments) + try { + if (comments && comments.length > 0) { + const commentWithAttachments: TbeComment[] = await Promise.all( + comments.map(async (c) => { + const attachments = await fetchRfqAttachmentsbyCommentId(c.id) + return { + ...c, + commentedBy: 1, // DB나 API 응답에 있다고 가정 + attachments, + } + }) + ) + setInitialComments(commentWithAttachments) + } + + setSelectedVendorIdForComments(vendorId) + setSelectedRfqIdForComments(rfqId) + 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) } - - setSelectedVendorIdForComments(vendorId) - setSelectedRfqIdForComments(rfqId) - setCommentSheetOpen(true) } // ----------------------------------------------------------- @@ -112,6 +128,13 @@ export function AllTbeTable({ promises }: VendorsTableProps) { setIsFileDialogOpen(true) } + const openVendorContactsDialog = (vendorId: number, vendor: VendorWithTbeFields) => { + setSelectedVendorId(vendorId) + setSelectedVendor(vendor) + setIsContactDialogOpen(true) + } + + // ----------------------------------------------------------- // 테이블 컬럼 // ----------------------------------------------------------- @@ -122,6 +145,7 @@ export function AllTbeTable({ promises }: VendorsTableProps) { router, openCommentSheet, // 필요하면 직접 호출 가능 openFilesDialog, + openVendorContactsDialog, }), [setRowAction, router] ) @@ -161,7 +185,7 @@ export function AllTbeTable({ promises }: VendorsTableProps) { enableAdvancedFilter: true, initialState: { sorting: [{ id: "rfqVendorUpdated", desc: true }], - columnPinning: { right: ["actions"] }, + columnPinning: { right: ["files", "comments"] }, }, getRowId: (originalRow) => (`${originalRow.id}${originalRow.rfqId}`), shallow: false, @@ -176,7 +200,7 @@ export function AllTbeTable({ promises }: VendorsTableProps) { filterFields={advancedFilterFields} shallow={false} > - <VendorsTableToolbarActions table={table} rfqId={selectedRfqIdForFiles ?? 0} /> + <VendorsTableToolbarActions table={table} rfqId={selectedRfqIdForFiles ?? 0} /> </DataTableAdvancedToolbar> </DataTable> @@ -186,7 +210,8 @@ export function AllTbeTable({ promises }: VendorsTableProps) { open={commentSheetOpen} onOpenChange={setCommentSheetOpen} vendorId={selectedVendorIdForComments ?? 0} - rfqId={selectedRfqIdForComments ?? 0} // ← 여기! + rfqId={selectedRfqIdForComments ?? 0} + isLoading={isLoadingComments} initialComments={initialComments} /> @@ -199,6 +224,20 @@ export function AllTbeTable({ promises }: VendorsTableProps) { rfqId={selectedRfqIdForFiles ?? 0} // ← 여기! onRefresh={handleRefresh} /> + + <TbeResultDialog + open={rowAction?.type === "tbeResult"} + onOpenChange={() => setRowAction(null)} + tbe={rowAction?.row.original ?? null} + /> + + <VendorContactsDialog + isOpen={isContactDialogOpen} + onOpenChange={setIsContactDialogOpen} + vendorId={selectedVendorId} + vendor={selectedVendor} + /> + </> ) }
\ No newline at end of file diff --git a/lib/tbe/table/vendor-contact-dialog.tsx b/lib/tbe/table/vendor-contact-dialog.tsx new file mode 100644 index 00000000..6c96d2ef --- /dev/null +++ b/lib/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 { Badge } from "@/components/ui/badge" +import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" +import { VendorContactsTable } from "@/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table" + +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 |
