summaryrefslogtreecommitdiff
path: root/lib/rfqs
diff options
context:
space:
mode:
Diffstat (limited to 'lib/rfqs')
-rw-r--r--lib/rfqs/cbe-table/cbe-table-columns.tsx92
-rw-r--r--lib/rfqs/cbe-table/cbe-table-toolbar-actions.tsx67
-rw-r--r--lib/rfqs/cbe-table/cbe-table.tsx123
-rw-r--r--lib/rfqs/cbe-table/comments-sheet.tsx328
-rw-r--r--lib/rfqs/cbe-table/feature-flags-provider.tsx108
-rw-r--r--lib/rfqs/cbe-table/invite-vendors-dialog.tsx423
-rw-r--r--lib/rfqs/cbe-table/vendor-contact-dialog.tsx71
-rw-r--r--lib/rfqs/repository.ts10
-rw-r--r--lib/rfqs/service.ts1596
-rw-r--r--lib/rfqs/table/add-rfq-dialog.tsx72
-rw-r--r--lib/rfqs/table/rfqs-table.tsx2
-rw-r--r--lib/rfqs/tbe-table/comments-sheet.tsx145
-rw-r--r--lib/rfqs/tbe-table/invite-vendors-dialog.tsx39
-rw-r--r--lib/rfqs/tbe-table/tbe-result-dialog.tsx208
-rw-r--r--lib/rfqs/tbe-table/tbe-table-columns.tsx99
-rw-r--r--lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx23
-rw-r--r--lib/rfqs/tbe-table/tbe-table.tsx66
-rw-r--r--lib/rfqs/tbe-table/vendor-contact-dialog.tsx71
-rw-r--r--lib/rfqs/tbe-table/vendor-contact/vendor-contact-table-column.tsx70
-rw-r--r--lib/rfqs/tbe-table/vendor-contact/vendor-contact-table.tsx89
-rw-r--r--lib/rfqs/validations.ts75
-rw-r--r--lib/rfqs/vendor-table/comments-sheet.tsx10
-rw-r--r--lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx2
-rw-r--r--lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx12
-rw-r--r--lib/rfqs/vendor-table/vendors-table.tsx16
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