diff options
Diffstat (limited to 'lib/rfqs')
25 files changed, 3179 insertions, 638 deletions
diff --git a/lib/rfqs/cbe-table/cbe-table-columns.tsx b/lib/rfqs/cbe-table/cbe-table-columns.tsx index 325b0465..bc16496f 100644 --- a/lib/rfqs/cbe-table/cbe-table-columns.tsx +++ b/lib/rfqs/cbe-table/cbe-table-columns.tsx @@ -34,8 +34,9 @@ interface GetColumnsProps { React.SetStateAction<DataTableRowAction<VendorWithCbeFields> | null> > router: NextRouter - openCommentSheet: (vendorId: number) => void - openFilesDialog: (cbeId:number , vendorId: number) => void + openCommentSheet: (responseId: number) => void + openVendorContactsDialog: (vendorId: number, vendor: VendorWithCbeFields) => void // 수정된 시그니처 + } /** @@ -45,7 +46,7 @@ export function getColumns({ setRowAction, router, openCommentSheet, - openFilesDialog + openVendorContactsDialog }: GetColumnsProps): ColumnDef<VendorWithCbeFields>[] { // ---------------------------------------------------------------- // 1) Select 컬럼 (체크박스) @@ -104,6 +105,30 @@ 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 === "vendorStatus") { const statusVal = row.original.vendorStatus if (!statusVal) return null @@ -116,8 +141,8 @@ export function getColumns({ } - if (cfg.id === "rfqVendorStatus") { - const statusVal = row.original.rfqVendorStatus + if (cfg.id === "responseStatus") { + const statusVal = row.original.responseStatus if (!statusVal) return null // const Icon = getStatusIcon(statusVal) const variant = statusVal ==="INVITED"?"default" :statusVal ==="DECLINED"?"destructive":statusVal ==="ACCEPTED"?"secondary":"outline" @@ -128,8 +153,8 @@ export function getColumns({ ) } - // 예) TBE Updated (날짜) - if (cfg.id === "cbeUpdated") { + // 예) CBE Updated (날짜) + if (cfg.id === "respondedAt" ) { const dateVal = val as Date | undefined if (!dateVal) return null return formatDate(dateVal) @@ -172,39 +197,32 @@ const commentsColumn: ColumnDef<VendorWithCbeFields> = { function handleClick() { // rowAction + openCommentSheet setRowAction({ row, type: "comments" }) - openCommentSheet(vendor.cbeId ?? 0) + openCommentSheet(vendor.responseId ?? 0) } return ( - <div className="flex items-center justify-center"> - <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"> - {commCount > 0 ? ( - <> - <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - <Badge - variant="secondary" - className="absolute -top-2 -right-2 h-4 min-w-4 text-xs px-1 flex items-center justify-center" - > - {commCount} - </Badge> - </> - ) : ( - <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - )} - </div> - <span className="sr-only">{commCount > 0 ? `${commCount} Comments` : "Add Comment"}</span> - </Button> - {/* <span className="ml-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={handleClick}> - {commCount > 0 ? `${commCount} Comments` : "Add Comment"} - </span> */} - </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/rfqs/cbe-table/cbe-table-toolbar-actions.tsx b/lib/rfqs/cbe-table/cbe-table-toolbar-actions.tsx new file mode 100644 index 00000000..fbcf9af9 --- /dev/null +++ b/lib/rfqs/cbe-table/cbe-table-toolbar-actions.tsx @@ -0,0 +1,67 @@ +"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 { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" +import { InviteVendorsDialog } from "./invite-vendors-dialog" + +interface VendorsTableToolbarActionsProps { + table: Table<VendorWithCbeFields> + rfqId: number +} + +export function VendorsTableToolbarActions({ table, rfqId }: VendorsTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // 파일이 선택되었을 때 처리 + + function handleImportClick() { + // 숨겨진 <input type="file" /> 요소를 클릭 + fileInputRef.current?.click() + } + + const invitationPossibeVendors = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(vendor => vendor.commercialResponseStatus === null); + }, [table.getFilteredSelectedRowModel().rows]); + + return ( + <div className="flex items-center gap-2"> + {invitationPossibeVendors.length > 0 && + ( + <InviteVendorsDialog + vendors={invitationPossibeVendors} + rfqId={rfqId} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + ) + } + + <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/rfqs/cbe-table/cbe-table.tsx b/lib/rfqs/cbe-table/cbe-table.tsx index b2a74466..37fbc3f4 100644 --- a/lib/rfqs/cbe-table/cbe-table.tsx +++ b/lib/rfqs/cbe-table/cbe-table.tsx @@ -8,16 +8,17 @@ import type { DataTableRowAction, } from "@/types/table" -import { toSentenceCase } from "@/lib/utils" 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 { Vendor, vendors } from "@/db/schema/vendors" import { fetchRfqAttachmentsbyCommentId, getCBE } from "../service" -import { TbeComment } from "../tbe-table/comments-sheet" import { getColumns } from "./cbe-table-columns" import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" +import { CommentSheet, CbeComment } from "./comments-sheet" +import { useSession } from "next-auth/react" // Next-auth session hook 추가 +import { VendorContactsDialog } from "./vendor-contact-dialog" +import { InviteVendorsDialog } from "./invite-vendors-dialog" +import { VendorsTableToolbarActions } from "./cbe-table-toolbar-actions" interface VendorsTableProps { promises: Promise< @@ -30,56 +31,54 @@ interface VendorsTableProps { export function CbeTable({ promises, rfqId }: VendorsTableProps) { - const { featureFlags } = useFeatureFlags() // Suspense로 받아온 데이터 const [{ data, pageCount }] = React.use(promises) + const { data: session } = useSession() // 세션 정보 가져오기 + + const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0 + const currentUser = session?.user - console.log(data, "data") const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithCbeFields> | null>(null) // **router** 획득 const router = useRouter() - const [initialComments, setInitialComments] = React.useState<TbeComment[]>([]) + const [initialComments, setInitialComments] = React.useState<CbeComment[]>([]) 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) + const [isLoadingComments, setIsLoadingComments] = React.useState(false) + // const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) - // Add handleRefresh function - const handleRefresh = React.useCallback(() => { - router.refresh(); - }, [router]); + const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null) + const [selectedCbeId, setSelectedCbeId] = React.useState<number | null>(null) + const [isContactDialogOpen, setIsContactDialogOpen] = React.useState(false) + const [selectedVendor, setSelectedVendor] = React.useState<VendorWithCbeFields | null>(null) + // console.log("selectedVendorId", selectedVendorId) + // console.log("selectedCbeId", selectedCbeId) React.useEffect(() => { if (rowAction?.type === "comments") { // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행 - openCommentSheet(Number(rowAction.row.original.id)) - } else if (rowAction?.type === "files") { - // Handle files action - const vendorId = rowAction.row.original.vendorId; - const cbeId = rowAction.row.original.cbeId ?? 0; - openFilesDialog(cbeId, vendorId); - } + openCommentSheet(Number(rowAction.row.original.responseId)) + } }, [rowAction]) - async function openCommentSheet(vendorId: number) { + async function openCommentSheet(responseId: number) { setInitialComments([]) - + setIsLoadingComments(true) const comments = rowAction?.row.original.comments + // const rfqId = rowAction?.row.original.rfqId + const vendorId = rowAction?.row.original.vendorId if (comments && comments.length > 0) { - const commentWithAttachments: TbeComment[] = await Promise.all( + const commentWithAttachments: CbeComment[] = await Promise.all( comments.map(async (c) => { const attachments = await fetchRfqAttachmentsbyCommentId(c.id) return { ...c, - commentedBy: 1, // DB나 API 응답에 있다고 가정 + commentedBy: currentUserId, // DB나 API 응답에 있다고 가정 attachments, } }) @@ -88,20 +87,22 @@ export function CbeTable({ promises, rfqId }: VendorsTableProps) { setInitialComments(commentWithAttachments) } - setSelectedRfqIdForComments(vendorId) + // if(rfqId){ setSelectedRfqIdForComments(rfqId)} + if(vendorId){ setSelectedVendorId(vendorId)} + setSelectedCbeId(responseId) setCommentSheetOpen(true) + setIsLoadingComments(false) } - const openFilesDialog = (cbeId: number, vendorId: number) => { - setSelectedTbeId(cbeId) + const openVendorContactsDialog = (vendorId: number, vendor: VendorWithCbeFields) => { setSelectedVendorId(vendorId) - setIsFileDialogOpen(true) + setSelectedVendor(vendor) + setIsContactDialogOpen(true) } - // getColumns() 호출 시, router를 주입 const columns = React.useMemo( - () => getColumns({ setRowAction, router, openCommentSheet, openFilesDialog }), + () => getColumns({ setRowAction, router, openCommentSheet, openVendorContactsDialog }), [setRowAction, router] ) @@ -111,18 +112,7 @@ export function CbeTable({ promises, rfqId }: VendorsTableProps) { const advancedFilterFields: DataTableAdvancedFilterField<VendorWithCbeFields>[] = [ { 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: "rfqVendorUpdated", label: "Updated at", type: "date" }, + { id: "respondedAt", label: "Updated at", type: "date" }, ] @@ -134,32 +124,55 @@ export function CbeTable({ promises, rfqId }: VendorsTableProps) { enablePinning: true, enableAdvancedFilter: true, initialState: { - sorting: [{ id: "rfqVendorUpdated", desc: true }], - columnPinning: { right: ["actions"] }, + sorting: [{ id: "respondedAt", desc: true }], + columnPinning: { right: ["comments"] }, }, - getRowId: (originalRow) => String(originalRow.id), + getRowId: (originalRow) => String(originalRow.responseId), shallow: false, clearOnDefault: true, }) return ( <> -<div style={{ maxWidth: '80vw' }}> -<DataTable + <DataTable table={table} - // tableContainerClass="sm:max-w-[80vw] md:max-w-[80vw] lg:max-w-[80vw]" - // tableContainerClass="max-w-[80vw]" > <DataTableAdvancedToolbar table={table} filterFields={advancedFilterFields} shallow={false} > - {/* <VendorsTableToolbarActions table={table} rfqId={rfqId} /> */} + <VendorsTableToolbarActions table={table} rfqId={rfqId} /> </DataTableAdvancedToolbar> </DataTable> - </div> - + + <CommentSheet + currentUserId={currentUserId} + open={commentSheetOpen} + onOpenChange={setCommentSheetOpen} + rfqId={rfqId} + cbeId={selectedCbeId ?? 0} + vendorId={selectedVendorId ?? 0} + isLoading={isLoadingComments} + initialComments={initialComments} + /> + + <InviteVendorsDialog + vendors={rowAction?.row.original ? [rowAction?.row.original] : []} + onOpenChange={() => setRowAction(null)} + rfqId={rfqId} + open={rowAction?.type === "invite"} + showTrigger={false} + currentUser={currentUser} + /> + + <VendorContactsDialog + isOpen={isContactDialogOpen} + onOpenChange={setIsContactDialogOpen} + vendorId={selectedVendorId} + vendor={selectedVendor} + /> + </> ) }
\ No newline at end of file diff --git a/lib/rfqs/cbe-table/comments-sheet.tsx b/lib/rfqs/cbe-table/comments-sheet.tsx new file mode 100644 index 00000000..e91a0617 --- /dev/null +++ b/lib/rfqs/cbe-table/comments-sheet.tsx @@ -0,0 +1,328 @@ +"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 { createRfqCommentWithAttachments } from "../service" +import { formatDate } from "@/lib/utils" + + +export interface CbeComment { + id: number + commentText: string + commentedBy?: number + commentedByEmail?: string + createdAt?: Date + attachments?: { + id: number + fileName: string + filePath: string + }[] +} + +// 1) props 정의 +interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { + initialComments?: CbeComment[] + currentUserId: number + rfqId: number + // tbeId?: number + cbeId?: number + vendorId: number + onCommentsUpdated?: (comments: CbeComment[]) => void + isLoading?: boolean // New prop +} + +// 2) 폼 스키마 +const commentFormSchema = z.object({ + commentText: z.string().min(1, "댓글을 입력하세요."), + newFiles: z.array(z.any()).optional(), // File[] +}) +type CommentFormValues = z.infer<typeof commentFormSchema> + +const MAX_FILE_SIZE = 30e6 // 30MB + +export function CommentSheet({ + rfqId, + vendorId, + initialComments = [], + currentUserId, + // tbeId, + cbeId, + onCommentsUpdated, + isLoading = false, // Default to false + ...props +}: CommentSheetProps) { + + + const [comments, setComments] = React.useState<CbeComment[]>(initialComments) + const [isPending, startTransition] = React.useTransition() + + React.useEffect(() => { + setComments(initialComments) + }, [initialComments]) + + const form = useForm<CommentFormValues>({ + resolver: zodResolver(commentFormSchema), + defaultValues: { + commentText: "", + newFiles: [], + }, + }) + + const { fields: newFileFields, append, remove } = useFieldArray({ + control: form.control, + name: "newFiles", + }) + + // (A) 기존 코멘트 렌더링 + function renderExistingComments() { + + if (isLoading) { + return ( + <div className="flex justify-center items-center h-32"> + <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> + <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span> + </div> + ) + } + + if (comments.length === 0) { + return <p className="text-sm text-muted-foreground">No comments yet</p> + } + return ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-1/2">Comment</TableHead> + <TableHead>Attachments</TableHead> + <TableHead>Created At</TableHead> + <TableHead>Created By</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {comments.map((c) => ( + <TableRow key={c.id}> + <TableCell>{c.commentText}</TableCell> + <TableCell> + {!c.attachments?.length && ( + <span className="text-sm text-muted-foreground">No files</span> + )} + {c.attachments?.length && ( + <div className="flex flex-col gap-1"> + {c.attachments.map((att) => ( + <div key={att.id} className="flex items-center gap-2"> + <a + href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`} + download + target="_blank" + rel="noreferrer" + className="inline-flex items-center gap-1 text-blue-600 underline" + > + <Download className="h-4 w-4" /> + {att.fileName} + </a> + </div> + ))} + </div> + )} + </TableCell> + <TableCell> {c.createdAt ? formatDate(c.createdAt) : "-"}</TableCell> + <TableCell>{c.commentedByEmail ?? "-"}</TableCell> + </TableRow> + ))} + </TableBody> + </Table> + ) + } + + // (B) 파일 드롭 + function handleDropAccepted(files: File[]) { + append(files) + } + + // (C) Submit + async function onSubmit(data: CommentFormValues) { + if (!rfqId) return + startTransition(async () => { + try { + // console.log("rfqId", rfqId) + // console.log("vendorId", vendorId) + // console.log("cbeId", cbeId) + // console.log("currentUserId", currentUserId) + + const res = await createRfqCommentWithAttachments({ + rfqId, + vendorId, + commentText: data.commentText, + commentedBy: currentUserId, + evaluationId: null, + cbeId: cbeId, + files: data.newFiles, + }) + + if (!res.ok) { + throw new Error("Failed to create comment") + } + + toast.success("Comment created") + + // 임시로 새 코멘트 추가 + const newComment: CbeComment = { + id: res.commentId, // 서버 응답 + commentText: data.commentText, + commentedBy: currentUserId, + createdAt: res.createdAt, + attachments: + data.newFiles?.map((f) => ({ + id: Math.floor(Math.random() * 1e6), + fileName: f.name, + filePath: "/uploads/" + f.name, + })) || [], + } + setComments((prev) => [...prev, newComment]) + onCommentsUpdated?.([...comments, newComment]) + + form.reset() + } catch (err: any) { + console.error(err) + toast.error("Error: " + err.message) + } + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-lg"> + <SheetHeader className="text-left"> + <SheetTitle>Comments</SheetTitle> + <SheetDescription> + 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다. + </SheetDescription> + </SheetHeader> + + <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + <FormField + control={form.control} + name="commentText" + render={({ field }) => ( + <FormItem> + <FormLabel>New Comment</FormLabel> + <FormControl> + <Textarea placeholder="Enter your comment..." {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <Dropzone + maxSize={MAX_FILE_SIZE} + onDropAccepted={handleDropAccepted} + onDropRejected={(rej) => { + toast.error("File rejected: " + (rej[0]?.file?.name || "")) + }} + > + {({ maxSize }) => ( + <DropzoneZone className="flex justify-center"> + <DropzoneInput /> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>Drop to attach files</DropzoneTitle> + <DropzoneDescription> + Max size: {prettyBytes(maxSize || 0)} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + )} + </Dropzone> + + {newFileFields.length > 0 && ( + <div className="flex flex-col gap-2"> + {newFileFields.map((field, idx) => { + const file = form.getValues(`newFiles.${idx}`) + if (!file) return null + return ( + <div + key={field.id} + className="flex items-center justify-between border rounded p-2" + > + <span className="text-sm"> + {file.name} ({prettyBytes(file.size)}) + </span> + <Button + variant="ghost" + size="icon" + type="button" + onClick={() => remove(idx)} + > + <X className="h-4 w-4" /> + </Button> + </div> + ) + })} + </div> + )} + + <SheetFooter className="gap-2 pt-4"> + <SheetClose asChild> + <Button type="button" variant="outline"> + Cancel + </Button> + </SheetClose> + <Button disabled={isPending}> + {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/cbe-table/feature-flags-provider.tsx b/lib/rfqs/cbe-table/feature-flags-provider.tsx deleted file mode 100644 index 81131894..00000000 --- a/lib/rfqs/cbe-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/rfqs/cbe-table/invite-vendors-dialog.tsx b/lib/rfqs/cbe-table/invite-vendors-dialog.tsx new file mode 100644 index 00000000..18edbe80 --- /dev/null +++ b/lib/rfqs/cbe-table/invite-vendors-dialog.tsx @@ -0,0 +1,423 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader, Send, User } from "lucide-react" +import { toast } from "sonner" +import { z } from "zod" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form" +import { type Row } from "@tanstack/react-table" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" + +import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { createCbeEvaluation } from "../service" + +// 컴포넌트 내부에서 사용할 폼 스키마 정의 +const formSchema = z.object({ + paymentTerms: z.string().min(1, "결제 조건을 입력하세요"), + incoterms: z.string().min(1, "Incoterms를 입력하세요"), + deliverySchedule: z.string().min(1, "배송 일정을 입력하세요"), + notes: z.string().optional(), +}) + +type FormValues = z.infer<typeof formSchema> + +interface InviteVendorsDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + rfqId: number + vendors: Row<VendorWithCbeFields>["original"][] + currentUserId?: number + currentUser?: { + id: string + name?: string | null + email?: string | null + image?: string | null + companyId?: number | null + domain?: string | null + } + showTrigger?: boolean + onSuccess?: () => void +} + +export function InviteVendorsDialog({ + rfqId, + vendors, + currentUserId, + currentUser, + showTrigger = true, + onSuccess, + ...props +}: InviteVendorsDialogProps) { + const [files, setFiles] = React.useState<FileList | null>(null) + const isDesktop = useMediaQuery("(min-width: 640px)") + const [isSubmitting, setIsSubmitting] = React.useState(false) + + // 로컬 스키마와 폼 값을 사용하도록 수정 + const form = useForm<FormValues>({ + resolver: zodResolver(formSchema), + defaultValues: { + paymentTerms: "", + incoterms: "", + deliverySchedule: "", + notes: "", + }, + mode: "onChange", + }) + + // 폼 상태 감시 + const { formState } = form + const isValid = formState.isValid && + !!form.getValues("paymentTerms") && + !!form.getValues("incoterms") && + !!form.getValues("deliverySchedule") + + // 디버깅용 상태 트래킹 + React.useEffect(() => { + const subscription = form.watch((value) => { + // 폼 값이 변경될 때마다 실행되는 콜백 + console.log("Form values changed:", value); + }); + + return () => subscription.unsubscribe(); + }, [form]); + + async function onSubmit(data: FormValues) { + try { + setIsSubmitting(true) + + // 기본 FormData 생성 + const formData = new FormData() + + // rfqId 추가 + formData.append("rfqId", String(rfqId)) + + // 폼 데이터 추가 + Object.entries(data).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + formData.append(key, String(value)) + } + }) + + // 현재 사용자 ID 추가 + if (currentUserId) { + formData.append("evaluatedBy", String(currentUserId)) + } + + // 협력업체 ID만 추가 (서버에서 연락처 정보를 조회) + vendors.forEach((vendor) => { + formData.append("vendorIds[]", String(vendor.vendorId)) + }) + + // 파일 추가 (있는 경우에만) + if (files && files.length > 0) { + for (let i = 0; i < files.length; i++) { + formData.append("files", files[i]) + } + } + + // 서버 액션 호출 + const response = await createCbeEvaluation(formData) + + if (response.error) { + toast.error(response.error) + return + } + + // 성공 처리 + toast.success(`${vendors.length}개 협력업체에 CBE 평가가 성공적으로 전송되었습니다!`) + form.reset() + setFiles(null) + props.onOpenChange?.(false) + onSuccess?.() + } catch (error) { + console.error(error) + toast.error("CBE 평가 생성 중 오류가 발생했습니다.") + } finally { + setIsSubmitting(false) + } + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + setFiles(null) + } + props.onOpenChange?.(nextOpen) + } + + // 필수 필드 라벨에 추가할 요소 + const RequiredLabel = ( + <span className="text-destructive ml-1 font-medium">*</span> + ) + + const formContent = ( + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + {/* 선택된 협력업체 정보 표시 */} + <div className="space-y-2"> + <FormLabel>선택된 협력업체 ({vendors.length})</FormLabel> + <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> + <FormDescription> + 선택된 모든 협력업체의 등록된 연락처에게 CBE 평가 알림이 전송됩니다. + </FormDescription> + </div> + + {/* 작성자 정보 (읽기 전용) */} + {currentUser && ( + <div className="border rounded-md p-3 space-y-2"> + <FormLabel>작성자</FormLabel> + <div className="flex items-center gap-3"> + {currentUser.image ? ( + <Avatar className="h-8 w-8"> + <AvatarImage src={currentUser.image} alt={currentUser.name || ""} /> + <AvatarFallback> + {currentUser.name?.charAt(0) || <User className="h-4 w-4" />} + </AvatarFallback> + </Avatar> + ) : ( + <Avatar className="h-8 w-8"> + <AvatarFallback> + {currentUser.name?.charAt(0) || <User className="h-4 w-4" />} + </AvatarFallback> + </Avatar> + )} + <div> + <p className="text-sm font-medium">{currentUser.name || "Unknown User"}</p> + <p className="text-xs text-muted-foreground">{currentUser.email || ""}</p> + </div> + </div> + </div> + )} + + {/* 결제 조건 - 필수 필드 */} + <FormField + control={form.control} + name="paymentTerms" + render={({ field }) => ( + <FormItem> + <FormLabel> + 결제 조건{RequiredLabel} + </FormLabel> + <FormControl> + <Input {...field} placeholder="예: Net 30" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Incoterms - 필수 필드 */} + <FormField + control={form.control} + name="incoterms" + render={({ field }) => ( + <FormItem> + <FormLabel> + Incoterms{RequiredLabel} + </FormLabel> + <FormControl> + <Input {...field} placeholder="예: FOB, CIF" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 배송 일정 - 필수 필드 */} + <FormField + control={form.control} + name="deliverySchedule" + render={({ field }) => ( + <FormItem> + <FormLabel> + 배송 일정{RequiredLabel} + </FormLabel> + <FormControl> + <Textarea + {...field} + placeholder="배송 일정 세부사항을 입력하세요" + rows={3} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 비고 - 선택적 필드 */} + <FormField + control={form.control} + name="notes" + render={({ field }) => ( + <FormItem> + <FormLabel>비고</FormLabel> + <FormControl> + <Textarea + {...field} + placeholder="추가 비고 사항을 입력하세요" + rows={3} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 파일 첨부 (옵션) */} + <div className="space-y-2"> + <FormLabel htmlFor="files">첨부 파일 (선택사항)</FormLabel> + <Input + id="files" + type="file" + multiple + onChange={(e) => setFiles(e.target.files)} + /> + {files && files.length > 0 && ( + <p className="text-sm text-muted-foreground"> + {files.length}개 파일이 첨부되었습니다 + </p> + )} + </div> + + {/* 필수 입력 항목 안내 */} + <div className="text-sm text-muted-foreground"> + <span className="text-destructive">*</span> 표시는 필수 입력 항목입니다. + </div> + + {/* 모바일에서는 Drawer 내부에서 버튼이 렌더링되므로 여기서는 숨김 */} + {isDesktop && ( + <DialogFooter className="gap-2 pt-4"> + <DialogClose asChild> + <Button + type="button" + variant="outline" + > + 취소 + </Button> + </DialogClose> + <Button + type="submit" + disabled={isSubmitting || !isValid} + > + {isSubmitting && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + {vendors.length > 1 ? `${vendors.length}개 협력업체에 전송` : "전송"} + </Button> + </DialogFooter> + )} + </form> + </Form> + ) + + // Desktop Dialog + if (isDesktop) { + return ( + <Dialog {...props} onOpenChange={handleDialogOpenChange}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Send className="mr-2 size-4" aria-hidden="true" /> + CBE 평가 전송 ({vendors.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent className="sm:max-w-[600px]"> + <DialogHeader> + <DialogTitle>CBE 평가 생성 및 전송</DialogTitle> + <DialogDescription> + 선택한 {vendors.length}개 협력업체에 대한 상업 입찰 평가를 생성하고 알림을 전송합니다. + </DialogDescription> + </DialogHeader> + + {formContent} + </DialogContent> + </Dialog> + ) + } + + // Mobile Drawer + return ( + <Drawer {...props} onOpenChange={handleDialogOpenChange}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Send className="mr-2 size-4" aria-hidden="true" /> + CBE 평가 전송 ({vendors.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>CBE 평가 생성 및 전송</DrawerTitle> + <DrawerDescription> + 선택한 {vendors.length}개 협력업체에 대한 상업 입찰 평가를 생성하고 알림을 전송합니다. + </DrawerDescription> + </DrawerHeader> + + <div className="px-4"> + {formContent} + </div> + + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">취소</Button> + </DrawerClose> + <Button + onClick={form.handleSubmit(onSubmit)} + disabled={isSubmitting || !isValid} + > + {isSubmitting && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + {vendors.length > 1 ? `${vendors.length}개 협력업체에 전송` : "전송"} + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/cbe-table/vendor-contact-dialog.tsx b/lib/rfqs/cbe-table/vendor-contact-dialog.tsx new file mode 100644 index 00000000..180db392 --- /dev/null +++ b/lib/rfqs/cbe-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 { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" +import { VendorContactsTable } from "../tbe-table/vendor-contact/vendor-contact-table" + +interface VendorContactsDialogProps { + isOpen: boolean + onOpenChange: (open: boolean) => void + vendorId: number | null + vendor: VendorWithCbeFields | 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.commercialResponseStatus && ( + <Badge + variant={ + vendor.commercialResponseStatus === "INVITED" ? "default" : + vendor.commercialResponseStatus === "DECLINED" ? "destructive" : + vendor.commercialResponseStatus === "ACCEPTED" ? "secondary" : "outline" + } + > + {vendor.commercialResponseStatus} + </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/repository.ts b/lib/rfqs/repository.ts index ad44cf07..24d09ec3 100644 --- a/lib/rfqs/repository.ts +++ b/lib/rfqs/repository.ts @@ -1,7 +1,7 @@ // src/lib/tasks/repository.ts import db from "@/db/db"; import { items } from "@/db/schema/items"; -import { rfqItems, rfqs, RfqWithItems, rfqsView, type Rfq,VendorResponse, vendorResponses } from "@/db/schema/rfq"; +import { rfqItems, rfqs, RfqWithItems, rfqsView, type Rfq,VendorResponse, vendorResponses, RfqViewWithItems } from "@/db/schema/rfq"; import { users } from "@/db/schema/users"; import { eq, @@ -177,12 +177,12 @@ export async function insertRfqItem( return tx.insert(rfqItems).values(data).returning(); } -export const getRfqById = async (id: number): Promise<RfqWithItems | null> => { +export const getRfqById = async (id: number): Promise<RfqViewWithItems | null> => { // 1) RFQ 단건 조회 const rfqsRes = await db .select() - .from(rfqs) - .where(eq(rfqs.id, id)) + .from(rfqsView) + .where(eq(rfqsView.id, id)) .limit(1); if (rfqsRes.length === 0) return null; @@ -197,7 +197,7 @@ export const getRfqById = async (id: number): Promise<RfqWithItems | null> => { // itemsRes: RfqItem[] // 3) RfqWithItems 형태로 반환 - const result: RfqWithItems = { + const result: RfqViewWithItems = { ...rfqRow, lines: itemsRes, }; diff --git a/lib/rfqs/service.ts b/lib/rfqs/service.ts index b56349e2..c7d1c3cd 100644 --- a/lib/rfqs/service.ts +++ b/lib/rfqs/service.ts @@ -8,7 +8,7 @@ import { filterColumns } from "@/lib/filter-columns"; import { unstable_cache } from "@/lib/unstable-cache"; import { getErrorMessage } from "@/lib/handle-error"; -import { GetRfqsSchema, CreateRfqSchema, UpdateRfqSchema, CreateRfqItemSchema, GetMatchedVendorsSchema, GetRfqsForVendorsSchema, UpdateRfqVendorSchema, GetTBESchema, RfqType, GetCBESchema } from "./validations"; +import { GetRfqsSchema, CreateRfqSchema, UpdateRfqSchema, CreateRfqItemSchema, GetMatchedVendorsSchema, GetRfqsForVendorsSchema, UpdateRfqVendorSchema, GetTBESchema, RfqType, GetCBESchema, createCbeEvaluationSchema } from "./validations"; import { asc, desc, ilike, inArray, and, gte, lte, not, or, sql, eq, isNull, ne, isNotNull, count } from "drizzle-orm"; import path from "path"; import fs from "fs/promises"; @@ -16,15 +16,16 @@ import { randomUUID } from "crypto"; import { writeFile, mkdir } from 'fs/promises' import { join } from 'path' -import { vendorResponses, vendorResponsesView, Rfq, rfqs, rfqAttachments, rfqItems, RfqWithItems, rfqComments, rfqEvaluations, vendorRfqView, vendorTbeView, rfqsView, vendorResponseAttachments, vendorTechnicalResponses, vendorCbeView, cbeEvaluations, vendorCommercialResponses } from "@/db/schema/rfq"; +import { vendorResponses, vendorResponsesView, Rfq, rfqs, rfqAttachments, rfqItems, RfqWithItems, rfqComments, rfqEvaluations, vendorRfqView, vendorTbeView, rfqsView, vendorResponseAttachments, vendorTechnicalResponses, vendorCbeView, cbeEvaluations, vendorCommercialResponses, vendorResponseCBEView, RfqViewWithItems } from "@/db/schema/rfq"; import { countRfqs, deleteRfqById, deleteRfqsByIds, getRfqById, groupByStatus, insertRfq, insertRfqItem, selectRfqs, updateRfq, updateRfqs, updateRfqVendor } from "./repository"; import logger from '@/lib/logger'; -import { vendorPossibleItems, vendors } from "@/db/schema/vendors"; +import { vendorContacts, vendorPossibleItems, vendors } from "@/db/schema/vendors"; import { sendEmail } from "../mail/sendEmail"; -import { projects } from "@/db/schema/projects"; +import { biddingProjects, projects } from "@/db/schema/projects"; import { items } from "@/db/schema/items"; import * as z from "zod" import { users } from "@/db/schema/users"; +import { headers } from 'next/headers'; interface InviteVendorsInput { @@ -176,6 +177,7 @@ export async function createRfq(input: CreateRfqSchema) { const [newTask] = await insertRfq(tx, { rfqCode: input.rfqCode, projectId: input.projectId || null, + bidProjectId: input.bidProjectId || null, description: input.description || null, dueDate: input.dueDate, status: input.status, @@ -547,7 +549,7 @@ export async function fetchRfqItems(rfqId: number) { })) } -export const findRfqById = async (id: number): Promise<RfqWithItems | null> => { +export const findRfqById = async (id: number): Promise<RfqViewWithItems | null> => { try { logger.info({ id }, 'Fetching user by ID'); const rfq = await getRfqById(id); @@ -726,13 +728,16 @@ export async function getMatchedVendors(input: GetMatchedVendorsSchema, rfqId: n // ───────────────────────────────────────────────────── // 5) 코멘트 조회: 기존과 동일 // ───────────────────────────────────────────────────── + console.log("distinctVendorIds", distinctVendorIds) const commAll = await db .select() .from(rfqComments) .where( and( inArray(rfqComments.vendorId, distinctVendorIds), - eq(rfqComments.rfqId, rfqId) + eq(rfqComments.rfqId, rfqId), + isNull(rfqComments.evaluationId), + isNull(rfqComments.cbeId) ) ) @@ -756,7 +761,7 @@ export async function getMatchedVendors(input: GetMatchedVendorsSchema, rfqId: n userMap.set(user.id, user); } - // 댓글 정보를 벤더 ID별로 그룹화하고, 사용자 이메일 추가 + // 댓글 정보를 협력업체 ID별로 그룹화하고, 사용자 이메일 추가 for (const c of commAll) { const vid = c.vendorId! if (!commByVendorId.has(vid)) { @@ -804,6 +809,9 @@ export async function inviteVendors(input: InviteVendorsInput) { throw new Error("Invalid input") } + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + // DB 데이터 준비 및 첨부파일 처리를 위한 트랜잭션 const rfqData = await db.transaction(async (tx) => { // 2-A) RFQ 기본 정보 조회 @@ -869,8 +877,7 @@ export async function inviteVendors(input: InviteVendorsInput) { }) const { rfqRow, items, vendorRows, attachments } = rfqData - const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000' - const loginUrl = `${baseUrl}/en/partners/rfq` + const loginUrl = `http://${host}/en/partners/rfq` // 이메일 전송 오류를 기록할 배열 const emailErrors = [] @@ -878,11 +885,11 @@ export async function inviteVendors(input: InviteVendorsInput) { // 각 벤더에 대해 처리 for (const v of vendorRows) { if (!v.email) { - continue // 이메일 없는 벤더 무시 + continue // 이메일 없는 협력업체 무시 } try { - // DB 업데이트: 각 벤더 상태 별도 트랜잭션 + // DB 업데이트: 각 협력업체 상태 별도 트랜잭션 await db.transaction(async (tx) => { // rfq_vendors upsert const existing = await tx @@ -932,10 +939,10 @@ export async function inviteVendors(input: InviteVendorsInput) { attachments, }) } catch (err) { - // 개별 벤더 처리 실패 로깅 + // 개별 협력업체 처리 실패 로깅 console.error(`Failed to process vendor ${v.id}: ${getErrorMessage(err)}`) emailErrors.push({ vendorId: v.id, error: getErrorMessage(err) }) - // 계속 진행 (다른 벤더 처리) + // 계속 진행 (다른 협력업체 처리) } } @@ -1015,7 +1022,7 @@ export async function getTBE(input: GetTBESchema, rfqId: number) { // 5) finalWhere const finalWhere = and( eq(vendorTbeView.rfqId, rfqId), - notRejected, + // notRejected, advancedWhere, globalWhere ) @@ -1057,6 +1064,12 @@ export async function getTBE(input: GetTBESchema, rfqId: number) { tbeResult: vendorTbeView.tbeResult, tbeNote: vendorTbeView.tbeNote, tbeUpdated: vendorTbeView.tbeUpdated, + + technicalResponseId:vendorTbeView.technicalResponseId, + technicalResponseStatus:vendorTbeView.technicalResponseStatus, + technicalSummary:vendorTbeView.technicalSummary, + technicalNotes:vendorTbeView.technicalNotes, + technicalUpdated:vendorTbeView.technicalUpdated, }) .from(vendorTbeView) .where(finalWhere) @@ -1286,8 +1299,7 @@ export async function getTBEforVendor(input: GetTBESchema, vendorId: number) { const finalWhere = and( isNotNull(vendorTbeView.tbeId), eq(vendorTbeView.vendorId, vendorId), - - notRejected, + // notRejected, advancedWhere, globalWhere ) @@ -1318,6 +1330,12 @@ export async function getTBEforVendor(input: GetTBESchema, vendorId: number) { rfqId: vendorTbeView.rfqId, rfqCode: vendorTbeView.rfqCode, + rfqType:vendorTbeView.rfqType, + rfqStatus:vendorTbeView.rfqStatus, + rfqDescription: vendorTbeView.description, + rfqDueDate: vendorTbeView.dueDate, + + projectCode: vendorTbeView.projectCode, projectName: vendorTbeView.projectName, description: vendorTbeView.description, @@ -1491,7 +1509,6 @@ export async function inviteTbeVendorsAction(formData: FormData) { const vendorIdsRaw = formData.getAll("vendorIds[]") const vendorIds = vendorIdsRaw.map((id) => Number(id)) - // 2) FormData에서 파일들 추출 (multiple) const tbeFiles = formData.getAll("tbeFiles") as File[] if (!rfqId || !vendorIds.length || !tbeFiles.length) { @@ -1500,7 +1517,13 @@ export async function inviteTbeVendorsAction(formData: FormData) { // /public/rfq/[rfqId] 경로 const uploadDir = path.join(process.cwd(), "public", "rfq", String(rfqId)) - + + // 디렉토리가 없다면 생성 + try { + await fs.mkdir(uploadDir, { recursive: true }) + } catch (err) { + console.error("디렉토리 생성 실패:", err) + } // DB 트랜잭션 await db.transaction(async (tx) => { @@ -1532,94 +1555,150 @@ export async function inviteTbeVendorsAction(formData: FormData) { .from(rfqItems) .where(eq(rfqItems.rfqId, rfqId)) - // (C) 대상 벤더들 + // (C) 대상 벤더들 (이메일 정보 확장) const vendorRows = await tx - .select({ id: vendors.id, email: vendors.email }) + .select({ + id: vendors.id, + name: vendors.vendorName, + email: vendors.email, + representativeEmail: vendors.representativeEmail // 대표자 이메일 추가 + }) .from(vendors) .where(sql`${vendors.id} in (${vendorIds})`) - // (D) 모든 TBE 파일 저장 & 이후 벤더 초대 처리 + // (D) 모든 TBE 파일 저장 & 이후 협력업체 초대 처리 // 파일은 한 번만 저장해도 되지만, 각 벤더별로 따로 저장/첨부가 필요하다면 루프를 돌려도 됨. - // 여기서는 "모든 파일"을 RFQ-DIR에 저장 + "각 벤더"에는 동일 파일 목록을 첨부한다는 예시. + // 여기서는 "모든 파일"을 RFQ-DIR에 저장 + "각 협력업체"에는 동일 파일 목록을 첨부한다는 예시. const savedFiles = [] for (const file of tbeFiles) { const originalName = file.name || "tbe-sheet.xlsx" - const savePath = path.join(uploadDir, originalName) + // 파일명 충돌 방지를 위한 타임스탬프 추가 + const timestamp = new Date().getTime() + const fileName = `${timestamp}-${originalName}` + const savePath = path.join(uploadDir, fileName) // 파일 ArrayBuffer → Buffer 변환 후 저장 const arrayBuffer = await file.arrayBuffer() - fs.writeFile(savePath, Buffer.from(arrayBuffer)) + await fs.writeFile(savePath, Buffer.from(arrayBuffer)) // 저장 경로 & 파일명 기록 savedFiles.push({ - fileName: originalName, - filePath: `/rfq/${rfqId}/${originalName}`, // public 이하 경로 + fileName: originalName, // 원본 파일명으로 첨부 + filePath: `/rfq/${rfqId}/${fileName}`, // public 이하 경로 absolutePath: savePath, }) } // (E) 각 벤더별로 TBE 평가 레코드, 초대 처리, 메일 발송 - for (const v of vendorRows) { - if (!v.email) { - // 이메일 없는 경우 로직 (스킵 or throw) + for (const vendor of vendorRows) { + // 1) 협력업체 연락처 조회 - 추가 이메일 수집 + const contacts = await tx + .select({ + contactName: vendorContacts.contactName, + contactEmail: vendorContacts.contactEmail, + isPrimary: vendorContacts.isPrimary, + }) + .from(vendorContacts) + .where(eq(vendorContacts.vendorId, vendor.id)) + + // 2) 모든 이메일 주소 수집 및 중복 제거 + const allEmails = new Set<string>() + + // 협력업체 이메일 추가 (있는 경우에만) + if (vendor.email) { + allEmails.add(vendor.email.trim().toLowerCase()) + } + + // 협력업체 대표자 이메일 추가 (있는 경우에만) + if (vendor.representativeEmail) { + allEmails.add(vendor.representativeEmail.trim().toLowerCase()) + } + + // 연락처 이메일 추가 + contacts.forEach(contact => { + if (contact.contactEmail) { + allEmails.add(contact.contactEmail.trim().toLowerCase()) + } + }) + + // 중복이 제거된 이메일 주소 배열로 변환 + const uniqueEmails = Array.from(allEmails) + + if (uniqueEmails.length === 0) { + console.warn(`협력업체 ID ${vendor.id}에 등록된 이메일 주소가 없습니다. TBE 초대를 건너뜁니다.`) continue } - // 1) TBE 평가 레코드 생성 + // 3) TBE 평가 레코드 생성 const [evalRow] = await tx .insert(rfqEvaluations) .values({ rfqId, - vendorId: v.id, + vendorId: vendor.id, evalType: "TBE", }) .returning({ id: rfqEvaluations.id }) - // 2) rfqAttachments에 저장한 파일들을 기록 + // 4) rfqAttachments에 저장한 파일들을 기록 for (const sf of savedFiles) { await tx.insert(rfqAttachments).values({ rfqId, - // vendorId: v.id, + vendorId: vendor.id, evaluationId: evalRow.id, fileName: sf.fileName, filePath: sf.filePath, }) } - // 4) 메일 발송 + // 5) 각 고유 이메일 주소로 초대 메일 발송 const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000' const loginUrl = `${baseUrl}/ko/partners/rfq` - await sendEmail({ - to: v.email, - subject: `[RFQ ${rfqRow.rfqCode}] You are invited for TBE!`, - template: "rfq-invite", - context: { - language: "en", - rfqId, - vendorId: v.id, - - rfqCode: rfqRow.rfqCode, - projectCode: rfqRow.projectCode, - projectName: rfqRow.projectName, - dueDate: rfqRow.dueDate, - description: rfqRow.description, - - items: items.map((it) => ({ - itemCode: it.itemCode, - description: it.description, - quantity: it.quantity, - uom: it.uom, - })), - loginUrl, - }, - attachments: savedFiles.map((sf) => ({ - path: sf.absolutePath, - filename: sf.fileName, - })), - }) + + console.log(`협력업체 ID ${vendor.id}(${vendor.name})에 대해 ${uniqueEmails.length}개의 고유 이메일로 TBE 초대 발송`) + + for (const email of uniqueEmails) { + try { + // 연락처 이름 찾기 (이메일과 일치하는 연락처가 있으면 사용, 없으면 '벤더명 담당자'로 대체) + const contact = contacts.find(c => + c.contactEmail && c.contactEmail.toLowerCase() === email.toLowerCase() + ) + const contactName = contact?.contactName || `${vendor.name} 담당자` + + await sendEmail({ + to: email, + subject: `[RFQ ${rfqRow.rfqCode}] You are invited for TBE!`, + template: "rfq-invite", + context: { + language: "en", + rfqId, + vendorId: vendor.id, + contactName, // 연락처 이름 추가 + rfqCode: rfqRow.rfqCode, + projectCode: rfqRow.projectCode, + projectName: rfqRow.projectName, + dueDate: rfqRow.dueDate, + description: rfqRow.description, + items: items.map((it) => ({ + itemCode: it.itemCode, + description: it.description, + quantity: it.quantity, + uom: it.uom, + })), + loginUrl, + }, + attachments: savedFiles.map((sf) => ({ + path: sf.absolutePath, + filename: sf.fileName, + })), + }) + console.log(`이메일 전송 성공: ${email} (${contactName})`) + } catch (emailErr) { + console.error(`이메일 전송 실패 (${email}):`, emailErr) + } + } } - // 5) 캐시 무효화 + // 6) 캐시 무효화 revalidateTag("tbe-vendors") }) @@ -1662,8 +1741,8 @@ export async function createRfqCommentWithAttachments(params: { files?: File[] }) { const { rfqId, vendorId, commentText, commentedBy, evaluationId,cbeId, files } = params - - + console.log("cbeId", cbeId) + console.log("evaluationId", evaluationId) // 1) 새로운 코멘트 생성 const [insertedComment] = await db .insert(rfqComments) @@ -1797,6 +1876,37 @@ export async function getProjects(): Promise<Project[]> { } +export async function getBidProjects(): Promise<Project[]> { + try { + // 트랜잭션을 사용하여 프로젝트 데이터 조회 + const projectList = await db.transaction(async (tx) => { + // 모든 프로젝트 조회 + const results = await tx + .select({ + id: biddingProjects.id, + projectCode: biddingProjects.pspid, + projectName: biddingProjects.projNm, + }) + .from(biddingProjects) + .orderBy(biddingProjects.id); + + return results; + }); + + // Handle null projectName values + const validProjectList = projectList.map(project => ({ + ...project, + projectName: project.projectName || '' // Replace null with empty string + })); + + return validProjectList; + } catch (error) { + console.error("프로젝트 목록 가져오기 실패:", error); + return []; // 오류 발생 시 빈 배열 반환 + } +} + + // 반환 타입 명시적 정의 - rfqCode가 null일 수 있음을 반영 export interface BudgetaryRfq { id: number; @@ -1919,6 +2029,19 @@ export async function getAllVendors() { return allVendors } + +export async function getVendorContactsByVendorId(vendorId: number) { + try { + const contacts = await db.query.vendorContacts.findMany({ + where: eq(vendorContacts.vendorId, vendorId), + }); + + return { success: true, data: contacts }; + } catch (error) { + console.error("Error fetching vendor contacts:", error); + return { success: false, error: "Failed to fetch vendor contacts" }; + } +} /** * Server action to associate items from an RFQ with a vendor * @@ -2020,8 +2143,6 @@ export async function addItemToVendors(rfqId: number, vendorIds: number[]) { * evaluationId가 일치하고 vendorId가 null인 파일 목록 */ export async function fetchTbeTemplateFiles(evaluationId: number) { - - console.log(evaluationId, "evaluationId") try { const files = await db .select({ @@ -2051,10 +2172,7 @@ export async function fetchTbeTemplateFiles(evaluationId: number) { } } -/** - * 특정 TBE 템플릿 파일 다운로드를 위한 정보 조회 - */ -export async function getTbeTemplateFileInfo(fileId: number) { +export async function getFileFromRfqAttachmentsbyid(fileId: number) { try { const file = await db .select({ @@ -2128,6 +2246,7 @@ export async function uploadTbeResponseFile(formData: FormData) { responseId: vendorResponseId, summary: "TBE 응답 파일 업로드", // 필요에 따라 수정 notes: `파일명: ${originalName}`, + responseStatus:"SUBMITTED" }) .returning({ id: vendorTechnicalResponses.id }); @@ -2354,7 +2473,9 @@ export async function getAllTBE(input: GetTBESchema) { rfqVendorStatus: vendorTbeView.rfqVendorStatus, rfqVendorUpdated: vendorTbeView.rfqVendorUpdated, + technicalResponseStatus:vendorTbeView.technicalResponseStatus, tbeResult: vendorTbeView.tbeResult, + tbeNote: vendorTbeView.tbeNote, tbeUpdated: vendorTbeView.tbeUpdated, }) @@ -2562,9 +2683,6 @@ export async function getAllTBE(input: GetTBESchema) { } - - - export async function getCBE(input: GetCBESchema, rfqId: number) { return unstable_cache( async () => { @@ -2574,7 +2692,7 @@ export async function getCBE(input: GetCBESchema, rfqId: number) { // [2] 고급 필터 const advancedWhere = filterColumns({ - table: vendorCbeView, + table: vendorResponseCBEView, filters: input.filters ?? [], joinOperator: input.joinOperator ?? "and", }); @@ -2584,73 +2702,83 @@ export async function getCBE(input: GetCBESchema, rfqId: number) { if (input.search) { const s = `%${input.search}%`; globalWhere = or( - sql`${vendorCbeView.vendorName} ILIKE ${s}`, - sql`${vendorCbeView.vendorCode} ILIKE ${s}`, - sql`${vendorCbeView.email} ILIKE ${s}` + sql`${vendorResponseCBEView.vendorName} ILIKE ${s}`, + sql`${vendorResponseCBEView.vendorCode} ILIKE ${s}`, + sql`${vendorResponseCBEView.rfqCode} ILIKE ${s}`, + sql`${vendorResponseCBEView.totalPrice}::text ILIKE ${s}` ); } - // [4] REJECTED 아니거나 NULL - const notRejected = or( - ne(vendorCbeView.rfqVendorStatus, "REJECTED"), - isNull(vendorCbeView.rfqVendorStatus) - ); + // [4] DECLINED 상태 제외 (거절된 업체는 표시하지 않음) + const notDeclined = ne(vendorResponseCBEView.responseStatus, "DECLINED"); - // [5] 최종 where + // [5] 최종 where 조건 const finalWhere = and( - eq(vendorCbeView.rfqId, rfqId), - notRejected, - advancedWhere, - globalWhere + eq(vendorResponseCBEView.rfqId, rfqId), + notDeclined, + advancedWhere ?? undefined, + globalWhere ?? undefined ); // [6] 정렬 const orderBy = input.sort?.length ? input.sort.map((s) => { - // vendor_cbe_view 컬럼 중 정렬 대상이 되는 것만 매핑 - const col = (vendorCbeView as any)[s.id]; - return s.desc ? desc(col) : asc(col); - }) - : [asc(vendorCbeView.vendorId)]; + // vendorResponseCBEView 컬럼 중 정렬 대상이 되는 것만 매핑 + const col = (vendorResponseCBEView as any)[s.id]; + return s.desc ? desc(col) : asc(col); + }) + : [asc(vendorResponseCBEView.vendorName)]; // 기본 정렬은 벤더명 // [7] 메인 SELECT const [rows, total] = await db.transaction(async (tx) => { const data = await tx .select({ - // 필요한 컬럼만 추출 - id: vendorCbeView.vendorId, - cbeId: vendorCbeView.cbeId, - vendorId: vendorCbeView.vendorId, - vendorName: vendorCbeView.vendorName, - vendorCode: vendorCbeView.vendorCode, - address: vendorCbeView.address, - country: vendorCbeView.country, - email: vendorCbeView.email, - website: vendorCbeView.website, - vendorStatus: vendorCbeView.vendorStatus, - - rfqId: vendorCbeView.rfqId, - rfqCode: vendorCbeView.rfqCode, - projectCode: vendorCbeView.projectCode, - projectName: vendorCbeView.projectName, - description: vendorCbeView.description, - dueDate: vendorCbeView.dueDate, - - rfqVendorStatus: vendorCbeView.rfqVendorStatus, - rfqVendorUpdated: vendorCbeView.rfqVendorUpdated, - - cbeResult: vendorCbeView.cbeResult, - cbeNote: vendorCbeView.cbeNote, - cbeUpdated: vendorCbeView.cbeUpdated, - - // 상업평가 정보 - totalCost: vendorCbeView.totalCost, - currency: vendorCbeView.currency, - paymentTerms: vendorCbeView.paymentTerms, - incoterms: vendorCbeView.incoterms, - deliverySchedule: vendorCbeView.deliverySchedule, + // 기본 식별 정보 + responseId: vendorResponseCBEView.responseId, + vendorId: vendorResponseCBEView.vendorId, + rfqId: vendorResponseCBEView.rfqId, + + // 협력업체 정보 + vendorName: vendorResponseCBEView.vendorName, + vendorCode: vendorResponseCBEView.vendorCode, + vendorStatus: vendorResponseCBEView.vendorStatus, + + // RFQ 정보 + rfqCode: vendorResponseCBEView.rfqCode, + rfqDescription: vendorResponseCBEView.rfqDescription, + rfqDueDate: vendorResponseCBEView.rfqDueDate, + rfqStatus: vendorResponseCBEView.rfqStatus, + rfqType: vendorResponseCBEView.rfqType, + + // 프로젝트 정보 + projectId: vendorResponseCBEView.projectId, + projectCode: vendorResponseCBEView.projectCode, + projectName: vendorResponseCBEView.projectName, + + // 응답 상태 정보 + responseStatus: vendorResponseCBEView.responseStatus, + responseNotes: vendorResponseCBEView.notes, + respondedAt: vendorResponseCBEView.respondedAt, + respondedBy: vendorResponseCBEView.respondedBy, + + // 상업 응답 정보 + commercialResponseId: vendorResponseCBEView.commercialResponseId, + commercialResponseStatus: vendorResponseCBEView.commercialResponseStatus, + totalPrice: vendorResponseCBEView.totalPrice, + currency: vendorResponseCBEView.currency, + paymentTerms: vendorResponseCBEView.paymentTerms, + incoterms: vendorResponseCBEView.incoterms, + deliveryPeriod: vendorResponseCBEView.deliveryPeriod, + warrantyPeriod: vendorResponseCBEView.warrantyPeriod, + validityPeriod: vendorResponseCBEView.validityPeriod, + commercialNotes: vendorResponseCBEView.commercialNotes, + + // 첨부파일 카운트 + attachmentCount: vendorResponseCBEView.attachmentCount, + commercialAttachmentCount: vendorResponseCBEView.commercialAttachmentCount, + technicalAttachmentCount: vendorResponseCBEView.technicalAttachmentCount, }) - .from(vendorCbeView) + .from(vendorResponseCBEView) .where(finalWhere) .orderBy(...orderBy) .offset(offset) @@ -2658,122 +2786,89 @@ export async function getCBE(input: GetCBESchema, rfqId: number) { const [{ count }] = await tx .select({ count: sql<number>`count(*)`.as("count") }) - .from(vendorCbeView) + .from(vendorResponseCBEView) .where(finalWhere); return [data, Number(count)]; }); if (!rows.length) { - return { data: [], pageCount: 0 }; + return { data: [], pageCount: 0, total: 0 }; } - // [8] Comments 조회 - // TBE 에서는 rfqComments + rfqEvaluations(evalType="TBE") 를 조인했지만, - // CBE는 cbeEvaluations 또는 evalType="CBE"를 기준으로 바꾸면 됩니다. - // 만약 cbeEvaluations.id 를 evaluationId 로 참조한다면 아래와 같이 innerJoin: + // [8] 협력업체 ID 목록 추출 const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))]; + const distinctResponseIds = [...new Set(rows.map((r) => r.responseId))]; + const distinctCommercialResponseIds = [...new Set(rows.filter(r => r.commercialResponseId).map((r) => r.commercialResponseId!))]; - const commAll = await db + // [9] CBE 평가 관련 코멘트 조회 + const commentsAll = await db .select({ id: rfqComments.id, commentText: rfqComments.commentText, vendorId: rfqComments.vendorId, - evaluationId: rfqComments.evaluationId, + cbeId: rfqComments.cbeId, createdAt: rfqComments.createdAt, commentedBy: rfqComments.commentedBy, - // cbeEvaluations에는 evalType 컬럼이 별도로 없을 수도 있음(프로젝트 구조에 맞게 수정) - // evalType: cbeEvaluations.evalType, }) .from(rfqComments) .innerJoin( - cbeEvaluations, - eq(cbeEvaluations.id, rfqComments.evaluationId) + vendorResponses, + eq(vendorResponses.id, rfqComments.cbeId) ) .where( and( - isNotNull(rfqComments.evaluationId), + isNotNull(rfqComments.cbeId), eq(rfqComments.rfqId, rfqId), inArray(rfqComments.vendorId, distinctVendorIds) ) ); - // vendorId -> comments grouping - const commByVendorId = new Map<number, any[]>(); - for (const c of commAll) { - const vid = c.vendorId!; - if (!commByVendorId.has(vid)) { - commByVendorId.set(vid, []); + // vendorId별 코멘트 그룹화 + const commentsByVendorId = new Map<number, any[]>(); + for (const comment of commentsAll) { + const vendorId = comment.vendorId!; + if (!commentsByVendorId.has(vendorId)) { + commentsByVendorId.set(vendorId, []); } - commByVendorId.get(vid)!.push({ - id: c.id, - commentText: c.commentText, - vendorId: c.vendorId, - evaluationId: c.evaluationId, - createdAt: c.createdAt, - commentedBy: c.commentedBy, + commentsByVendorId.get(vendorId)!.push({ + id: comment.id, + commentText: comment.commentText, + vendorId: comment.vendorId, + cbeId: comment.cbeId, + createdAt: comment.createdAt, + commentedBy: comment.commentedBy, }); } - // [9] CBE 파일 조회 (프로젝트에 따라 구조가 달라질 수 있음) - // - TBE는 vendorTechnicalResponses 기준 - // - CBE는 vendorCommercialResponses(가정) 등이 있을 수 있음 - // - 여기서는 예시로 "동일한 vendorResponses + vendorResponseAttachments" 라고 가정 - // Step 1: vendorResponses 가져오기 (rfqId + vendorIds) - const responsesAll = await db + // [10] 첨부 파일 조회 - 일반 응답 첨부파일 + const responseAttachments = await db .select({ - id: vendorResponses.id, - vendorId: vendorResponses.vendorId, + id: vendorResponseAttachments.id, + fileName: vendorResponseAttachments.fileName, + filePath: vendorResponseAttachments.filePath, + responseId: vendorResponseAttachments.responseId, + fileType: vendorResponseAttachments.fileType, + attachmentType: vendorResponseAttachments.attachmentType, + description: vendorResponseAttachments.description, + uploadedAt: vendorResponseAttachments.uploadedAt, + uploadedBy: vendorResponseAttachments.uploadedBy, }) - .from(vendorResponses) + .from(vendorResponseAttachments) .where( and( - eq(vendorResponses.rfqId, rfqId), - inArray(vendorResponses.vendorId, distinctVendorIds) + inArray(vendorResponseAttachments.responseId, distinctResponseIds), + isNotNull(vendorResponseAttachments.responseId) ) ); - // Group responses by vendorId - const responsesByVendorId = new Map<number, number[]>(); - for (const resp of responsesAll) { - if (!responsesByVendorId.has(resp.vendorId)) { - responsesByVendorId.set(resp.vendorId, []); - } - responsesByVendorId.get(resp.vendorId)!.push(resp.id); - } - - // Step 2: responseIds - const allResponseIds = responsesAll.map((r) => r.id); - - - const commercialResponsesAll = await db - .select({ - id: vendorCommercialResponses.id, - responseId: vendorCommercialResponses.responseId, - }) - .from(vendorCommercialResponses) - .where(inArray(vendorCommercialResponses.responseId, allResponseIds)); - - const commercialResponseIdsByResponseId = new Map<number, number[]>(); - for (const cr of commercialResponsesAll) { - if (!commercialResponseIdsByResponseId.has(cr.responseId)) { - commercialResponseIdsByResponseId.set(cr.responseId, []); - } - commercialResponseIdsByResponseId.get(cr.responseId)!.push(cr.id); - } - - const allCommercialResponseIds = commercialResponsesAll.map((cr) => cr.id); - - - // 여기서는 예시로 TBE와 마찬가지로 vendorResponseAttachments를 - // 직접 responseId로 관리한다고 가정(혹은 commercialResponseId로 연결) - // Step 3: vendorResponseAttachments 조회 - const filesAll = await db + // [11] 첨부 파일 조회 - 상업 응답 첨부파일 + const commercialResponseAttachments = await db .select({ id: vendorResponseAttachments.id, fileName: vendorResponseAttachments.fileName, filePath: vendorResponseAttachments.filePath, - responseId: vendorResponseAttachments.responseId, + commercialResponseId: vendorResponseAttachments.commercialResponseId, fileType: vendorResponseAttachments.fileType, attachmentType: vendorResponseAttachments.attachmentType, description: vendorResponseAttachments.description, @@ -2783,19 +2878,20 @@ export async function getCBE(input: GetCBESchema, rfqId: number) { .from(vendorResponseAttachments) .where( and( - inArray(vendorResponseAttachments.responseId, allCommercialResponseIds), - isNotNull(vendorResponseAttachments.responseId) + inArray(vendorResponseAttachments.commercialResponseId, distinctCommercialResponseIds), + isNotNull(vendorResponseAttachments.commercialResponseId) ) ); - // Step 4: responseId -> files + // [12] 첨부파일 그룹화 + // responseId별 첨부파일 맵 생성 const filesByResponseId = new Map<number, any[]>(); - for (const file of filesAll) { - const rid = file.responseId!; - if (!filesByResponseId.has(rid)) { - filesByResponseId.set(rid, []); + for (const file of responseAttachments) { + const responseId = file.responseId!; + if (!filesByResponseId.has(responseId)) { + filesByResponseId.set(responseId, []); } - filesByResponseId.get(rid)!.push({ + filesByResponseId.get(responseId)!.push({ id: file.id, fileName: file.fileName, filePath: file.filePath, @@ -2804,40 +2900,66 @@ export async function getCBE(input: GetCBESchema, rfqId: number) { description: file.description, uploadedAt: file.uploadedAt, uploadedBy: file.uploadedBy, + attachmentSource: 'response' }); } - // Step 5: vendorId -> files - const filesByVendorId = new Map<number, any[]>(); - for (const [vendorId, responseIds] of responsesByVendorId.entries()) { - filesByVendorId.set(vendorId, []); - for (const responseId of responseIds) { - const files = filesByResponseId.get(responseId) || []; - filesByVendorId.get(vendorId)!.push(...files); + // commercialResponseId별 첨부파일 맵 생성 + const filesByCommercialResponseId = new Map<number, any[]>(); + for (const file of commercialResponseAttachments) { + const commercialResponseId = file.commercialResponseId!; + if (!filesByCommercialResponseId.has(commercialResponseId)) { + filesByCommercialResponseId.set(commercialResponseId, []); } + filesByCommercialResponseId.get(commercialResponseId)!.push({ + id: file.id, + fileName: file.fileName, + filePath: file.filePath, + fileType: file.fileType, + attachmentType: file.attachmentType, + description: file.description, + uploadedAt: file.uploadedAt, + uploadedBy: file.uploadedBy, + attachmentSource: 'commercial' + }); } - // [10] 최종 데이터 합치기 - const final = rows.map((row) => ({ - ...row, - dueDate: row.dueDate ? new Date(row.dueDate) : null, - comments: commByVendorId.get(row.vendorId) ?? [], - files: filesByVendorId.get(row.vendorId) ?? [], - })); + // [13] 최종 데이터 병합 + const final = rows.map((row) => { + // 해당 응답의 모든 첨부파일 가져오기 + const responseFiles = filesByResponseId.get(row.responseId) || []; + const commercialFiles = row.commercialResponseId + ? filesByCommercialResponseId.get(row.commercialResponseId) || [] + : []; + + // 모든 첨부파일 병합 + const allFiles = [...responseFiles, ...commercialFiles]; + + return { + ...row, + rfqDueDate: row.rfqDueDate ? new Date(row.rfqDueDate) : null, + respondedAt: row.respondedAt ? new Date(row.respondedAt) : null, + comments: commentsByVendorId.get(row.vendorId) || [], + files: allFiles, + }; + }); const pageCount = Math.ceil(total / limit); - return { data: final, pageCount }; + return { + data: final, + pageCount, + total + }; }, // 캐싱 키 & 옵션 - [JSON.stringify({ input, rfqId })], + [`cbe-vendors-${rfqId}-${JSON.stringify(input)}`], { revalidate: 3600, - tags: ["cbe-vendors"], + tags: [`cbe-vendors-${rfqId}`], } )(); } - export async function generateNextRfqCode(rfqType: RfqType): Promise<{ code: string; error?: string }> { try { if (!rfqType) { @@ -2880,4 +3002,1026 @@ export async function generateNextRfqCode(rfqType: RfqType): Promise<{ code: str console.error('Error generating next RFQ code:', error); return { code: "", error: '코드 생성에 실패했습니다' }; } +} + +interface SaveTbeResultParams { + id: number // id from the rfq_evaluations table + vendorId: number // vendorId from the rfq_evaluations table + result: string // The selected evaluation result + notes: string // The evaluation notes +} + +export async function saveTbeResult({ + id, + vendorId, + result, + notes, +}: SaveTbeResultParams) { + try { + // Check if we have all required data + if (!id || !vendorId || !result) { + return { + success: false, + message: "Missing required data for evaluation update", + } + } + + // Update the record in the database + await db + .update(rfqEvaluations) + .set({ + result: result, + notes: notes, + updatedAt: new Date(), + }) + .where( + and( + eq(rfqEvaluations.id, id), + eq(rfqEvaluations.vendorId, vendorId), + eq(rfqEvaluations.evalType, "TBE") + ) + ) + + // Revalidate the tbe-vendors tag to refresh the data + revalidateTag("tbe-vendors") + revalidateTag("all-tbe-vendors") + + return { + success: true, + message: "TBE evaluation updated successfully", + } + } catch (error) { + console.error("Failed to update TBE evaluation:", error) + + return { + success: false, + message: error instanceof Error ? error.message : "An unknown error occurred", + } + } +} + + +export async function createCbeEvaluation(formData: FormData) { + try { + // 폼 데이터 추출 + const rfqId = Number(formData.get("rfqId")) + const vendorIds = formData.getAll("vendorIds[]").map(id => Number(id)) + const evaluatedBy = formData.get("evaluatedBy") ? Number(formData.get("evaluatedBy")) : null + + + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + + // 기본 CBE 데이터 추출 + const rawData = { + rfqId, + paymentTerms: formData.get("paymentTerms") as string, + incoterms: formData.get("incoterms") as string, + deliverySchedule: formData.get("deliverySchedule") as string, + notes: formData.get("notes") as string, + // 단일 협력업체 처리 시 사용할 vendorId (여러 협력업체 처리에선 사용하지 않음) + // vendorId: vendorIds[0] || 0, + } + + // zod 스키마 유효성 검사 (vendorId는 더미로 채워 검증하고 실제로는 배열로 처리) + const validationResult = createCbeEvaluationSchema.safeParse(rawData) + if (!validationResult.success) { + const errors = validationResult.error.format() + console.error("Validation errors:", errors) + return { error: "입력 데이터가 유효하지 않습니다." } + } + + const validData = validationResult.data + + // RFQ 정보 조회 + const [rfqInfo] = await db + .select({ + rfqCode: rfqsView.rfqCode, + projectCode: rfqsView.projectCode, + projectName: rfqsView.projectName, + dueDate: rfqsView.dueDate, + description: rfqsView.description, + }) + .from(rfqsView) + .where(eq(rfqsView.id, rfqId)) + + if (!rfqInfo) { + return { error: "RFQ 정보를 찾을 수 없습니다." } + } + + // 파일 처리 준비 + const files = formData.getAll("files") as File[] + const hasFiles = files && files.length > 0 && files[0].size > 0 + + // 파일 저장을 위한 디렉토리 생성 (파일이 있는 경우에만) + let uploadDir = "" + if (hasFiles) { + uploadDir = path.join(process.cwd(), "public", "rfq", String(rfqId)) + try { + await fs.mkdir(uploadDir, { recursive: true }) + } catch (err) { + console.error("디렉토리 생성 실패:", err) + return { error: "파일 업로드를 위한 디렉토리 생성에 실패했습니다." } + } + } + + // 첨부 파일 정보를 저장할 배열 + const attachments: { filename: string; path: string }[] = [] + + // 파일이 있는 경우, 파일을 저장하고 첨부 파일 정보 준비 + if (hasFiles) { + for (const file of files) { + if (file.size > 0) { + const originalFilename = file.name + const fileExtension = path.extname(originalFilename) + const timestamp = new Date().getTime() + const safeFilename = `cbe-${rfqId}-${timestamp}${fileExtension}` + const filePath = path.join("rfq", String(rfqId), safeFilename) + const fullPath = path.join(process.cwd(), "public", filePath) + + try { + // File을 ArrayBuffer로 변환하여 파일 시스템에 저장 + const arrayBuffer = await file.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + await fs.writeFile(fullPath, buffer) + + // 첨부 파일 정보 추가 + attachments.push({ + filename: originalFilename, + path: fullPath, // 이메일 첨부를 위한 전체 경로 + }) + } catch (err) { + console.error(`파일 저장 실패:`, err) + // 파일 저장 실패를 기록하지만 전체 프로세스는 계속 진행 + } + } + } + } + + // 각 벤더별로 CBE 평가 레코드 생성 및 알림 전송 + const createdCbeIds: number[] = [] + const failedVendors: { id: number, reason: string }[] = [] + + for (const vendorId of vendorIds) { + try { + // 협력업체 정보 조회 (이메일 포함) + const [vendorInfo] = await db + .select({ + id: vendors.id, + name: vendors.vendorName, + vendorCode: vendors.vendorCode, + email: vendors.email, // 협력업체 자체 이메일 추가 + representativeEmail: vendors.representativeEmail, // 협력업체 대표자 이메일 추가 + }) + .from(vendors) + .where(eq(vendors.id, vendorId)) + + if (!vendorInfo) { + failedVendors.push({ id: vendorId, reason: "협력업체 정보를 찾을 수 없습니다." }) + continue + } + + // 기존 협력업체 응답 레코드 찾기 + const existingResponse = await db + .select({ id: vendorResponses.id }) + .from(vendorResponses) + .where( + and( + eq(vendorResponses.rfqId, rfqId), + eq(vendorResponses.vendorId, vendorId) + ) + ) + .limit(1) + + if (existingResponse.length === 0) { + console.error(`협력업체 ID ${vendorId}에 대한 응답 레코드가 존재하지 않습니다.`) + failedVendors.push({ id: vendorId, reason: "협력업체 응답 레코드를 찾을 수 없습니다" }) + continue // 다음 벤더로 넘어감 + } + + // 1. CBE 평가 레코드 생성 + const [newCbeEvaluation] = await db + .insert(cbeEvaluations) + .values({ + rfqId, + vendorId, + evaluatedBy, + result: "PENDING", // 초기 상태는 PENDING으로 설정 + totalCost: 0, // 초기값은 0으로 설정 + currency: "USD", // 기본 통화 설정 + paymentTerms: validData.paymentTerms || null, + incoterms: validData.incoterms || null, + deliverySchedule: validData.deliverySchedule || null, + notes: validData.notes || null, + }) + .returning({ id: cbeEvaluations.id }) + + if (!newCbeEvaluation?.id) { + failedVendors.push({ id: vendorId, reason: "CBE 평가 생성 실패" }) + continue + } + + // 2. 상업 응답 레코드 생성 + const [newCbeResponse] = await db + .insert(vendorCommercialResponses) + .values({ + responseId: existingResponse[0].id, + responseStatus: "PENDING", + currency: "USD", + paymentTerms: validData.paymentTerms || null, + incoterms: validData.incoterms || null, + deliveryPeriod: validData.deliverySchedule || null, + }) + .returning({ id: vendorCommercialResponses.id }) + + if (!newCbeResponse?.id) { + failedVendors.push({ id: vendorId, reason: "상업 응답 생성 실패" }) + continue + } + + createdCbeIds.push(newCbeEvaluation.id) + + // 3. 첨부 파일이 있는 경우, 데이터베이스에 첨부 파일 레코드 생성 + if (hasFiles) { + for (let i = 0; i < attachments.length; i++) { + const attachment = attachments[i] + + await db.insert(rfqAttachments).values({ + rfqId, + vendorId, + fileName: attachment.filename, + filePath: `/${path.relative(path.join(process.cwd(), "public"), attachment.path)}`, // URL 경로를 위해 public 기준 상대 경로로 저장 + cbeId: newCbeEvaluation.id, + }) + } + } + + // 4. 협력업체 연락처 조회 + const contacts = await db + .select({ + contactName: vendorContacts.contactName, + contactEmail: vendorContacts.contactEmail, + isPrimary: vendorContacts.isPrimary, + }) + .from(vendorContacts) + .where(eq(vendorContacts.vendorId, vendorId)) + + // 5. 모든 이메일 주소 수집 및 중복 제거 + const allEmails = new Set<string>() + + // 연락처 이메일 추가 + contacts.forEach(contact => { + if (contact.contactEmail) { + allEmails.add(contact.contactEmail.trim().toLowerCase()) + } + }) + + // 협력업체 자체 이메일 추가 (있는 경우에만) + if (vendorInfo.email) { + allEmails.add(vendorInfo.email.trim().toLowerCase()) + } + + // 협력업체 대표자 이메일 추가 (있는 경우에만) + if (vendorInfo.representativeEmail) { + allEmails.add(vendorInfo.representativeEmail.trim().toLowerCase()) + } + + // 중복이 제거된 이메일 주소 배열로 변환 + const uniqueEmails = Array.from(allEmails) + + if (uniqueEmails.length === 0) { + console.warn(`협력업체 ID ${vendorId}에 등록된 이메일 주소가 없습니다.`) + } else { + console.log(`협력업체 ID ${vendorId}에 대해 ${uniqueEmails.length}개의 고유 이메일 주소로 알림을 전송합니다.`) + + // 이메일 발송에 필요한 공통 데이터 준비 + const emailData = { + rfqId, + cbeId: newCbeEvaluation.id, + vendorId, + rfqCode: rfqInfo.rfqCode, + projectCode: rfqInfo.projectCode, + projectName: rfqInfo.projectName, + dueDate: rfqInfo.dueDate, + description: rfqInfo.description, + vendorName: vendorInfo.name, + vendorCode: vendorInfo.vendorCode, + paymentTerms: validData.paymentTerms, + incoterms: validData.incoterms, + deliverySchedule: validData.deliverySchedule, + notes: validData.notes, + loginUrl: `http://${host}/en/partners/cbe` + } + + // 각 고유 이메일 주소로 이메일 발송 + for (const email of uniqueEmails) { + try { + // 연락처 이름 찾기 (이메일과 일치하는 연락처가 있으면 사용, 없으면 '벤더명 담당자'로 대체) + const contact = contacts.find(c => + c.contactEmail && c.contactEmail.toLowerCase() === email.toLowerCase() + ) + const contactName = contact?.contactName || `${vendorInfo.name} 담당자` + + await sendEmail({ + to: email, + subject: `[RFQ ${rfqInfo.rfqCode}] 상업 입찰 평가 (CBE) 알림`, + template: "cbe-invitation", + context: { + language: "ko", // 또는 다국어 처리를 위한 설정 + contactName, + ...emailData, + }, + attachments: attachments, + }) + console.log(`이메일 전송 성공: ${email}`) + } catch (emailErr) { + console.error(`이메일 전송 실패 (${email}):`, emailErr) + } + } + } + + } catch (err) { + console.error(`협력업체 ID ${vendorId}의 CBE 생성 실패:`, err) + failedVendors.push({ id: vendorId, reason: "예기치 않은 오류" }) + } + } + + // UI 업데이트를 위한 경로 재검증 + revalidatePath(`/rfq/${rfqId}`) + revalidateTag(`cbe-vendors-${rfqId}`) + + // 결과 반환 + if (createdCbeIds.length === 0) { + return { error: "어떤 벤더에 대해서도 CBE 평가를 생성하지 못했습니다." } + } + + return { + success: true, + cbeIds: createdCbeIds, + totalCreated: createdCbeIds.length, + totalFailed: failedVendors.length, + failedVendors: failedVendors.length > 0 ? failedVendors : undefined + } + + } catch (error) { + console.error("CBE 평가 생성 중 오류 발생:", error) + return { error: "예상치 못한 오류가 발생했습니다." } + } +} + +export async function getCBEbyVendorId(input: GetCBESchema, vendorId: number) { + return unstable_cache( + async () => { + // [1] 페이징 + const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10); + const limit = input.perPage ?? 10; + + // [2] 고급 필터 + const advancedWhere = filterColumns({ + table: vendorResponseCBEView, + filters: input.filters ?? [], + joinOperator: input.joinOperator ?? "and", + }); + + // [3] 글로벌 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + sql`${vendorResponseCBEView.rfqCode} ILIKE ${s}`, + sql`${vendorResponseCBEView.projectCode} ILIKE ${s}`, + sql`${vendorResponseCBEView.projectName} ILIKE ${s}`, + sql`${vendorResponseCBEView.totalPrice}::text ILIKE ${s}` + ); + } + + // [4] DECLINED 상태 제외 (거절된 응답은 표시하지 않음) + // const notDeclined = ne(vendorResponseCBEView.responseStatus, "DECLINED"); + + // [5] 최종 where 조건 + const finalWhere = and( + eq(vendorResponseCBEView.vendorId, vendorId), // vendorId로 필터링 + isNotNull(vendorResponseCBEView.commercialCreatedAt), + // notDeclined, + advancedWhere ?? undefined, + globalWhere ?? undefined + ); + + // [6] 정렬 + const orderBy = input.sort?.length + ? input.sort.map((s) => { + // vendorResponseCBEView 컬럼 중 정렬 대상이 되는 것만 매핑 + const col = (vendorResponseCBEView as any)[s.id]; + return s.desc ? desc(col) : asc(col); + }) + : [desc(vendorResponseCBEView.rfqDueDate)]; // 기본 정렬은 RFQ 마감일 내림차순 + + // [7] 메인 SELECT + const [rows, total] = await db.transaction(async (tx) => { + const data = await tx + .select({ + // 기본 식별 정보 + responseId: vendorResponseCBEView.responseId, + vendorId: vendorResponseCBEView.vendorId, + rfqId: vendorResponseCBEView.rfqId, + + // 협력업체 정보 + vendorName: vendorResponseCBEView.vendorName, + vendorCode: vendorResponseCBEView.vendorCode, + vendorStatus: vendorResponseCBEView.vendorStatus, + + // RFQ 정보 + rfqCode: vendorResponseCBEView.rfqCode, + rfqDescription: vendorResponseCBEView.rfqDescription, + rfqDueDate: vendorResponseCBEView.rfqDueDate, + rfqStatus: vendorResponseCBEView.rfqStatus, + rfqType: vendorResponseCBEView.rfqType, + + // 프로젝트 정보 + projectId: vendorResponseCBEView.projectId, + projectCode: vendorResponseCBEView.projectCode, + projectName: vendorResponseCBEView.projectName, + + // 응답 상태 정보 + responseStatus: vendorResponseCBEView.responseStatus, + responseNotes: vendorResponseCBEView.notes, + respondedAt: vendorResponseCBEView.respondedAt, + respondedBy: vendorResponseCBEView.respondedBy, + + // 상업 응답 정보 + commercialResponseId: vendorResponseCBEView.commercialResponseId, + commercialResponseStatus: vendorResponseCBEView.commercialResponseStatus, + totalPrice: vendorResponseCBEView.totalPrice, + currency: vendorResponseCBEView.currency, + paymentTerms: vendorResponseCBEView.paymentTerms, + incoterms: vendorResponseCBEView.incoterms, + deliveryPeriod: vendorResponseCBEView.deliveryPeriod, + warrantyPeriod: vendorResponseCBEView.warrantyPeriod, + validityPeriod: vendorResponseCBEView.validityPeriod, + commercialNotes: vendorResponseCBEView.commercialNotes, + + // 첨부파일 카운트 + attachmentCount: vendorResponseCBEView.attachmentCount, + commercialAttachmentCount: vendorResponseCBEView.commercialAttachmentCount, + technicalAttachmentCount: vendorResponseCBEView.technicalAttachmentCount, + }) + .from(vendorResponseCBEView) + .where(finalWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(limit); + + const [{ count }] = await tx + .select({ count: sql<number>`count(*)`.as("count") }) + .from(vendorResponseCBEView) + .where(finalWhere); + + return [data, Number(count)]; + }); + + if (!rows.length) { + return { data: [], pageCount: 0, total: 0 }; + } + + // [8] RFQ ID 목록 추출 + const distinctRfqIds = [...new Set(rows.map((r) => r.rfqId))]; + const distinctResponseIds = [...new Set(rows.map((r) => r.responseId))]; + const distinctCommercialResponseIds = [...new Set(rows.filter(r => r.commercialResponseId).map((r) => r.commercialResponseId!))]; + + // [9] CBE 평가 관련 코멘트 조회 + const commentsAll = await db + .select({ + id: rfqComments.id, + commentText: rfqComments.commentText, + rfqId: rfqComments.rfqId, + cbeId: rfqComments.cbeId, + createdAt: rfqComments.createdAt, + commentedBy: rfqComments.commentedBy, + }) + .from(rfqComments) + .innerJoin( + vendorResponses, + eq(vendorResponses.id, rfqComments.cbeId) + ) + .where( + and( + isNotNull(rfqComments.cbeId), + eq(rfqComments.vendorId, vendorId), + inArray(rfqComments.rfqId, distinctRfqIds) + ) + ); + + // rfqId별 코멘트 그룹화 + const commentsByRfqId = new Map<number, any[]>(); + for (const comment of commentsAll) { + const rfqId = comment.rfqId!; + if (!commentsByRfqId.has(rfqId)) { + commentsByRfqId.set(rfqId, []); + } + commentsByRfqId.get(rfqId)!.push({ + id: comment.id, + commentText: comment.commentText, + rfqId: comment.rfqId, + cbeId: comment.cbeId, + createdAt: comment.createdAt, + commentedBy: comment.commentedBy, + }); + } + + // [10] 첨부 파일 조회 - 일반 응답 첨부파일 + const responseAttachments = await db + .select({ + id: vendorResponseAttachments.id, + fileName: vendorResponseAttachments.fileName, + filePath: vendorResponseAttachments.filePath, + responseId: vendorResponseAttachments.responseId, + fileType: vendorResponseAttachments.fileType, + attachmentType: vendorResponseAttachments.attachmentType, + description: vendorResponseAttachments.description, + uploadedAt: vendorResponseAttachments.uploadedAt, + uploadedBy: vendorResponseAttachments.uploadedBy, + }) + .from(vendorResponseAttachments) + .where( + and( + inArray(vendorResponseAttachments.responseId, distinctResponseIds), + isNotNull(vendorResponseAttachments.responseId) + ) + ); + + // [11] 첨부 파일 조회 - 상업 응답 첨부파일 + const commercialResponseAttachments = await db + .select({ + id: vendorResponseAttachments.id, + fileName: vendorResponseAttachments.fileName, + filePath: vendorResponseAttachments.filePath, + commercialResponseId: vendorResponseAttachments.commercialResponseId, + fileType: vendorResponseAttachments.fileType, + attachmentType: vendorResponseAttachments.attachmentType, + description: vendorResponseAttachments.description, + uploadedAt: vendorResponseAttachments.uploadedAt, + uploadedBy: vendorResponseAttachments.uploadedBy, + }) + .from(vendorResponseAttachments) + .where( + and( + inArray(vendorResponseAttachments.commercialResponseId, distinctCommercialResponseIds), + isNotNull(vendorResponseAttachments.commercialResponseId) + ) + ); + + // [12] 첨부파일 그룹화 + // responseId별 첨부파일 맵 생성 + const filesByResponseId = new Map<number, any[]>(); + for (const file of responseAttachments) { + const responseId = file.responseId!; + if (!filesByResponseId.has(responseId)) { + filesByResponseId.set(responseId, []); + } + filesByResponseId.get(responseId)!.push({ + id: file.id, + fileName: file.fileName, + filePath: file.filePath, + fileType: file.fileType, + attachmentType: file.attachmentType, + description: file.description, + uploadedAt: file.uploadedAt, + uploadedBy: file.uploadedBy, + attachmentSource: 'response' + }); + } + + // commercialResponseId별 첨부파일 맵 생성 + const filesByCommercialResponseId = new Map<number, any[]>(); + for (const file of commercialResponseAttachments) { + const commercialResponseId = file.commercialResponseId!; + if (!filesByCommercialResponseId.has(commercialResponseId)) { + filesByCommercialResponseId.set(commercialResponseId, []); + } + filesByCommercialResponseId.get(commercialResponseId)!.push({ + id: file.id, + fileName: file.fileName, + filePath: file.filePath, + fileType: file.fileType, + attachmentType: file.attachmentType, + description: file.description, + uploadedAt: file.uploadedAt, + uploadedBy: file.uploadedBy, + attachmentSource: 'commercial' + }); + } + + // [13] 최종 데이터 병합 + const final = rows.map((row) => { + // 해당 응답의 모든 첨부파일 가져오기 + const responseFiles = filesByResponseId.get(row.responseId) || []; + const commercialFiles = row.commercialResponseId + ? filesByCommercialResponseId.get(row.commercialResponseId) || [] + : []; + + // 모든 첨부파일 병합 + const allFiles = [...responseFiles, ...commercialFiles]; + + return { + ...row, + rfqDueDate: row.rfqDueDate ? new Date(row.rfqDueDate) : null, + respondedAt: row.respondedAt ? new Date(row.respondedAt) : null, + comments: commentsByRfqId.get(row.rfqId) || [], + files: allFiles, + }; + }); + + const pageCount = Math.ceil(total / limit); + return { + data: final, + pageCount, + total + }; + }, + // 캐싱 키 & 옵션 + [`cbe-vendor-${vendorId}-${JSON.stringify(input)}`], + { + revalidate: 3600, + tags: [`cbe-vendor-${vendorId}`], + } + )(); +} + +export async function fetchCbeFiles(vendorId: number, rfqId: number) { + try { + // 1. 먼저 해당 RFQ와 벤더에 해당하는 CBE 평가 레코드를 찾습니다. + const cbeEval = await db + .select({ id: cbeEvaluations.id }) + .from(cbeEvaluations) + .where( + and( + eq(cbeEvaluations.rfqId, rfqId), + eq(cbeEvaluations.vendorId, vendorId) + ) + ) + .limit(1) + + if (!cbeEval.length) { + return { + files: [], + error: "해당 RFQ와 벤더에 대한 CBE 평가를 찾을 수 없습니다." + } + } + + const cbeId = cbeEval[0].id + + // 2. 관련 첨부 파일을 조회합니다. + // - commentId와 evaluationId는 null이어야 함 + // - rfqId와 vendorId가 일치해야 함 + // - cbeId가 위에서 찾은 CBE 평가 ID와 일치해야 함 + const files = await db + .select({ + id: rfqAttachments.id, + fileName: rfqAttachments.fileName, + filePath: rfqAttachments.filePath, + createdAt: rfqAttachments.createdAt + }) + .from(rfqAttachments) + .where( + and( + eq(rfqAttachments.rfqId, rfqId), + eq(rfqAttachments.vendorId, vendorId), + eq(rfqAttachments.cbeId, cbeId), + isNull(rfqAttachments.commentId), + isNull(rfqAttachments.evaluationId) + ) + ) + .orderBy(rfqAttachments.createdAt) + + return { + files, + cbeId + } + } catch (error) { + console.error("CBE 파일 조회 중 오류 발생:", error) + return { + files: [], + error: "CBE 파일을 가져오는 중 오류가 발생했습니다." + } + } +} + +export async function getAllCBE(input: GetCBESchema) { + return unstable_cache( + async () => { + // [1] 페이징 + const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10); + const limit = input.perPage ?? 10; + + // [2] 고급 필터 + const advancedWhere = filterColumns({ + table: vendorResponseCBEView, + filters: input.filters ?? [], + joinOperator: input.joinOperator ?? "and", + }); + + // [3] 글로벌 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + sql`${vendorResponseCBEView.vendorName} ILIKE ${s}`, + sql`${vendorResponseCBEView.vendorCode} ILIKE ${s}`, + sql`${vendorResponseCBEView.rfqCode} ILIKE ${s}`, + sql`${vendorResponseCBEView.projectCode} ILIKE ${s}`, + sql`${vendorResponseCBEView.projectName} ILIKE ${s}`, + sql`${vendorResponseCBEView.totalPrice}::text ILIKE ${s}` + ); + } + + // [4] DECLINED 상태 제외 (거절된 업체는 표시하지 않음) + const notDeclined = ne(vendorResponseCBEView.responseStatus, "DECLINED"); + + // [5] rfqType 필터 추가 + const rfqTypeFilter = input.rfqType ? eq(vendorResponseCBEView.rfqType, input.rfqType) : undefined; + + // [6] 최종 where 조건 + const finalWhere = and( + notDeclined, + advancedWhere ?? undefined, + globalWhere ?? undefined, + rfqTypeFilter // 새로 추가된 rfqType 필터 + ); + + // [7] 정렬 + const orderBy = input.sort?.length + ? input.sort.map((s) => { + // vendorResponseCBEView 컬럼 중 정렬 대상이 되는 것만 매핑 + const col = (vendorResponseCBEView as any)[s.id]; + return s.desc ? desc(col) : asc(col); + }) + : [desc(vendorResponseCBEView.rfqId), asc(vendorResponseCBEView.vendorName)]; // 기본 정렬은 최신 RFQ 먼저, 그 다음 벤더명 + + // [8] 메인 SELECT + const [rows, total] = await db.transaction(async (tx) => { + const data = await tx + .select({ + // 기본 식별 정보 + responseId: vendorResponseCBEView.responseId, + vendorId: vendorResponseCBEView.vendorId, + rfqId: vendorResponseCBEView.rfqId, + + // 협력업체 정보 + vendorName: vendorResponseCBEView.vendorName, + vendorCode: vendorResponseCBEView.vendorCode, + vendorStatus: vendorResponseCBEView.vendorStatus, + + // RFQ 정보 + rfqCode: vendorResponseCBEView.rfqCode, + rfqDescription: vendorResponseCBEView.rfqDescription, + rfqDueDate: vendorResponseCBEView.rfqDueDate, + rfqStatus: vendorResponseCBEView.rfqStatus, + rfqType: vendorResponseCBEView.rfqType, + + // 프로젝트 정보 + projectId: vendorResponseCBEView.projectId, + projectCode: vendorResponseCBEView.projectCode, + projectName: vendorResponseCBEView.projectName, + + // 응답 상태 정보 + responseStatus: vendorResponseCBEView.responseStatus, + responseNotes: vendorResponseCBEView.notes, + respondedAt: vendorResponseCBEView.respondedAt, + respondedBy: vendorResponseCBEView.respondedBy, + + // 상업 응답 정보 + commercialResponseId: vendorResponseCBEView.commercialResponseId, + commercialResponseStatus: vendorResponseCBEView.commercialResponseStatus, + totalPrice: vendorResponseCBEView.totalPrice, + currency: vendorResponseCBEView.currency, + paymentTerms: vendorResponseCBEView.paymentTerms, + incoterms: vendorResponseCBEView.incoterms, + deliveryPeriod: vendorResponseCBEView.deliveryPeriod, + warrantyPeriod: vendorResponseCBEView.warrantyPeriod, + validityPeriod: vendorResponseCBEView.validityPeriod, + commercialNotes: vendorResponseCBEView.commercialNotes, + + // 첨부파일 카운트 + attachmentCount: vendorResponseCBEView.attachmentCount, + commercialAttachmentCount: vendorResponseCBEView.commercialAttachmentCount, + technicalAttachmentCount: vendorResponseCBEView.technicalAttachmentCount, + }) + .from(vendorResponseCBEView) + .where(finalWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(limit); + + const [{ count }] = await tx + .select({ count: sql<number>`count(*)`.as("count") }) + .from(vendorResponseCBEView) + .where(finalWhere); + + return [data, Number(count)]; + }); + + if (!rows.length) { + return { data: [], pageCount: 0, total: 0 }; + } + + // [9] 고유한 rfqIds와 vendorIds 추출 - null 필터링 + const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId).filter(Boolean))] as number[]; + const distinctRfqIds = [...new Set(rows.map((r) => r.rfqId).filter(Boolean))] as number[]; + const distinctResponseIds = [...new Set(rows.map((r) => r.responseId).filter(Boolean))] as number[]; + const distinctCommercialResponseIds = [...new Set(rows.filter(r => r.commercialResponseId).map((r) => r.commercialResponseId!))]; + + // [10] CBE 평가 관련 코멘트 조회 + const commentsConditions = [isNotNull(rfqComments.cbeId)]; + + // 배열이 비어있지 않을 때만 조건 추가 + if (distinctRfqIds.length > 0) { + commentsConditions.push(inArray(rfqComments.rfqId, distinctRfqIds)); + } + + if (distinctVendorIds.length > 0) { + commentsConditions.push(inArray(rfqComments.vendorId, distinctVendorIds)); + } + + const commentsAll = await db + .select({ + id: rfqComments.id, + commentText: rfqComments.commentText, + vendorId: rfqComments.vendorId, + rfqId: rfqComments.rfqId, + cbeId: rfqComments.cbeId, + createdAt: rfqComments.createdAt, + commentedBy: rfqComments.commentedBy, + }) + .from(rfqComments) + .innerJoin( + vendorResponses, + eq(vendorResponses.id, rfqComments.cbeId) + ) + .where(and(...commentsConditions)); + + // [11] 복합 키(rfqId-vendorId)별 코멘트 그룹화 + const commentsByCompositeKey = new Map<string, any[]>(); + for (const comment of commentsAll) { + if (!comment.rfqId || !comment.vendorId) continue; + + const compositeKey = `${comment.rfqId}-${comment.vendorId}`; + if (!commentsByCompositeKey.has(compositeKey)) { + commentsByCompositeKey.set(compositeKey, []); + } + commentsByCompositeKey.get(compositeKey)!.push({ + id: comment.id, + commentText: comment.commentText, + vendorId: comment.vendorId, + cbeId: comment.cbeId, + createdAt: comment.createdAt, + commentedBy: comment.commentedBy, + }); + } + + // [12] 첨부 파일 조회 - 일반 응답 첨부파일 + const responseAttachments = await db + .select({ + id: vendorResponseAttachments.id, + fileName: vendorResponseAttachments.fileName, + filePath: vendorResponseAttachments.filePath, + responseId: vendorResponseAttachments.responseId, + fileType: vendorResponseAttachments.fileType, + attachmentType: vendorResponseAttachments.attachmentType, + description: vendorResponseAttachments.description, + uploadedAt: vendorResponseAttachments.uploadedAt, + uploadedBy: vendorResponseAttachments.uploadedBy, + }) + .from(vendorResponseAttachments) + .where( + and( + inArray(vendorResponseAttachments.responseId, distinctResponseIds), + isNotNull(vendorResponseAttachments.responseId) + ) + ); + + // [13] 첨부 파일 조회 - 상업 응답 첨부파일 + const commercialResponseAttachments = await db + .select({ + id: vendorResponseAttachments.id, + fileName: vendorResponseAttachments.fileName, + filePath: vendorResponseAttachments.filePath, + commercialResponseId: vendorResponseAttachments.commercialResponseId, + fileType: vendorResponseAttachments.fileType, + attachmentType: vendorResponseAttachments.attachmentType, + description: vendorResponseAttachments.description, + uploadedAt: vendorResponseAttachments.uploadedAt, + uploadedBy: vendorResponseAttachments.uploadedBy, + }) + .from(vendorResponseAttachments) + .where( + and( + inArray(vendorResponseAttachments.commercialResponseId, distinctCommercialResponseIds), + isNotNull(vendorResponseAttachments.commercialResponseId) + ) + ); + + // [14] 첨부파일 그룹화 + // responseId별 첨부파일 맵 생성 + const filesByResponseId = new Map<number, any[]>(); + for (const file of responseAttachments) { + const responseId = file.responseId!; + if (!filesByResponseId.has(responseId)) { + filesByResponseId.set(responseId, []); + } + filesByResponseId.get(responseId)!.push({ + id: file.id, + fileName: file.fileName, + filePath: file.filePath, + fileType: file.fileType, + attachmentType: file.attachmentType, + description: file.description, + uploadedAt: file.uploadedAt, + uploadedBy: file.uploadedBy, + attachmentSource: 'response' + }); + } + + // commercialResponseId별 첨부파일 맵 생성 + const filesByCommercialResponseId = new Map<number, any[]>(); + for (const file of commercialResponseAttachments) { + const commercialResponseId = file.commercialResponseId!; + if (!filesByCommercialResponseId.has(commercialResponseId)) { + filesByCommercialResponseId.set(commercialResponseId, []); + } + filesByCommercialResponseId.get(commercialResponseId)!.push({ + id: file.id, + fileName: file.fileName, + filePath: file.filePath, + fileType: file.fileType, + attachmentType: file.attachmentType, + description: file.description, + uploadedAt: file.uploadedAt, + uploadedBy: file.uploadedBy, + attachmentSource: 'commercial' + }); + } + + // [15] 복합 키(rfqId-vendorId)별 첨부파일 맵 생성 + const filesByCompositeKey = new Map<string, any[]>(); + + // responseId -> rfqId-vendorId 매핑 생성 + const responseIdToCompositeKey = new Map<number, string>(); + for (const row of rows) { + if (row.responseId) { + responseIdToCompositeKey.set(row.responseId, `${row.rfqId}-${row.vendorId}`); + } + if (row.commercialResponseId) { + responseIdToCompositeKey.set(row.commercialResponseId, `${row.rfqId}-${row.vendorId}`); + } + } + + // responseId별 첨부파일을 복합 키별로 그룹화 + for (const [responseId, files] of filesByResponseId.entries()) { + const compositeKey = responseIdToCompositeKey.get(responseId); + if (compositeKey) { + if (!filesByCompositeKey.has(compositeKey)) { + filesByCompositeKey.set(compositeKey, []); + } + filesByCompositeKey.get(compositeKey)!.push(...files); + } + } + + // commercialResponseId별 첨부파일을 복합 키별로 그룹화 + for (const [commercialResponseId, files] of filesByCommercialResponseId.entries()) { + const compositeKey = responseIdToCompositeKey.get(commercialResponseId); + if (compositeKey) { + if (!filesByCompositeKey.has(compositeKey)) { + filesByCompositeKey.set(compositeKey, []); + } + filesByCompositeKey.get(compositeKey)!.push(...files); + } + } + + // [16] 최종 데이터 병합 + const final = rows.map((row) => { + const compositeKey = `${row.rfqId}-${row.vendorId}`; + + return { + ...row, + rfqDueDate: row.rfqDueDate ? new Date(row.rfqDueDate) : null, + respondedAt: row.respondedAt ? new Date(row.respondedAt) : null, + comments: commentsByCompositeKey.get(compositeKey) || [], + files: filesByCompositeKey.get(compositeKey) || [], + }; + }); + + const pageCount = Math.ceil(total / limit); + return { + data: final, + pageCount, + total + }; + }, + // 캐싱 키 & 옵션 + [`all-cbe-vendors-${JSON.stringify(input)}`], + { + revalidate: 3600, + tags: ["all-cbe-vendors"], + } + )(); }
\ No newline at end of file diff --git a/lib/rfqs/table/add-rfq-dialog.tsx b/lib/rfqs/table/add-rfq-dialog.tsx index 41055608..9d4d7cf0 100644 --- a/lib/rfqs/table/add-rfq-dialog.tsx +++ b/lib/rfqs/table/add-rfq-dialog.tsx @@ -16,6 +16,7 @@ import { createRfq, generateNextRfqCode, getBudgetaryRfqs } from "../service" import { ProjectSelector } from "@/components/ProjectSelector" import { type Project } from "../service" import { ParentRfqSelector } from "./ParentRfqSelector" +import { EstimateProjectSelector } from "@/components/BidProjectSelector" // 부모 RFQ 정보 타입 정의 interface ParentRfq { @@ -43,18 +44,13 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) // Get the user ID safely, ensuring it's a valid number const userId = React.useMemo(() => { const id = session?.user?.id ? Number(session.user.id) : null; - - // Debug logging - remove in production - console.log("Session status:", status); - console.log("Session data:", session); - console.log("User ID:", id); - + return id; }, [session, status]); // RfqType에 따른 타이틀 생성 const getTitle = () => { - switch(rfqType) { + switch (rfqType) { case RfqType.PURCHASE: return "Purchase RFQ"; case RfqType.BUDGETARY: @@ -68,7 +64,7 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) // RfqType 설명 가져오기 const getTypeDescription = () => { - switch(rfqType) { + switch (rfqType) { case RfqType.PURCHASE: return "실제 구매 발주 전에 가격을 요청"; case RfqType.BUDGETARY: @@ -111,12 +107,12 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) try { // 서버 액션 호출 const result = await generateNextRfqCode(rfqType); - + if (result.error) { toast.error(`RFQ 코드 생성 실패: ${result.error}`); return; } - + // 생성된 코드를 폼에 설정 form.setValue("rfqCode", result.code); } catch (error) { @@ -126,14 +122,14 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) setIsLoadingRfqCode(false); } }; - + generateRfqCode(); } }, [open, rfqType, form]); // 현재 RFQ 타입에 따라 선택 가능한 부모 RFQ 타입들 결정 const getParentRfqTypes = (): RfqType[] => { - switch(rfqType) { + switch (rfqType) { case RfqType.PURCHASE: // PURCHASE는 BUDGETARY와 PURCHASE_BUDGETARY를 부모로 가질 수 있음 return [RfqType.BUDGETARY, RfqType.PURCHASE_BUDGETARY]; @@ -153,13 +149,13 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) try { // 현재 RFQ 타입에 따라 선택 가능한, 부모가 될 수 있는 RFQ 타입들 가져오기 const parentTypes = getParentRfqTypes(); - + // 부모 RFQ 타입이 있을 때만 API 호출 if (parentTypes.length > 0) { const result = await getBudgetaryRfqs({ rfqTypes: parentTypes // 서비스에 rfqTypes 파라미터 추가 필요 }); - + if ('rfqs' in result) { setParentRfqs(result.rfqs as unknown as ParentRfq[]); } else if ('error' in result) { @@ -186,6 +182,14 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) form.setValue("projectId", project.id); }; + const handleBidProjectSelect = (project: Project | null) => { + if (project === null) { + return; + } + + form.setValue("bidProjectId", project.id); + }; + // 부모 RFQ 선택 처리 const handleParentRfqSelect = (rfq: ParentRfq | null) => { setSelectedParentRfq(rfq); @@ -212,7 +216,7 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) toast.error(`에러: ${result.error}`); return; } - + toast.success("RFQ가 성공적으로 생성되었습니다."); form.reset(); setSelectedParentRfq(null); @@ -234,7 +238,8 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) // 타입에 따라 부모 RFQ 선택 필드를 보여줄지 결정 const shouldShowParentRfqSelector = rfqType === RfqType.PURCHASE || rfqType === RfqType.PURCHASE_BUDGETARY; - + const shouldShowEstimateSelector = rfqType === RfqType.BUDGETARY; + // 부모 RFQ 선택기 레이블 및 설명 가져오기 const getParentRfqSelectorLabel = () => { if (rfqType === RfqType.PURCHASE) { @@ -294,11 +299,18 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) <FormItem> <FormLabel>Project</FormLabel> <FormControl> - <ProjectSelector - selectedProjectId={field.value} - onProjectSelect={handleProjectSelect} - placeholder="프로젝트 선택..." - /> + + {shouldShowEstimateSelector ? + <EstimateProjectSelector + selectedProjectId={field.value} + onProjectSelect={handleBidProjectSelect} + placeholder="견적 프로젝트 선택..." + /> : + <ProjectSelector + selectedProjectId={field.value} + onProjectSelect={handleProjectSelect} + placeholder="프로젝트 선택..." + />} </FormControl> <FormMessage /> </FormItem> @@ -317,11 +329,11 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) <ParentRfqSelector selectedRfqId={field.value as number | undefined} onRfqSelect={handleParentRfqSelect} - rfqType={rfqType} + rfqType={rfqType} parentRfqTypes={getParentRfqTypes()} placeholder={ - rfqType === RfqType.PURCHASE - ? "BUDGETARY 또는 PURCHASE_BUDGETARY RFQ 선택..." + rfqType === RfqType.PURCHASE + ? "BUDGETARY 또는 PURCHASE_BUDGETARY RFQ 선택..." : "BUDGETARY RFQ 선택..." } /> @@ -344,9 +356,9 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) <FormLabel>RFQ Code</FormLabel> <FormControl> <div className="flex"> - <Input - placeholder="자동으로 생성 중..." - {...field} + <Input + placeholder="자동으로 생성 중..." + {...field} disabled={true} className="bg-muted" /> @@ -416,7 +428,7 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) disabled className="capitalize" {...field} - onChange={() => {}} // Prevent changes + onChange={() => { }} // Prevent changes /> </FormControl> <FormMessage /> @@ -433,8 +445,8 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) > Cancel </Button> - <Button - type="submit" + <Button + type="submit" disabled={form.formState.isSubmitting || status !== "authenticated"} > Create diff --git a/lib/rfqs/table/rfqs-table.tsx b/lib/rfqs/table/rfqs-table.tsx index e4ff47d8..287f1d53 100644 --- a/lib/rfqs/table/rfqs-table.tsx +++ b/lib/rfqs/table/rfqs-table.tsx @@ -216,7 +216,7 @@ export function RfqsTable({ promises, rfqType = RfqType.PURCHASE }: RfqsTablePro <div style={{ maxWidth: '100vw' }}> <DataTable table={table} - floatingBar={<RfqsTableFloatingBar table={table} />} + // floatingBar={<RfqsTableFloatingBar table={table} />} > <DataTableAdvancedToolbar table={table} 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 diff --git a/lib/rfqs/validations.ts b/lib/rfqs/validations.ts index 9e9e96cc..59e9e362 100644 --- a/lib/rfqs/validations.ts +++ b/lib/rfqs/validations.ts @@ -2,18 +2,18 @@ import { createSearchParamsCache, parseAsArrayOf, parseAsInteger, parseAsString, - parseAsStringEnum, + parseAsStringEnum,parseAsBoolean } from "nuqs/server" import * as z from "zod" import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" -import { Rfq, rfqs, RfqsView, VendorCbeView, VendorRfqViewBase, VendorTbeView } from "@/db/schema/rfq"; +import { Rfq, rfqs, RfqsView, VendorCbeView, VendorResponseCBEView, VendorRfqViewBase, VendorTbeView } from "@/db/schema/rfq"; import { Vendor, vendors } from "@/db/schema/vendors"; export const RfqType = { PURCHASE_BUDGETARY: "PURCHASE_BUDGETARY", PURCHASE: "PURCHASE", - BUDGETARY: "BUDGETARY" + BUDGETARY: "c" } as const; export type RfqType = typeof RfqType[keyof typeof RfqType]; @@ -129,6 +129,7 @@ export const createRfqSchema = z.object({ rfqCode: z.string().min(3, "RFQ 코드는 최소 3글자 이상이어야 합니다"), description: z.string().optional(), projectId: z.number().nullable().optional(), // 프로젝트 ID (선택적) + bidProjectId: z.number().nullable().optional(), // 프로젝트 ID (선택적) parentRfqId: z.number().nullable().optional(), // 부모 RFQ ID (선택적) dueDate: z.date(), status: z.enum(["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"]), @@ -227,50 +228,70 @@ export const updateRfqVendorSchema = z.object({ export type UpdateRfqVendorSchema = z.infer<typeof updateRfqVendorSchema> - - export const searchParamsCBECache = createSearchParamsCache({ // 1) 공통 플래그 flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - + // 2) 페이지네이션 page: parseAsInteger.withDefault(1), perPage: parseAsInteger.withDefault(10), - - // 3) 정렬 (Rfq 테이블) - // getSortingStateParser<Rfq>() → Rfq 테이블의 컬럼명에 맞춘 유효성 검사 - sort: getSortingStateParser<VendorCbeView>().withDefault([ - { id: "cbeUpdated", desc: true }, + + // 3) 정렬 (VendorResponseCBEView 테이블) + // getSortingStateParser<VendorResponseCBEView>() → CBE 테이블의 컬럼명에 맞춤 + sort: getSortingStateParser<VendorResponseCBEView>().withDefault([ + { id: "totalPrice", desc: true }, ]), - - // 4) 간단 검색 필드 + + // 4) 간단 검색 필드 - 기본 정보 vendorName: parseAsString.withDefault(""), vendorCode: parseAsString.withDefault(""), country: parseAsString.withDefault(""), email: parseAsString.withDefault(""), website: parseAsString.withDefault(""), - - cbeResult: parseAsString.withDefault(""), - cbeNote: parseAsString.withDefault(""), - cbeUpdated: parseAsString.withDefault(""), - rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY", "PURCHASE_BUDGETARY"]).withDefault("PURCHASE"), - - - totalCost: parseAsInteger.withDefault(0), + + // CBE 관련 필드 + commercialResponseId: parseAsString.withDefault(""), + totalPrice: parseAsString.withDefault(""), currency: parseAsString.withDefault(""), paymentTerms: parseAsString.withDefault(""), incoterms: parseAsString.withDefault(""), - deliverySchedule: parseAsString.withDefault(""), - - // 5) 상태 (배열) - Rfq["status"]는 "DRAFT"|"PUBLISHED"|"EVALUATION"|"AWARDED" - // rfqs.status.enumValues 로 가져온 문자열 배열을 z.enum([...])로 처리 + deliveryPeriod: parseAsString.withDefault(""), + warrantyPeriod: parseAsString.withDefault(""), + validityPeriod: parseAsString.withDefault(""), + + // RFQ 관련 필드 + rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY", "PURCHASE_BUDGETARY"]).withDefault("PURCHASE"), + + // 응답 상태 + responseStatus: parseAsStringEnum(["INVITED", "ACCEPTED", "DECLINED", "REVIEWING", "RESPONDED"]).withDefault("REVIEWING"), + + // 5) 상태 (배열) - vendor 상태 vendorStatus: parseAsArrayOf(z.enum(vendors.status.enumValues)).withDefault([]), - + // 6) 고급 필터 (nuqs - filterColumns) filters: getFiltersStateParser().withDefault([]), joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - + // 7) 글로벌 검색어 search: parseAsString.withDefault(""), + + // 8) 첨부파일 관련 필터 + hasAttachments: parseAsBoolean.withDefault(false), + + // 9) 날짜 범위 필터 + respondedAtRange: parseAsString.withDefault(""), + commercialUpdatedAtRange: parseAsString.withDefault(""), }) + export type GetCBESchema = Awaited<ReturnType<typeof searchParamsCBECache.parse>>; + + +export const createCbeEvaluationSchema = z.object({ + paymentTerms: z.string().min(1, "결제 조건을 입력하세요"), + incoterms: z.string().min(1, "Incoterms를 입력하세요"), + deliverySchedule: z.string().min(1, "배송 일정을 입력하세요"), + notes: z.string().optional(), +}) + +// 타입 추출 +export type CreateCbeEvaluationSchema = z.infer<typeof createCbeEvaluationSchema>
\ No newline at end of file diff --git a/lib/rfqs/vendor-table/comments-sheet.tsx b/lib/rfqs/vendor-table/comments-sheet.tsx index 3a2a9353..441fdcf1 100644 --- a/lib/rfqs/vendor-table/comments-sheet.tsx +++ b/lib/rfqs/vendor-table/comments-sheet.tsx @@ -53,7 +53,7 @@ export interface MatchedVendorComment { commentText: string commentedBy?: number commentedByEmail?: string - createdAt?: Date + createdAt?: Date attachments?: { id: number fileName: string @@ -90,8 +90,6 @@ export function CommentSheet({ ...props }: CommentSheetProps) { - console.log(initialComments) - const [comments, setComments] = React.useState<MatchedVendorComment[]>(initialComments) const [isPending, startTransition] = React.useTransition() @@ -138,7 +136,7 @@ export function CommentSheet({ </TableRow> </TableHeader> <TableBody> - {comments.map((c) => ( + {comments.map((c) => ( <TableRow key={c.id}> <TableCell>{c.commentText}</TableCell> <TableCell> @@ -150,7 +148,7 @@ export function CommentSheet({ {c.attachments.map((att) => ( <div key={att.id} className="flex items-center gap-2"> <a - href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`} + href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`} download target="_blank" rel="noreferrer" @@ -164,7 +162,7 @@ export function CommentSheet({ </div> )} </TableCell> - <TableCell> { c.createdAt ? formatDate(c.createdAt): "-"}</TableCell> + <TableCell> {c.createdAt ? formatDate(c.createdAt) : "-"}</TableCell> <TableCell>{c.commentedByEmail ?? "-"}</TableCell> </TableRow> ))} diff --git a/lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx b/lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx index c436eebd..e34a5052 100644 --- a/lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx +++ b/lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx @@ -52,7 +52,7 @@ export function VendorsListTable({ rfqId }: VendorsListTableProps) { const allVendors = await getAllVendors() setVendors(allVendors) } catch (error) { - console.error("벤더 목록 로드 오류:", error) + console.error("협력업체 목록 로드 오류:", error) toast({ title: "Error", description: "Failed to load vendors", diff --git a/lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx b/lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx index abb34f85..864d0f4b 100644 --- a/lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx +++ b/lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx @@ -21,14 +21,14 @@ export function VendorsTableToolbarActions({ table, rfqId }: VendorsTableToolbar // 선택된 모든 행 const selectedRows = table.getFilteredSelectedRowModel().rows - // 조건에 맞는 벤더만 필터링 + // 조건에 맞는 협력업체만 필터링 const eligibleVendors = React.useMemo(() => { return selectedRows .map(row => row.original) .filter(vendor => !vendor.rfqVendorStatus || vendor.rfqVendorStatus === "INVITED") }, [selectedRows]) - // 조건에 맞지 않는 벤더 수 + // 조건에 맞지 않는 협력업체 수 const ineligibleCount = selectedRows.length - eligibleVendors.length function handleImportClick() { @@ -36,17 +36,17 @@ export function VendorsTableToolbarActions({ table, rfqId }: VendorsTableToolbar } function handleInviteClick() { - // 조건에 맞지 않는 벤더가 있다면 토스트 메시지 표시 + // 조건에 맞지 않는 협력업체가 있다면 토스트 메시지 표시 if (ineligibleCount > 0) { toast({ - title: "일부 벤더만 초대됩니다", + title: "일부 협력업체만 초대됩니다", description: `선택한 ${selectedRows.length}개 중 ${eligibleVendors.length}개만 초대 가능합니다. 나머지 ${ineligibleCount}개는 초대 불가능한 상태입니다.`, // variant: "warning", }) } } - // 다이얼로그 표시 여부 - 적합한 벤더가 1개 이상 있으면 표시 + // 다이얼로그 표시 여부 - 적합한 협력업체가 1개 이상 있으면 표시 const showInviteDialog = eligibleVendors.length > 0 return ( @@ -70,7 +70,7 @@ export function VendorsTableToolbarActions({ table, rfqId }: VendorsTableToolbar variant="default" size="sm" disabled={true} - title="선택된 벤더 중 초대 가능한 벤더가 없습니다" + title="선택된 협력업체 중 초대 가능한 협력업체가 없습니다" > 초대 불가 </Button> diff --git a/lib/rfqs/vendor-table/vendors-table.tsx b/lib/rfqs/vendor-table/vendors-table.tsx index ae9cba41..b2e4d5ad 100644 --- a/lib/rfqs/vendor-table/vendors-table.tsx +++ b/lib/rfqs/vendor-table/vendors-table.tsx @@ -74,17 +74,17 @@ export function MatchedVendorsTable({ promises, rfqId, rfqType }: VendorsTablePr async function openCommentSheet(vendorId: number) { // Clear previous comments setInitialComments([]) - + // Start loading setIsLoadingComments(true) - + // Open the sheet immediately with loading state setSelectedVendorIdForComments(vendorId) setCommentSheetOpen(true) - + // (a) 현재 Row의 comments 불러옴 const comments = rowAction?.row.original.comments - + try { if (comments && comments.length > 0) { // (b) 각 comment마다 첨부파일 fetch @@ -107,7 +107,7 @@ export function MatchedVendorsTable({ promises, rfqId, rfqType }: VendorsTablePr setIsLoadingComments(false) } } - + // 6) 컬럼 정의 (memo) const columns = React.useMemo( () => getColumns({ setRowAction, router, openCommentSheet }), @@ -164,10 +164,8 @@ export function MatchedVendorsTable({ promises, rfqId, rfqType }: VendorsTablePr // 세션에서 userId 추출하고 숫자로 변환 const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0 - console.log(currentUserId,"currentUserId") - return ( - <div style={{ maxWidth: '80vw' }}> + <> <DataTable table={table} > @@ -205,6 +203,6 @@ export function MatchedVendorsTable({ promises, rfqId, rfqType }: VendorsTablePr rowAction.row.original.comments = updatedComments }} /> - </div> + </> ) }
\ No newline at end of file |
