summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/b-rfq/final/final-rfq-detail-columns.tsx589
-rw-r--r--lib/b-rfq/final/final-rfq-detail-table.tsx297
-rw-r--r--lib/b-rfq/final/final-rfq-detail-toolbar-actions.tsx201
-rw-r--r--lib/b-rfq/final/update-final-rfq-sheet.tsx70
-rw-r--r--lib/b-rfq/initial/initial-rfq-detail-columns.tsx4
-rw-r--r--lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx77
-rw-r--r--lib/b-rfq/initial/short-list-confirm-dialog.tsx269
-rw-r--r--lib/b-rfq/service.ts2025
-rw-r--r--lib/b-rfq/validations.ts45
-rw-r--r--lib/evaluation/table/evaluation-table.tsx1
-rw-r--r--lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx96
-rw-r--r--lib/mail/mailer.ts146
-rw-r--r--lib/mail/sendEmail.ts15
-rw-r--r--lib/mail/templates/letter-of-regret.hbs190
-rw-r--r--lib/users/service.ts22
15 files changed, 3126 insertions, 921 deletions
diff --git a/lib/b-rfq/final/final-rfq-detail-columns.tsx b/lib/b-rfq/final/final-rfq-detail-columns.tsx
new file mode 100644
index 00000000..832923eb
--- /dev/null
+++ b/lib/b-rfq/final/final-rfq-detail-columns.tsx
@@ -0,0 +1,589 @@
+// final-rfq-detail-columns.tsx
+"use client"
+
+import * as React from "react"
+import { type ColumnDef } from "@tanstack/react-table"
+import { type Row } from "@tanstack/react-table"
+import {
+ Ellipsis, Building, Eye, Edit,
+ MessageSquare, Settings, CheckCircle2, XCircle, DollarSign, Calendar
+} from "lucide-react"
+
+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, DropdownMenuItem,
+ DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuShortcut
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { FinalRfqDetailView } from "@/db/schema"
+
+// RowAction 타입 정의
+export interface DataTableRowAction<TData> {
+ row: Row<TData>
+ type: "update"
+}
+
+interface GetFinalRfqDetailColumnsProps {
+ onSelectDetail?: (detail: any) => void
+ setRowAction?: React.Dispatch<React.SetStateAction<DataTableRowAction<FinalRfqDetailView> | null>>
+}
+
+export function getFinalRfqDetailColumns({
+ onSelectDetail,
+ setRowAction
+}: GetFinalRfqDetailColumnsProps = {}): ColumnDef<FinalRfqDetailView>[] {
+
+ return [
+ /** ───────────── 체크박스 ───────────── */
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ },
+
+ /** 1. RFQ Status */
+ {
+ accessorKey: "finalRfqStatus",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="최종 RFQ Status" />
+ ),
+ cell: ({ row }) => {
+ const status = row.getValue("finalRfqStatus") as string
+ const getFinalStatusColor = (status: string) => {
+ switch (status) {
+ case "DRAFT": return "outline"
+ case "Final RFQ Sent": return "default"
+ case "Quotation Received": return "success"
+ case "Vendor Selected": return "default"
+ default: return "secondary"
+ }
+ }
+ return (
+ <Badge variant={getFinalStatusColor(status) as any}>
+ {status}
+ </Badge>
+ )
+ },
+ size: 120
+ },
+
+ /** 2. RFQ No. */
+ {
+ accessorKey: "rfqCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ No." />
+ ),
+ cell: ({ row }) => (
+ <div className="text-sm font-medium">
+ {row.getValue("rfqCode") as string}
+ </div>
+ ),
+ size: 120,
+ },
+
+ /** 3. Rev. */
+ {
+ accessorKey: "returnRevision",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Rev." />
+ ),
+ cell: ({ row }) => {
+ const revision = row.getValue("returnRevision") as number
+ return revision > 0 ? (
+ <Badge variant="outline">
+ Rev. {revision}
+ </Badge>
+ ) : (
+ <Badge variant="outline">
+ Rev. 0
+ </Badge>
+ )
+ },
+ size: 80,
+ },
+
+ /** 4. Vendor Code */
+ {
+ accessorKey: "vendorCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Vendor Code" />
+ ),
+ cell: ({ row }) => (
+ <div className="text-sm font-medium">
+ {row.original.vendorCode}
+ </div>
+ ),
+ size: 100,
+ },
+
+ /** 5. Vendor Name */
+ {
+ accessorKey: "vendorName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Vendor Name" />
+ ),
+ cell: ({ row }) => (
+ <div className="text-sm font-medium">
+ {row.original.vendorName}
+ </div>
+ ),
+ size: 150,
+ },
+
+ /** 6. 업체분류 */
+ {
+ id: "vendorClassification",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="업체분류" />
+ ),
+ cell: ({ row }) => {
+ const vendorCode = row.original.vendorCode as string
+ return vendorCode ? (
+ <Badge variant="success" className="text-xs">
+ 정규업체
+ </Badge>
+ ) : (
+ <Badge variant="secondary" className="text-xs">
+ 잠재업체
+ </Badge>
+ )
+ },
+ size: 100,
+ },
+
+ /** 7. CP 현황 */
+ {
+ accessorKey: "cpRequestYn",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="CP 현황" />
+ ),
+ cell: ({ row }) => {
+ const cpRequest = row.getValue("cpRequestYn") as boolean
+ return cpRequest ? (
+ <Badge variant="success" className="text-xs">
+ 신청
+ </Badge>
+ ) : (
+ <Badge variant="outline" className="text-xs">
+ 미신청
+ </Badge>
+ )
+ },
+ size: 80,
+ },
+
+ /** 8. GTC현황 */
+ {
+ id: "gtcStatus",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="GTC현황" />
+ ),
+ cell: ({ row }) => {
+ const gtc = row.original.gtc as string
+ const gtcValidDate = row.original.gtcValidDate as string
+ const prjectGtcYn = row.original.prjectGtcYn as boolean
+
+ if (prjectGtcYn || gtc) {
+ return (
+ <div className="space-y-1">
+ <Badge variant="success" className="text-xs">
+ 보유
+ </Badge>
+ {gtcValidDate && (
+ <div className="text-xs text-muted-foreground">
+ {gtcValidDate}
+ </div>
+ )}
+ </div>
+ )
+ }
+ return (
+ <Badge variant="outline" className="text-xs">
+ 미보유
+ </Badge>
+ )
+ },
+ size: 100,
+ },
+
+ /** 9. TBE 결과 (스키마에 없어서 placeholder) */
+ {
+ id: "tbeResult",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="TBE 결과" />
+ ),
+ cell: ({ row }) => {
+ // TODO: TBE 결과 로직 구현 필요
+ return (
+ <span className="text-muted-foreground text-xs">-</span>
+ )
+ },
+ size: 80,
+ },
+
+ /** 10. 최종 선정 */
+ {
+ id: "finalSelection",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="최종 선정" />
+ ),
+ cell: ({ row }) => {
+ const status = row.original.finalRfqStatus as string
+ return status === "Vendor Selected" ? (
+ <Badge variant="success" className="text-xs">
+ <CheckCircle2 className="h-3 w-3 mr-1" />
+ 선정
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground text-xs">-</span>
+ )
+ },
+ size: 80,
+ },
+
+ /** 11. Currency */
+ {
+ accessorKey: "currency",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Currency" />
+ ),
+ cell: ({ row }) => {
+ const currency = row.getValue("currency") as string
+ return currency ? (
+ <Badge variant="outline" className="text-xs">
+ {/* <DollarSign className="h-3 w-3 mr-1" /> */}
+ {currency}
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 80,
+ },
+
+ /** 12. Terms of Payment */
+ {
+ accessorKey: "paymentTermsCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Terms of Payment" />
+ ),
+ cell: ({ row }) => {
+ const paymentTermsCode = row.getValue("paymentTermsCode") as string
+ return paymentTermsCode ? (
+ <Badge variant="secondary" className="text-xs">
+ {paymentTermsCode}
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 120,
+ },
+
+ /** 13. Payment Desc. */
+ {
+ accessorKey: "paymentTermsDescription",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Payment Desc." />
+ ),
+ cell: ({ row }) => {
+ const description = row.getValue("paymentTermsDescription") as string
+ return description ? (
+ <div className="text-xs max-w-[150px] truncate" title={description}>
+ {description}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 150,
+ },
+
+ /** 14. TAX */
+ {
+ accessorKey: "taxCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="TAX" />
+ ),
+ cell: ({ row }) => {
+ const taxCode = row.getValue("taxCode") as string
+ return taxCode ? (
+ <Badge variant="outline" className="text-xs">
+ {taxCode}
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 80,
+ },
+
+ /** 15. Delivery Date* */
+ {
+ accessorKey: "deliveryDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Delivery Date*" />
+ ),
+ cell: ({ row }) => {
+ const deliveryDate = row.getValue("deliveryDate") as Date
+ return deliveryDate ? (
+ <div className="text-sm">
+ {formatDate(deliveryDate)}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 120,
+ },
+
+ /** 16. Country */
+ {
+ accessorKey: "vendorCountry",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Country" />
+ ),
+ cell: ({ row }) => {
+ const country = row.getValue("vendorCountry") as string
+ const countryDisplay = country === "KR" ? "D" : "F"
+ return (
+ <Badge variant="outline" className="text-xs">
+ {countryDisplay}
+ </Badge>
+ )
+ },
+ size: 80,
+ },
+
+ /** 17. Place of Shipping */
+ {
+ accessorKey: "placeOfShipping",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Place of Shipping" />
+ ),
+ cell: ({ row }) => {
+ const placeOfShipping = row.getValue("placeOfShipping") as string
+ return placeOfShipping ? (
+ <div className="text-xs max-w-[120px] truncate" title={placeOfShipping}>
+ {placeOfShipping}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 120,
+ },
+
+ /** 18. Place of Destination */
+ {
+ accessorKey: "placeOfDestination",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Place of Destination" />
+ ),
+ cell: ({ row }) => {
+ const placeOfDestination = row.getValue("placeOfDestination") as string
+ return placeOfDestination ? (
+ <div className="text-xs max-w-[120px] truncate" title={placeOfDestination}>
+ {placeOfDestination}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 120,
+ },
+
+ /** 19. 초도 여부* */
+ {
+ accessorKey: "firsttimeYn",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="초도 여부*" />
+ ),
+ cell: ({ row }) => {
+ const firsttime = row.getValue("firsttimeYn") as boolean
+ return firsttime ? (
+ <Badge variant="success" className="text-xs">
+ 초도
+ </Badge>
+ ) : (
+ <Badge variant="outline" className="text-xs">
+ 재구매
+ </Badge>
+ )
+ },
+ size: 80,
+ },
+
+ /** 20. 연동제 적용* */
+ {
+ accessorKey: "materialPriceRelatedYn",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="연동제 적용*" />
+ ),
+ cell: ({ row }) => {
+ const materialPrice = row.getValue("materialPriceRelatedYn") as boolean
+ return materialPrice ? (
+ <Badge variant="success" className="text-xs">
+ 적용
+ </Badge>
+ ) : (
+ <Badge variant="outline" className="text-xs">
+ 미적용
+ </Badge>
+ )
+ },
+ size: 100,
+ },
+
+ /** 21. Business Size */
+ {
+ id: "businessSizeDisplay",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Business Size" />
+ ),
+ cell: ({ row }) => {
+ const businessSize = row.original.vendorBusinessSize as string
+ return businessSize ? (
+ <Badge variant="outline" className="text-xs">
+ {businessSize}
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 100,
+ },
+
+ /** 22. 최종 Update일 */
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="최종 Update일" />
+ ),
+ cell: ({ row }) => {
+ const updated = row.getValue("updatedAt") as Date
+ return updated ? (
+ <div className="text-sm">
+ {formatDate(updated)}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 120,
+ },
+
+ /** 23. 최종 Update담당자 (스키마에 없어서 placeholder) */
+ {
+ id: "updatedByUser",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="최종 Update담당자" />
+ ),
+ cell: ({ row }) => {
+ // TODO: updatedBy 사용자 정보 조인 필요
+ return (
+ <span className="text-muted-foreground text-xs">-</span>
+ )
+ },
+ size: 120,
+ },
+
+ /** 24. Vendor 설명 */
+ {
+ accessorKey: "vendorRemark",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Vendor 설명" />
+ ),
+ cell: ({ row }) => {
+ const vendorRemark = row.getValue("vendorRemark") as string
+ return vendorRemark ? (
+ <div className="text-xs max-w-[150px] truncate" title={vendorRemark}>
+ {vendorRemark}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 150,
+ },
+
+ /** 25. 비고 */
+ {
+ accessorKey: "remark",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="비고" />
+ ),
+ cell: ({ row }) => {
+ const remark = row.getValue("remark") as string
+ return remark ? (
+ <div className="text-xs max-w-[150px] truncate" title={remark}>
+ {remark}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 150,
+ },
+
+ /** ───────────── 액션 ───────────── */
+ {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-48">
+ <DropdownMenuItem>
+ <MessageSquare className="mr-2 h-4 w-4" />
+ 벤더 견적 보기
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ {setRowAction && (
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ <Edit className="mr-2 h-4 w-4" />
+ 수정
+ </DropdownMenuItem>
+ )}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ },
+ ]
+} \ No newline at end of file
diff --git a/lib/b-rfq/final/final-rfq-detail-table.tsx b/lib/b-rfq/final/final-rfq-detail-table.tsx
new file mode 100644
index 00000000..8ae42e7e
--- /dev/null
+++ b/lib/b-rfq/final/final-rfq-detail-table.tsx
@@ -0,0 +1,297 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableAdvancedFilterField, type DataTableFilterField } from "@/types/table"
+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 { getFinalRfqDetail } from "../service" // 앞서 만든 서버 액션
+import {
+ getFinalRfqDetailColumns,
+ type DataTableRowAction
+} from "./final-rfq-detail-columns"
+import { FinalRfqDetailTableToolbarActions } from "./final-rfq-detail-toolbar-actions"
+import { UpdateFinalRfqSheet } from "./update-final-rfq-sheet"
+import { FinalRfqDetailView } from "@/db/schema"
+
+interface FinalRfqDetailTableProps {
+ promises: Promise<Awaited<ReturnType<typeof getFinalRfqDetail>>>
+ rfqId?: number
+}
+
+export function FinalRfqDetailTable({ promises, rfqId }: FinalRfqDetailTableProps) {
+ const { data, pageCount } = React.use(promises)
+
+ // 선택된 상세 정보
+ const [selectedDetail, setSelectedDetail] = React.useState<any>(null)
+
+ // Row action 상태 (update만)
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<FinalRfqDetailView> | null>(null)
+
+ const columns = React.useMemo(
+ () => getFinalRfqDetailColumns({
+ onSelectDetail: setSelectedDetail,
+ setRowAction: setRowAction
+ }),
+ []
+ )
+
+ /**
+ * 필터 필드 정의
+ */
+ const filterFields: DataTableFilterField<any>[] = [
+ {
+ id: "rfqCode",
+ label: "RFQ 코드",
+ placeholder: "RFQ 코드로 검색...",
+ },
+ {
+ id: "vendorName",
+ label: "벤더명",
+ placeholder: "벤더명으로 검색...",
+ },
+ {
+ id: "rfqStatus",
+ label: "RFQ 상태",
+ options: [
+ { label: "Draft", value: "DRAFT", count: 0 },
+ { label: "문서 접수", value: "Doc. Received", count: 0 },
+ { label: "담당자 배정", value: "PIC Assigned", count: 0 },
+ { label: "문서 확정", value: "Doc. Confirmed", count: 0 },
+ { label: "초기 RFQ 발송", value: "Init. RFQ Sent", count: 0 },
+ { label: "초기 RFQ 응답", value: "Init. RFQ Answered", count: 0 },
+ { label: "TBE 시작", value: "TBE started", count: 0 },
+ { label: "TBE 완료", value: "TBE finished", count: 0 },
+ { label: "최종 RFQ 발송", value: "Final RFQ Sent", count: 0 },
+ { label: "견적 접수", value: "Quotation Received", count: 0 },
+ { label: "벤더 선정", value: "Vendor Selected", count: 0 },
+ ],
+ },
+ {
+ id: "finalRfqStatus",
+ label: "최종 RFQ 상태",
+ options: [
+ { label: "초안", value: "DRAFT", count: 0 },
+ { label: "발송", value: "Final RFQ Sent", count: 0 },
+ { label: "견적 접수", value: "Quotation Received", count: 0 },
+ { label: "벤더 선정", value: "Vendor Selected", count: 0 },
+ ],
+ },
+ {
+ id: "vendorCountry",
+ label: "벤더 국가",
+ options: [
+ { label: "한국", value: "KR", count: 0 },
+ { label: "중국", value: "CN", count: 0 },
+ { label: "일본", value: "JP", count: 0 },
+ { label: "미국", value: "US", count: 0 },
+ { label: "독일", value: "DE", count: 0 },
+ ],
+ },
+ {
+ id: "currency",
+ label: "통화",
+ options: [
+ { label: "USD", value: "USD", count: 0 },
+ { label: "EUR", value: "EUR", count: 0 },
+ { label: "KRW", value: "KRW", count: 0 },
+ { label: "JPY", value: "JPY", count: 0 },
+ { label: "CNY", value: "CNY", count: 0 },
+ ],
+ },
+ ]
+
+ /**
+ * 고급 필터 필드
+ */
+ const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [
+ {
+ id: "rfqCode",
+ label: "RFQ 코드",
+ type: "text",
+ },
+ {
+ id: "vendorName",
+ label: "벤더명",
+ type: "text",
+ },
+ {
+ id: "vendorCode",
+ label: "벤더 코드",
+ type: "text",
+ },
+ {
+ id: "vendorCountry",
+ label: "벤더 국가",
+ type: "multi-select",
+ options: [
+ { label: "한국", value: "KR" },
+ { label: "중국", value: "CN" },
+ { label: "일본", value: "JP" },
+ { label: "미국", value: "US" },
+ { label: "독일", value: "DE" },
+ ],
+ },
+ {
+ id: "rfqStatus",
+ label: "RFQ 상태",
+ type: "multi-select",
+ options: [
+ { label: "Draft", value: "DRAFT" },
+ { label: "문서 접수", value: "Doc. Received" },
+ { label: "담당자 배정", value: "PIC Assigned" },
+ { label: "문서 확정", value: "Doc. Confirmed" },
+ { label: "초기 RFQ 발송", value: "Init. RFQ Sent" },
+ { label: "초기 RFQ 응답", value: "Init. RFQ Answered" },
+ { label: "TBE 시작", value: "TBE started" },
+ { label: "TBE 완료", value: "TBE finished" },
+ { label: "최종 RFQ 발송", value: "Final RFQ Sent" },
+ { label: "견적 접수", value: "Quotation Received" },
+ { label: "벤더 선정", value: "Vendor Selected" },
+ ],
+ },
+ {
+ id: "finalRfqStatus",
+ label: "최종 RFQ 상태",
+ type: "multi-select",
+ options: [
+ { label: "초안", value: "DRAFT" },
+ { label: "발송", value: "Final RFQ Sent" },
+ { label: "견적 접수", value: "Quotation Received" },
+ { label: "벤더 선정", value: "Vendor Selected" },
+ ],
+ },
+ {
+ id: "vendorBusinessSize",
+ label: "벤더 규모",
+ type: "multi-select",
+ options: [
+ { label: "대기업", value: "LARGE" },
+ { label: "중기업", value: "MEDIUM" },
+ { label: "소기업", value: "SMALL" },
+ { label: "스타트업", value: "STARTUP" },
+ ],
+ },
+ {
+ id: "incotermsCode",
+ label: "Incoterms",
+ type: "text",
+ },
+ {
+ id: "paymentTermsCode",
+ label: "Payment Terms",
+ type: "text",
+ },
+ {
+ id: "currency",
+ label: "통화",
+ type: "multi-select",
+ options: [
+ { label: "USD", value: "USD" },
+ { label: "EUR", value: "EUR" },
+ { label: "KRW", value: "KRW" },
+ { label: "JPY", value: "JPY" },
+ { label: "CNY", value: "CNY" },
+ ],
+ },
+ {
+ id: "dueDate",
+ label: "마감일",
+ type: "date",
+ },
+ {
+ id: "validDate",
+ label: "유효일",
+ type: "date",
+ },
+ {
+ id: "deliveryDate",
+ label: "납기일",
+ type: "date",
+ },
+ {
+ id: "shortList",
+ label: "Short List",
+ type: "boolean",
+ },
+ {
+ id: "returnYn",
+ label: "Return 여부",
+ type: "boolean",
+ },
+ {
+ id: "cpRequestYn",
+ label: "CP Request 여부",
+ type: "boolean",
+ },
+ {
+ id: "prjectGtcYn",
+ label: "Project GTC 여부",
+ type: "boolean",
+ },
+ {
+ id: "firsttimeYn",
+ label: "First Time 여부",
+ type: "boolean",
+ },
+ {
+ id: "materialPriceRelatedYn",
+ label: "Material Price Related 여부",
+ type: "boolean",
+ },
+ {
+ id: "classification",
+ label: "분류",
+ type: "text",
+ },
+ {
+ id: "sparepart",
+ label: "예비부품",
+ type: "text",
+ },
+ {
+ id: "createdAt",
+ label: "등록일",
+ type: "date",
+ },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => originalRow.finalRfqId ? originalRow.finalRfqId.toString() : "1",
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <div className="space-y-6">
+ {/* 메인 테이블 */}
+ <div className="h-full w-full">
+ <DataTable table={table} className="h-full">
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <FinalRfqDetailTableToolbarActions table={table} rfqId={rfqId} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+
+ {/* Update Sheet */}
+ <UpdateFinalRfqSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ finalRfq={rowAction?.type === "update" ? rowAction.row.original : null}
+ />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/b-rfq/final/final-rfq-detail-toolbar-actions.tsx b/lib/b-rfq/final/final-rfq-detail-toolbar-actions.tsx
new file mode 100644
index 00000000..d8be4f7b
--- /dev/null
+++ b/lib/b-rfq/final/final-rfq-detail-toolbar-actions.tsx
@@ -0,0 +1,201 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { useRouter } from "next/navigation"
+import { toast } from "sonner"
+import { Button } from "@/components/ui/button"
+import {
+ Mail,
+ CheckCircle2,
+ Loader,
+ Award,
+ RefreshCw
+} from "lucide-react"
+import { FinalRfqDetailView } from "@/db/schema"
+
+interface FinalRfqDetailTableToolbarActionsProps {
+ table: Table<FinalRfqDetailView>
+ rfqId?: number
+ onRefresh?: () => void // 데이터 새로고침 콜백
+}
+
+export function FinalRfqDetailTableToolbarActions({
+ table,
+ rfqId,
+ onRefresh
+}: FinalRfqDetailTableToolbarActionsProps) {
+ const router = useRouter()
+
+ // 선택된 행들 가져오기
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const selectedDetails = selectedRows.map((row) => row.original)
+ const selectedCount = selectedRows.length
+
+ // 상태 관리
+ const [isEmailSending, setIsEmailSending] = React.useState(false)
+ const [isSelecting, setIsSelecting] = React.useState(false)
+
+ // RFQ 발송 핸들러 (로직 없음)
+ const handleBulkRfqSend = async () => {
+ if (selectedCount === 0) {
+ toast.error("발송할 RFQ를 선택해주세요.")
+ return
+ }
+
+ setIsEmailSending(true)
+
+ try {
+ // TODO: 실제 RFQ 발송 로직 구현
+ await new Promise(resolve => setTimeout(resolve, 2000)) // 임시 딜레이
+
+ toast.success(`${selectedCount}개의 최종 RFQ가 발송되었습니다.`)
+
+ // 선택 해제
+ table.toggleAllRowsSelected(false)
+
+ // 데이터 새로고침
+ if (onRefresh) {
+ onRefresh()
+ }
+
+ } catch (error) {
+ console.error("RFQ sending error:", error)
+ toast.error("최종 RFQ 발송 중 오류가 발생했습니다.")
+ } finally {
+ setIsEmailSending(false)
+ }
+ }
+
+ // 최종 선정 핸들러 (로직 없음)
+ const handleFinalSelection = async () => {
+ if (selectedCount === 0) {
+ toast.error("최종 선정할 벤더를 선택해주세요.")
+ return
+ }
+
+ if (selectedCount > 1) {
+ toast.error("최종 선정은 1개의 벤더만 가능합니다.")
+ return
+ }
+
+ setIsSelecting(true)
+
+ try {
+ // TODO: 실제 최종 선정 로직 구현
+ await new Promise(resolve => setTimeout(resolve, 1500)) // 임시 딜레이
+
+ const selectedVendor = selectedDetails[0]
+ toast.success(`${selectedVendor.vendorName}이(가) 최종 선정되었습니다.`)
+
+ // 선택 해제
+ table.toggleAllRowsSelected(false)
+
+ // 데이터 새로고침
+ if (onRefresh) {
+ onRefresh()
+ }
+
+ // 계약서 페이지로 이동 (필요시)
+ if (rfqId) {
+ setTimeout(() => {
+ toast.info("계약서 작성 페이지로 이동합니다.")
+ // router.push(`/evcp/contracts/${rfqId}`)
+ }, 1500)
+ }
+
+ } catch (error) {
+ console.error("Final selection error:", error)
+ toast.error("최종 선정 중 오류가 발생했습니다.")
+ } finally {
+ setIsSelecting(false)
+ }
+ }
+
+ // 발송 가능한 RFQ 필터링 (DRAFT 상태)
+ const sendableRfqs = selectedDetails.filter(
+ detail => detail.finalRfqStatus === "DRAFT"
+ )
+ const sendableCount = sendableRfqs.length
+
+ // 선정 가능한 벤더 필터링 (견적 접수 상태)
+ const selectableVendors = selectedDetails.filter(
+ detail => detail.finalRfqStatus === "Quotation Received"
+ )
+ const selectableCount = selectableVendors.length
+
+ // 전체 벤더 중 견적 접수 완료된 벤더 수
+ const allVendors = table.getRowModel().rows.map(row => row.original)
+ const quotationReceivedCount = allVendors.filter(
+ vendor => vendor.finalRfqStatus === "Quotation Received"
+ ).length
+
+ return (
+ <div className="flex items-center gap-2">
+ {/** 선택된 항목이 있을 때만 표시되는 액션들 */}
+ {selectedCount > 0 && (
+ <>
+ {/* RFQ 발송 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleBulkRfqSend}
+ className="h-8"
+ disabled={isEmailSending || sendableCount === 0}
+ title={sendableCount === 0 ? "발송 가능한 RFQ가 없습니다 (DRAFT 상태만 가능)" : `${sendableCount}개의 최종 RFQ 발송`}
+ >
+ {isEmailSending ? (
+ <Loader className="mr-2 h-4 w-4 animate-spin" />
+ ) : (
+ <Mail className="mr-2 h-4 w-4" />
+ )}
+ 최종 RFQ 발송 ({sendableCount}/{selectedCount})
+ </Button>
+
+ {/* 최종 선정 버튼 */}
+ <Button
+ variant="default"
+ size="sm"
+ onClick={handleFinalSelection}
+ className="h-8"
+ disabled={isSelecting || selectedCount !== 1 || selectableCount === 0}
+ title={
+ selectedCount !== 1
+ ? "최종 선정은 1개의 벤더만 선택해주세요"
+ : selectableCount === 0
+ ? "견적 접수가 완료된 벤더만 선정 가능합니다"
+ : "선택된 벤더를 최종 선정"
+ }
+ >
+ {isSelecting ? (
+ <Loader className="mr-2 h-4 w-4 animate-spin" />
+ ) : (
+ <Award className="mr-2 h-4 w-4" />
+ )}
+ 최종 선정
+ </Button>
+ </>
+ )}
+
+ {/* 정보 표시 (선택이 없을 때) */}
+ {selectedCount === 0 && quotationReceivedCount > 0 && (
+ <div className="text-sm text-muted-foreground">
+ 견적 접수 완료: {quotationReceivedCount}개 벤더
+ </div>
+ )}
+
+ {/* 새로고침 버튼 */}
+ {onRefresh && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={onRefresh}
+ className="h-8"
+ title="데이터 새로고침"
+ >
+ <RefreshCw className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/b-rfq/final/update-final-rfq-sheet.tsx b/lib/b-rfq/final/update-final-rfq-sheet.tsx
new file mode 100644
index 00000000..65e23a92
--- /dev/null
+++ b/lib/b-rfq/final/update-final-rfq-sheet.tsx
@@ -0,0 +1,70 @@
+"use client"
+
+import * as React from "react"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import { FinalRfqDetailView } from "@/db/schema"
+
+interface UpdateFinalRfqSheetProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ finalRfq: FinalRfqDetailView | null
+}
+
+export function UpdateFinalRfqSheet({
+ open,
+ onOpenChange,
+ finalRfq
+}: UpdateFinalRfqSheetProps) {
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="sm:max-w-md">
+ <SheetHeader>
+ <SheetTitle>최종 RFQ 수정</SheetTitle>
+ <SheetDescription>
+ 최종 RFQ 정보를 수정합니다.
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="py-6">
+ {finalRfq && (
+ <div className="space-y-4">
+ <div>
+ <h4 className="font-medium">RFQ 정보</h4>
+ <p className="text-sm text-muted-foreground">
+ RFQ Code: {finalRfq.rfqCode}
+ </p>
+ <p className="text-sm text-muted-foreground">
+ 벤더: {finalRfq.vendorName}
+ </p>
+ <p className="text-sm text-muted-foreground">
+ 상태: {finalRfq.finalRfqStatus}
+ </p>
+ </div>
+
+ {/* TODO: 실제 업데이트 폼 구현 */}
+ <div className="text-center text-muted-foreground">
+ 업데이트 폼이 여기에 구현됩니다.
+ </div>
+ </div>
+ )}
+ </div>
+
+ <div className="flex justify-end gap-2">
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 취소
+ </Button>
+ <Button onClick={() => onOpenChange(false)}>
+ 저장
+ </Button>
+ </div>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/b-rfq/initial/initial-rfq-detail-columns.tsx b/lib/b-rfq/initial/initial-rfq-detail-columns.tsx
index 02dfd765..f2be425c 100644
--- a/lib/b-rfq/initial/initial-rfq-detail-columns.tsx
+++ b/lib/b-rfq/initial/initial-rfq-detail-columns.tsx
@@ -69,7 +69,7 @@ export function getInitialRfqDetailColumns({
{
accessorKey: "initialRfqStatus",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="초기 RFQ 상태" />
+ <DataTableColumnHeaderSimple column={column} title="RFQ 상태" />
),
cell: ({ row }) => {
const status = row.getValue("initialRfqStatus") as string
@@ -93,7 +93,7 @@ export function getInitialRfqDetailColumns({
{
accessorKey: "rfqCode",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 코드" />
+ <DataTableColumnHeaderSimple column={column} title="RFQ No." />
),
cell: ({ row }) => (
<div className="text-sm">
diff --git a/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx b/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx
index 639d338d..c26bda28 100644
--- a/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx
+++ b/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx
@@ -17,6 +17,7 @@ import {
} from "lucide-react"
import { AddInitialRfqDialog } from "./add-initial-rfq-dialog"
import { DeleteInitialRfqDialog } from "./delete-initial-rfq-dialog"
+import { ShortListConfirmDialog } from "./short-list-confirm-dialog"
import { InitialRfqDetailView } from "@/db/schema"
import { sendBulkInitialRfqEmails } from "../service"
@@ -40,15 +41,26 @@ export function InitialRfqDetailTableToolbarActions({
// 상태 관리
const [showDeleteDialog, setShowDeleteDialog] = React.useState(false)
+ const [showShortListDialog, setShowShortListDialog] = React.useState(false)
const [isEmailSending, setIsEmailSending] = React.useState(false)
- const handleBulkEmail = async () => {
+ // 전체 벤더 리스트 가져오기 (ShortList 확정용)
+ const allVendors = table.getRowModel().rows.map(row => row.original)
+
+const handleBulkEmail = async () => {
if (selectedCount === 0) return
setIsEmailSending(true)
try {
- const initialRfqIds = selectedDetails.map(detail => detail.initialRfqId);
+ const initialRfqIds = selectedDetails
+ .map(detail => detail.initialRfqId)
+ .filter((id): id is number => id !== null);
+
+ if (initialRfqIds.length === 0) {
+ toast.error("유효한 RFQ ID가 없습니다.")
+ return
+ }
const result = await sendBulkInitialRfqEmails({
initialRfqIds,
@@ -113,9 +125,23 @@ export function InitialRfqDetailTableToolbarActions({
// S/L 확정 버튼 클릭
const handleSlConfirm = () => {
- if (rfqId) {
- router.push(`/evcp/b-rfq/${rfqId}`)
+ if (!rfqId || allVendors.length === 0) {
+ toast.error("S/L 확정할 벤더가 없습니다.")
+ return
}
+
+ // 진행 가능한 상태 확인
+ const validVendors = allVendors.filter(vendor =>
+ vendor.initialRfqStatus === "Init. RFQ Answered" ||
+ vendor.initialRfqStatus === "Init. RFQ Sent"
+ )
+
+ if (validVendors.length === 0) {
+ toast.error("S/L 확정이 가능한 벤더가 없습니다. (RFQ 발송 또는 응답 완료된 벤더만 가능)")
+ return
+ }
+
+ setShowShortListDialog(true)
}
// 초기 RFQ 추가 성공 시 처리
@@ -146,12 +172,37 @@ export function InitialRfqDetailTableToolbarActions({
}
}
+ // Short List 확정 성공 시 처리
+ const handleShortListSuccess = () => {
+ // 선택 해제
+ table.toggleAllRowsSelected(false)
+ setShowShortListDialog(false)
+
+ // 데이터 새로고침
+ if (onRefresh) {
+ onRefresh()
+ }
+
+ // 최종 RFQ 페이지로 이동
+ if (rfqId) {
+ toast.success("Short List가 확정되었습니다. 최종 RFQ 페이지로 이동합니다.")
+ setTimeout(() => {
+ router.push(`/evcp/b-rfq/${rfqId}`)
+ }, 1500)
+ }
+ }
+
// 선택된 항목 중 첫 번째를 기본값으로 사용
const defaultValues = selectedCount > 0 ? selectedDetails[0] : undefined
const canDelete = selectedDetails.every(detail => detail.initialRfqStatus === "DRAFT")
const draftCount = selectedDetails.filter(detail => detail.initialRfqStatus === "DRAFT").length
+ // S/L 확정 가능한 벤더 수
+ const validForShortList = allVendors.filter(vendor =>
+ vendor.initialRfqStatus === "Init. RFQ Answered" ||
+ vendor.initialRfqStatus === "Init. RFQ Sent"
+ ).length
return (
<>
@@ -191,9 +242,11 @@ export function InitialRfqDetailTableToolbarActions({
size="sm"
onClick={handleSlConfirm}
className="h-8"
+ disabled={validForShortList === 0}
+ title={validForShortList === 0 ? "S/L 확정이 가능한 벤더가 없습니다" : `${validForShortList}개 벤더 중 Short List 선택`}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
- S/L 확정
+ S/L 확정 ({validForShortList})
</Button>
)}
@@ -215,6 +268,20 @@ export function InitialRfqDetailTableToolbarActions({
showTrigger={false}
onSuccess={handleDeleteSuccess}
/>
+
+ {/* Short List 확정 다이얼로그 */}
+ {rfqId && (
+ <ShortListConfirmDialog
+ open={showShortListDialog}
+ onOpenChange={setShowShortListDialog}
+ rfqId={rfqId}
+ vendors={allVendors.filter(vendor =>
+ vendor.initialRfqStatus === "Init. RFQ Answered" ||
+ vendor.initialRfqStatus === "Init. RFQ Sent"
+ )}
+ onSuccess={handleShortListSuccess}
+ />
+ )}
</>
)
} \ No newline at end of file
diff --git a/lib/b-rfq/initial/short-list-confirm-dialog.tsx b/lib/b-rfq/initial/short-list-confirm-dialog.tsx
new file mode 100644
index 00000000..92c62dc0
--- /dev/null
+++ b/lib/b-rfq/initial/short-list-confirm-dialog.tsx
@@ -0,0 +1,269 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import { z } from "zod"
+import { Loader2, Building, CheckCircle2, XCircle } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Badge } from "@/components/ui/badge"
+import { Separator } from "@/components/ui/separator"
+import { ScrollArea } from "@/components/ui/scroll-area"
+
+import { shortListConfirm } from "../service"
+import { InitialRfqDetailView } from "@/db/schema"
+
+const shortListSchema = z.object({
+ selectedVendorIds: z.array(z.number()).min(1, "최소 1개 이상의 벤더를 선택해야 합니다."),
+})
+
+type ShortListFormData = z.infer<typeof shortListSchema>
+
+interface ShortListConfirmDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ rfqId: number
+ vendors: InitialRfqDetailView[]
+ onSuccess?: () => void
+}
+
+export function ShortListConfirmDialog({
+ open,
+ onOpenChange,
+ rfqId,
+ vendors,
+ onSuccess
+}: ShortListConfirmDialogProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ const form = useForm<ShortListFormData>({
+ resolver: zodResolver(shortListSchema),
+ defaultValues: {
+ selectedVendorIds: vendors
+ .filter(vendor => vendor.shortList === true)
+ .map(vendor => vendor.vendorId)
+ .filter(Boolean) as number[]
+ },
+ })
+
+ const watchedSelectedIds = form.watch("selectedVendorIds")
+
+ // 선택된/탈락된 벤더 계산
+ const selectedVendors = vendors.filter(vendor =>
+ vendor.vendorId && watchedSelectedIds.includes(vendor.vendorId)
+ )
+ const rejectedVendors = vendors.filter(vendor =>
+ vendor.vendorId && !watchedSelectedIds.includes(vendor.vendorId)
+ )
+
+ async function onSubmit(data: ShortListFormData) {
+ if (!rfqId) return
+
+ setIsLoading(true)
+
+ try {
+ const result = await shortListConfirm({
+ rfqId,
+ selectedVendorIds: data.selectedVendorIds,
+ rejectedVendorIds: vendors
+ .filter(v => v.vendorId && !data.selectedVendorIds.includes(v.vendorId))
+ .map(v => v.vendorId!)
+ })
+
+ if (result.success) {
+ toast.success(result.message)
+ onOpenChange(false)
+ form.reset()
+ onSuccess?.()
+ } else {
+ toast.error(result.message || "Short List 확정에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("Short List confirm error:", error)
+ toast.error("Short List 확정 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleVendorToggle = (vendorId: number, checked: boolean) => {
+ const currentSelected = form.getValues("selectedVendorIds")
+
+ if (checked) {
+ form.setValue("selectedVendorIds", [...currentSelected, vendorId])
+ } else {
+ form.setValue("selectedVendorIds", currentSelected.filter(id => id !== vendorId))
+ }
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <CheckCircle2 className="h-5 w-5 text-green-600" />
+ Short List 확정
+ </DialogTitle>
+ <DialogDescription>
+ 최종 RFQ로 진행할 벤더를 선택해주세요. 선택되지 않은 벤더에게는 자동으로 Letter of Regret이 발송됩니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+ <FormField
+ control={form.control}
+ name="selectedVendorIds"
+ render={() => (
+ <FormItem>
+ <FormLabel className="text-base font-semibold">
+ 벤더 선택 ({vendors.length}개 업체)
+ </FormLabel>
+ <FormControl>
+ <ScrollArea className="h-[400px] border rounded-md p-4">
+ <div className="space-y-4">
+ {vendors.map((vendor) => {
+ const isSelected = vendor.vendorId && watchedSelectedIds.includes(vendor.vendorId)
+
+ return (
+ <div
+ key={vendor.vendorId}
+ className={`flex items-start space-x-3 p-3 rounded-lg border transition-colors ${
+ isSelected
+ ? 'border-green-200 bg-green-50'
+ : 'border-red-100 bg-red-50'
+ }`}
+ >
+ <Checkbox
+ checked={isSelected}
+ onCheckedChange={(checked) =>
+ vendor.vendorId && handleVendorToggle(vendor.vendorId, !!checked)
+ }
+ className="mt-1"
+ />
+ <div className="flex-1 space-y-2">
+ <div className="flex items-center gap-2">
+ <Building className="h-4 w-4 text-muted-foreground" />
+ <span className="font-medium">{vendor.vendorName}</span>
+ {isSelected ? (
+ <Badge variant="secondary" className="bg-green-100 text-green-800">
+ 선택됨
+ </Badge>
+ ) : (
+ <Badge variant="secondary" className="bg-red-100 text-red-800">
+ 탈락
+ </Badge>
+ )}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ <span className="font-mono">{vendor.vendorCode}</span>
+ {vendor.vendorCountry && (
+ <>
+ <span className="mx-2">•</span>
+ <span>{vendor.vendorCountry === "KR" ? "국내" : "해외"}</span>
+ </>
+ )}
+ {vendor.vendorCategory && (
+ <>
+ <span className="mx-2">•</span>
+ <span>{vendor.vendorCategory}</span>
+ </>
+ )}
+ {vendor.vendorBusinessSize && (
+ <>
+ <span className="mx-2">•</span>
+ <span>{vendor.vendorBusinessSize}</span>
+ </>
+ )}
+ </div>
+ <div className="text-xs text-muted-foreground">
+ RFQ 상태: <Badge variant="outline" className="text-xs">
+ {vendor.initialRfqStatus}
+ </Badge>
+ </div>
+ </div>
+ </div>
+ )
+ })}
+ </div>
+ </ScrollArea>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 요약 정보 */}
+ <div className="grid grid-cols-2 gap-4 p-4 bg-muted/50 rounded-lg">
+ <div className="space-y-2">
+ <div className="flex items-center gap-2 text-green-700">
+ <CheckCircle2 className="h-4 w-4" />
+ <span className="font-medium">선택된 벤더</span>
+ </div>
+ <div className="text-2xl font-bold text-green-700">
+ {selectedVendors.length}개 업체
+ </div>
+ {selectedVendors.length > 0 && (
+ <div className="text-sm text-muted-foreground">
+ {selectedVendors.map(v => v.vendorName).join(", ")}
+ </div>
+ )}
+ </div>
+ <div className="space-y-2">
+ <div className="flex items-center gap-2 text-red-700">
+ <XCircle className="h-4 w-4" />
+ <span className="font-medium">탈락 벤더</span>
+ </div>
+ <div className="text-2xl font-bold text-red-700">
+ {rejectedVendors.length}개 업체
+ </div>
+ {rejectedVendors.length > 0 && (
+ <div className="text-sm text-muted-foreground">
+ Letter of Regret 발송 예정
+ </div>
+ )}
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ disabled={isLoading || selectedVendors.length === 0}
+ >
+ {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ Short List 확정
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/b-rfq/service.ts b/lib/b-rfq/service.ts
index 5a65872b..4def634b 100644
--- a/lib/b-rfq/service.ts
+++ b/lib/b-rfq/service.ts
@@ -1,17 +1,21 @@
'use server'
-import { revalidateTag, unstable_cache ,unstable_noStore} from "next/cache"
-import {count, desc, asc, and, or, gte, lte, ilike, eq, inArray, sql } from "drizzle-orm"
+import { revalidatePath, revalidateTag, unstable_cache, unstable_noStore } from "next/cache"
+import { count, desc, asc, and, or, gte, lte, ilike, eq, inArray, sql } from "drizzle-orm"
import { filterColumns } from "@/lib/filter-columns"
import db from "@/db/db"
-import { vendorResponseDetailView,
+import {
+ vendorResponseDetailView,
attachmentRevisionHistoryView,
rfqProgressSummaryView,
- vendorResponseAttachmentsEnhanced ,Incoterm, RfqDashboardView, Vendor, VendorAttachmentResponse, bRfqAttachmentRevisions, bRfqs, bRfqsAttachments, incoterms, initialRfq, initialRfqDetailView, projects, users, vendorAttachmentResponses, vendors,
- vendorResponseAttachmentsB} from "@/db/schema" // 실제 스키마 import 경로에 맞게 수정
+ vendorResponseAttachmentsEnhanced, Incoterm, RfqDashboardView, Vendor, VendorAttachmentResponse, bRfqAttachmentRevisions, bRfqs, bRfqsAttachments, incoterms, initialRfq, initialRfqDetailView, projects, users, vendorAttachmentResponses, vendors,
+ vendorResponseAttachmentsB,
+ finalRfq,
+ finalRfqDetailView
+} from "@/db/schema" // 실제 스키마 import 경로에 맞게 수정
import { rfqDashboardView } from "@/db/schema" // 뷰 import
import type { SQL } from "drizzle-orm"
-import { AttachmentRecord, BulkEmailInput, CreateRfqInput, DeleteAttachmentsInput, GetInitialRfqDetailSchema, GetRFQDashboardSchema, GetRfqAttachmentsSchema, GetVendorResponsesSchema, RemoveInitialRfqsSchema, RequestRevisionResult, ResponseStatus, UpdateInitialRfqSchema, VendorRfqResponseSummary, attachmentRecordSchema, bulkEmailSchema, createRfqServerSchema, deleteAttachmentsSchema, removeInitialRfqsSchema, requestRevisionSchema, updateInitialRfqSchema } from "./validations"
+import { AttachmentRecord, BulkEmailInput, CreateRfqInput, DeleteAttachmentsInput, GetInitialRfqDetailSchema, GetRFQDashboardSchema, GetRfqAttachmentsSchema, GetVendorResponsesSchema, RemoveInitialRfqsSchema, RequestRevisionResult, ResponseStatus, ShortListConfirmInput, UpdateInitialRfqSchema, VendorRfqResponseSummary, attachmentRecordSchema, bulkEmailSchema, createRfqServerSchema, deleteAttachmentsSchema, removeInitialRfqsSchema, requestRevisionSchema, updateInitialRfqSchema, shortListConfirmSchema, GetFinalRfqDetailSchema } from "./validations"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/app/api/auth/[...nextauth]/route"
import { unlink } from "fs/promises"
@@ -21,7 +25,7 @@ import { sendEmail } from "../mail/sendEmail"
import { RfqType } from "../rfqs/validations"
const tag = {
- initialRfqDetail:"initial-rfq",
+ initialRfqDetail: "initial-rfq",
rfqDashboard: 'rfq-dashboard',
rfq: (id: number) => `rfq-${id}`,
rfqAttachments: (rfqId: number) => `rfq-attachments-${rfqId}`,
@@ -34,122 +38,122 @@ const tag = {
export async function getRFQDashboard(input: GetRFQDashboardSchema) {
- try {
- const offset = (input.page - 1) * input.perPage;
-
- const rfqFilterMapping = createRFQFilterMapping();
- const joinedTables = getRFQJoinedTables();
-
- console.log(input, "견적 인풋")
-
- // 1) 고급 필터 조건
- let advancedWhere: SQL<unknown> | undefined = undefined;
- if (input.filters && input.filters.length > 0) {
- advancedWhere = filterColumns({
- table: rfqDashboardView,
- filters: input.filters,
- joinOperator: input.joinOperator || 'and',
- joinedTables,
- customColumnMapping: rfqFilterMapping,
- });
- }
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ const rfqFilterMapping = createRFQFilterMapping();
+ const joinedTables = getRFQJoinedTables();
+
+ console.log(input, "견적 인풋")
+
+ // 1) 고급 필터 조건
+ let advancedWhere: SQL<unknown> | undefined = undefined;
+ if (input.filters && input.filters.length > 0) {
+ advancedWhere = filterColumns({
+ table: rfqDashboardView,
+ filters: input.filters,
+ joinOperator: input.joinOperator || 'and',
+ joinedTables,
+ customColumnMapping: rfqFilterMapping,
+ });
+ }
- // 2) 기본 필터 조건
- let basicWhere: SQL<unknown> | undefined = undefined;
- if (input.basicFilters && input.basicFilters.length > 0) {
- basicWhere = filterColumns({
- table: rfqDashboardView,
- filters: input.basicFilters,
- joinOperator: input.basicJoinOperator || 'and',
- joinedTables,
- customColumnMapping: rfqFilterMapping,
- });
- }
+ // 2) 기본 필터 조건
+ let basicWhere: SQL<unknown> | undefined = undefined;
+ if (input.basicFilters && input.basicFilters.length > 0) {
+ basicWhere = filterColumns({
+ table: rfqDashboardView,
+ filters: input.basicFilters,
+ joinOperator: input.basicJoinOperator || 'and',
+ joinedTables,
+ customColumnMapping: rfqFilterMapping,
+ });
+ }
- // 3) 글로벌 검색 조건
- let globalWhere: SQL<unknown> | undefined = undefined;
- if (input.search) {
- const s = `%${input.search}%`;
+ // 3) 글로벌 검색 조건
+ let globalWhere: SQL<unknown> | undefined = undefined;
+ if (input.search) {
+ const s = `%${input.search}%`;
- const validSearchConditions: SQL<unknown>[] = [];
+ const validSearchConditions: SQL<unknown>[] = [];
- const rfqCodeCondition = ilike(rfqDashboardView.rfqCode, s);
- if (rfqCodeCondition) validSearchConditions.push(rfqCodeCondition);
+ const rfqCodeCondition = ilike(rfqDashboardView.rfqCode, s);
+ if (rfqCodeCondition) validSearchConditions.push(rfqCodeCondition);
- const descriptionCondition = ilike(rfqDashboardView.description, s);
- if (descriptionCondition) validSearchConditions.push(descriptionCondition);
+ const descriptionCondition = ilike(rfqDashboardView.description, s);
+ if (descriptionCondition) validSearchConditions.push(descriptionCondition);
- const projectNameCondition = ilike(rfqDashboardView.projectName, s);
- if (projectNameCondition) validSearchConditions.push(projectNameCondition);
+ const projectNameCondition = ilike(rfqDashboardView.projectName, s);
+ if (projectNameCondition) validSearchConditions.push(projectNameCondition);
- const projectCodeCondition = ilike(rfqDashboardView.projectCode, s);
- if (projectCodeCondition) validSearchConditions.push(projectCodeCondition);
+ const projectCodeCondition = ilike(rfqDashboardView.projectCode, s);
+ if (projectCodeCondition) validSearchConditions.push(projectCodeCondition);
- const picNameCondition = ilike(rfqDashboardView.picName, s);
- if (picNameCondition) validSearchConditions.push(picNameCondition);
+ const picNameCondition = ilike(rfqDashboardView.picName, s);
+ if (picNameCondition) validSearchConditions.push(picNameCondition);
- const packageNoCondition = ilike(rfqDashboardView.packageNo, s);
- if (packageNoCondition) validSearchConditions.push(packageNoCondition);
+ const packageNoCondition = ilike(rfqDashboardView.packageNo, s);
+ if (packageNoCondition) validSearchConditions.push(packageNoCondition);
- const packageNameCondition = ilike(rfqDashboardView.packageName, s);
- if (packageNameCondition) validSearchConditions.push(packageNameCondition);
+ const packageNameCondition = ilike(rfqDashboardView.packageName, s);
+ if (packageNameCondition) validSearchConditions.push(packageNameCondition);
- if (validSearchConditions.length > 0) {
- globalWhere = or(...validSearchConditions);
- }
- }
+ if (validSearchConditions.length > 0) {
+ globalWhere = or(...validSearchConditions);
+ }
+ }
- // 6) 최종 WHERE 조건 생성
- const whereConditions: SQL<unknown>[] = [];
+ // 6) 최종 WHERE 조건 생성
+ const whereConditions: SQL<unknown>[] = [];
- if (advancedWhere) whereConditions.push(advancedWhere);
- if (basicWhere) whereConditions.push(basicWhere);
- if (globalWhere) whereConditions.push(globalWhere);
+ if (advancedWhere) whereConditions.push(advancedWhere);
+ if (basicWhere) whereConditions.push(basicWhere);
+ if (globalWhere) whereConditions.push(globalWhere);
- const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
+ const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
- // 7) 전체 데이터 수 조회
- const totalResult = await db
- .select({ count: count() })
- .from(rfqDashboardView)
- .where(finalWhere);
+ // 7) 전체 데이터 수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(rfqDashboardView)
+ .where(finalWhere);
- const total = totalResult[0]?.count || 0;
+ const total = totalResult[0]?.count || 0;
- if (total === 0) {
- return { data: [], pageCount: 0, total: 0 };
- }
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 };
+ }
- console.log(total)
+ console.log(total)
- // 8) 정렬 및 페이징 처리된 데이터 조회
- const orderByColumns = input.sort.map((sort) => {
- const column = sort.id as keyof typeof rfqDashboardView.$inferSelect;
- return sort.desc ? desc(rfqDashboardView[column]) : asc(rfqDashboardView[column]);
- });
+ // 8) 정렬 및 페이징 처리된 데이터 조회
+ const orderByColumns = input.sort.map((sort) => {
+ const column = sort.id as keyof typeof rfqDashboardView.$inferSelect;
+ return sort.desc ? desc(rfqDashboardView[column]) : asc(rfqDashboardView[column]);
+ });
- if (orderByColumns.length === 0) {
- orderByColumns.push(desc(rfqDashboardView.createdAt));
- }
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(rfqDashboardView.createdAt));
+ }
+
+ const rfqData = await db
+ .select()
+ .from(rfqDashboardView)
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
+ .limit(input.perPage)
+ .offset(offset);
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data: rfqData, pageCount, total };
+ } catch (err) {
+ console.error("Error in getRFQDashboard:", err);
+ return { data: [], pageCount: 0, total: 0 };
+ }
- const rfqData = await db
- .select()
- .from(rfqDashboardView)
- .where(finalWhere)
- .orderBy(...orderByColumns)
- .limit(input.perPage)
- .offset(offset);
-
- const pageCount = Math.ceil(total / input.perPage);
-
- return { data: rfqData, pageCount, total };
- } catch (err) {
- console.error("Error in getRFQDashboard:", err);
- return { data: [], pageCount: 0, total: 0 };
- }
-
}
// 헬퍼 함수들
@@ -311,142 +315,142 @@ export async function getRfqAttachments(
input: GetRfqAttachmentsSchema,
rfqId: number
) {
- try {
- const offset = (input.page - 1) * input.perPage
+ try {
+ const offset = (input.page - 1) * input.perPage
- // Advanced Filter 처리 (메인 테이블 기준)
- const advancedWhere = filterColumns({
- table: bRfqsAttachments,
- filters: input.filters,
- joinOperator: input.joinOperator,
- })
+ // Advanced Filter 처리 (메인 테이블 기준)
+ const advancedWhere = filterColumns({
+ table: bRfqsAttachments,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ })
- // 전역 검색 (첨부파일 + 리비전 파일명 검색)
- let globalWhere
- if (input.search) {
- const s = `%${input.search}%`
- globalWhere = or(
- ilike(bRfqsAttachments.serialNo, s),
- ilike(bRfqsAttachments.description, s),
- ilike(bRfqsAttachments.currentRevision, s),
- ilike(bRfqAttachmentRevisions.fileName, s),
- ilike(bRfqAttachmentRevisions.originalFileName, s)
- )
- }
+ // 전역 검색 (첨부파일 + 리비전 파일명 검색)
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(
+ ilike(bRfqsAttachments.serialNo, s),
+ ilike(bRfqsAttachments.description, s),
+ ilike(bRfqsAttachments.currentRevision, s),
+ ilike(bRfqAttachmentRevisions.fileName, s),
+ ilike(bRfqAttachmentRevisions.originalFileName, s)
+ )
+ }
- // 기본 필터
- let basicWhere
- if (input.attachmentType.length > 0 || input.fileType.length > 0) {
- basicWhere = and(
- input.attachmentType.length > 0
- ? inArray(bRfqsAttachments.attachmentType, input.attachmentType)
- : undefined,
- input.fileType.length > 0
- ? inArray(bRfqAttachmentRevisions.fileType, input.fileType)
- : undefined
- )
- }
+ // 기본 필터
+ let basicWhere
+ if (input.attachmentType.length > 0 || input.fileType.length > 0) {
+ basicWhere = and(
+ input.attachmentType.length > 0
+ ? inArray(bRfqsAttachments.attachmentType, input.attachmentType)
+ : undefined,
+ input.fileType.length > 0
+ ? inArray(bRfqAttachmentRevisions.fileType, input.fileType)
+ : undefined
+ )
+ }
- // 최종 WHERE 절
- const finalWhere = and(
- eq(bRfqsAttachments.rfqId, rfqId), // RFQ ID 필수 조건
- advancedWhere,
- globalWhere,
- basicWhere
- )
+ // 최종 WHERE 절
+ const finalWhere = and(
+ eq(bRfqsAttachments.rfqId, rfqId), // RFQ ID 필수 조건
+ advancedWhere,
+ globalWhere,
+ basicWhere
+ )
+
+ // 정렬 (메인 테이블 기준)
+ const orderBy = input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(bRfqsAttachments[item.id as keyof typeof bRfqsAttachments]) : asc(bRfqsAttachments[item.id as keyof typeof bRfqsAttachments])
+ )
+ : [desc(bRfqsAttachments.createdAt)]
- // 정렬 (메인 테이블 기준)
- const orderBy = input.sort.length > 0
- ? input.sort.map((item) =>
- item.desc ? desc(bRfqsAttachments[item.id as keyof typeof bRfqsAttachments]) : asc(bRfqsAttachments[item.id as keyof typeof bRfqsAttachments])
+ // 트랜잭션으로 데이터 조회
+ const { data, total } = await db.transaction(async (tx) => {
+ // 메인 데이터 조회 (첨부파일 + 최신 리비전 조인)
+ const data = await tx
+ .select({
+ // 첨부파일 메인 정보
+ id: bRfqsAttachments.id,
+ attachmentType: bRfqsAttachments.attachmentType,
+ serialNo: bRfqsAttachments.serialNo,
+ rfqId: bRfqsAttachments.rfqId,
+ currentRevision: bRfqsAttachments.currentRevision,
+ latestRevisionId: bRfqsAttachments.latestRevisionId,
+ description: bRfqsAttachments.description,
+ createdBy: bRfqsAttachments.createdBy,
+ createdAt: bRfqsAttachments.createdAt,
+ updatedAt: bRfqsAttachments.updatedAt,
+
+ // 최신 리비전 파일 정보
+ fileName: bRfqAttachmentRevisions.fileName,
+ originalFileName: bRfqAttachmentRevisions.originalFileName,
+ filePath: bRfqAttachmentRevisions.filePath,
+ fileSize: bRfqAttachmentRevisions.fileSize,
+ fileType: bRfqAttachmentRevisions.fileType,
+ revisionComment: bRfqAttachmentRevisions.revisionComment,
+
+ // 생성자 정보
+ createdByName: users.name,
+ })
+ .from(bRfqsAttachments)
+ .leftJoin(
+ bRfqAttachmentRevisions,
+ and(
+ eq(bRfqsAttachments.latestRevisionId, bRfqAttachmentRevisions.id),
+ eq(bRfqAttachmentRevisions.isLatest, true)
)
- : [desc(bRfqsAttachments.createdAt)]
-
- // 트랜잭션으로 데이터 조회
- const { data, total } = await db.transaction(async (tx) => {
- // 메인 데이터 조회 (첨부파일 + 최신 리비전 조인)
- const data = await tx
- .select({
- // 첨부파일 메인 정보
- id: bRfqsAttachments.id,
- attachmentType: bRfqsAttachments.attachmentType,
- serialNo: bRfqsAttachments.serialNo,
- rfqId: bRfqsAttachments.rfqId,
- currentRevision: bRfqsAttachments.currentRevision,
- latestRevisionId: bRfqsAttachments.latestRevisionId,
- description: bRfqsAttachments.description,
- createdBy: bRfqsAttachments.createdBy,
- createdAt: bRfqsAttachments.createdAt,
- updatedAt: bRfqsAttachments.updatedAt,
-
- // 최신 리비전 파일 정보
- fileName: bRfqAttachmentRevisions.fileName,
- originalFileName: bRfqAttachmentRevisions.originalFileName,
- filePath: bRfqAttachmentRevisions.filePath,
- fileSize: bRfqAttachmentRevisions.fileSize,
- fileType: bRfqAttachmentRevisions.fileType,
- revisionComment: bRfqAttachmentRevisions.revisionComment,
-
- // 생성자 정보
- createdByName: users.name,
- })
- .from(bRfqsAttachments)
- .leftJoin(
- bRfqAttachmentRevisions,
- and(
- eq(bRfqsAttachments.latestRevisionId, bRfqAttachmentRevisions.id),
- eq(bRfqAttachmentRevisions.isLatest, true)
- )
- )
- .leftJoin(users, eq(bRfqsAttachments.createdBy, users.id))
- .where(finalWhere)
- .orderBy(...orderBy)
- .limit(input.perPage)
- .offset(offset)
-
- // 전체 개수 조회
- const totalResult = await tx
- .select({ count: count() })
- .from(bRfqsAttachments)
- .leftJoin(
- bRfqAttachmentRevisions,
- eq(bRfqsAttachments.latestRevisionId, bRfqAttachmentRevisions.id)
- )
- .where(finalWhere)
-
- const total = totalResult[0]?.count ?? 0
+ )
+ .leftJoin(users, eq(bRfqsAttachments.createdBy, users.id))
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .limit(input.perPage)
+ .offset(offset)
+
+ // 전체 개수 조회
+ const totalResult = await tx
+ .select({ count: count() })
+ .from(bRfqsAttachments)
+ .leftJoin(
+ bRfqAttachmentRevisions,
+ eq(bRfqsAttachments.latestRevisionId, bRfqAttachmentRevisions.id)
+ )
+ .where(finalWhere)
- return { data, total }
- })
+ const total = totalResult[0]?.count ?? 0
- const pageCount = Math.ceil(total / input.perPage)
+ return { data, total }
+ })
- // 각 첨부파일별 벤더 응답 통계 조회
- const attachmentIds = data.map(item => item.id)
- let responseStatsMap: Record<number, any> = {}
+ const pageCount = Math.ceil(total / input.perPage)
- if (attachmentIds.length > 0) {
- responseStatsMap = await getAttachmentResponseStats(attachmentIds)
- }
+ // 각 첨부파일별 벤더 응답 통계 조회
+ const attachmentIds = data.map(item => item.id)
+ let responseStatsMap: Record<number, any> = {}
- // 통계 데이터 병합
- const dataWithStats = data.map(attachment => ({
- ...attachment,
- responseStats: responseStatsMap[attachment.id] || {
- totalVendors: 0,
- respondedCount: 0,
- pendingCount: 0,
- waivedCount: 0,
- responseRate: 0
- }
- }))
+ if (attachmentIds.length > 0) {
+ responseStatsMap = await getAttachmentResponseStats(attachmentIds)
+ }
- return { data: dataWithStats, pageCount }
- } catch (err) {
- console.error("getRfqAttachments error:", err)
- return { data: [], pageCount: 0 }
+ // 통계 데이터 병합
+ const dataWithStats = data.map(attachment => ({
+ ...attachment,
+ responseStats: responseStatsMap[attachment.id] || {
+ totalVendors: 0,
+ respondedCount: 0,
+ pendingCount: 0,
+ waivedCount: 0,
+ responseRate: 0
}
-
+ }))
+
+ return { data: dataWithStats, pageCount }
+ } catch (err) {
+ console.error("getRfqAttachments error:", err)
+ return { data: [], pageCount: 0 }
+ }
+
}
// 첨부파일별 벤더 응답 통계 조회
@@ -529,7 +533,7 @@ export async function getVendorResponsesForAttachment(
// 2. 각 응답에 대한 파일 정보 가져오기
const responseIds = responses.map(r => r.id);
-
+
let responseFiles: any[] = [];
if (responseIds.length > 0) {
responseFiles = await db
@@ -657,14 +661,14 @@ export async function requestTbe(rfqId: number, attachmentIds?: number[]) {
await db.transaction(async (tx) => {
const [updatedRfq] = await tx
- .update(bRfqs)
- .set({
- status: "TBE started",
- updatedBy: Number(session.user.id),
- updatedAt: new Date(),
- })
- .where(eq(bRfqs.id, rfqId))
- .returning()
+ .update(bRfqs)
+ .set({
+ status: "TBE started",
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(eq(bRfqs.id, rfqId))
+ .returning()
// 각 첨부파일에 대해 벤더 응답 레코드 생성 또는 업데이트
for (const attachment of targetAttachments) {
@@ -673,7 +677,7 @@ export async function requestTbe(rfqId: number, attachmentIds?: number[]) {
}
})
-
+
const attachmentCount = targetAttachments.length
const attachmentList = targetAttachments
.map(a => `${a.serialNo} (${a.currentRevision})`)
@@ -776,7 +780,7 @@ export async function addRfqAttachmentRecord(record: AttachmentRecord) {
return { attachment, revision }
})
- return {
+ return {
success: true,
message: `파일이 성공적으로 등록되었습니다. (시리얼: ${result.attachment.serialNo}, 리비전: Rev.0)`,
attachment: result.attachment,
@@ -884,7 +888,7 @@ export async function addRevisionToAttachment(
return inserted;
});
-
+
return {
success: true,
@@ -903,39 +907,39 @@ export async function addRevisionToAttachment(
// 특정 첨부파일의 모든 리비전 조회
export async function getAttachmentRevisions(attachmentId: number) {
- try {
- const revisions = await db
- .select({
- id: bRfqAttachmentRevisions.id,
- revisionNo: bRfqAttachmentRevisions.revisionNo,
- fileName: bRfqAttachmentRevisions.fileName,
- originalFileName: bRfqAttachmentRevisions.originalFileName,
- filePath: bRfqAttachmentRevisions.filePath,
- fileSize: bRfqAttachmentRevisions.fileSize,
- fileType: bRfqAttachmentRevisions.fileType,
- revisionComment: bRfqAttachmentRevisions.revisionComment,
- isLatest: bRfqAttachmentRevisions.isLatest,
- createdBy: bRfqAttachmentRevisions.createdBy,
- createdAt: bRfqAttachmentRevisions.createdAt,
- createdByName: users.name,
- })
- .from(bRfqAttachmentRevisions)
- .leftJoin(users, eq(bRfqAttachmentRevisions.createdBy, users.id))
- .where(eq(bRfqAttachmentRevisions.attachmentId, attachmentId))
- .orderBy(desc(bRfqAttachmentRevisions.createdAt))
+ try {
+ const revisions = await db
+ .select({
+ id: bRfqAttachmentRevisions.id,
+ revisionNo: bRfqAttachmentRevisions.revisionNo,
+ fileName: bRfqAttachmentRevisions.fileName,
+ originalFileName: bRfqAttachmentRevisions.originalFileName,
+ filePath: bRfqAttachmentRevisions.filePath,
+ fileSize: bRfqAttachmentRevisions.fileSize,
+ fileType: bRfqAttachmentRevisions.fileType,
+ revisionComment: bRfqAttachmentRevisions.revisionComment,
+ isLatest: bRfqAttachmentRevisions.isLatest,
+ createdBy: bRfqAttachmentRevisions.createdBy,
+ createdAt: bRfqAttachmentRevisions.createdAt,
+ createdByName: users.name,
+ })
+ .from(bRfqAttachmentRevisions)
+ .leftJoin(users, eq(bRfqAttachmentRevisions.createdBy, users.id))
+ .where(eq(bRfqAttachmentRevisions.attachmentId, attachmentId))
+ .orderBy(desc(bRfqAttachmentRevisions.createdAt))
- return {
- success: true,
- revisions,
- }
- } catch (error) {
- console.error("getAttachmentRevisions error:", error)
- return {
- success: false,
- message: "리비전 조회 중 오류가 발생했습니다.",
- revisions: [],
- }
- }
+ return {
+ success: true,
+ revisions,
+ }
+ } catch (error) {
+ console.error("getAttachmentRevisions error:", error)
+ return {
+ success: false,
+ message: "리비전 조회 중 오류가 발생했습니다.",
+ revisions: [],
+ }
+ }
}
@@ -1003,7 +1007,7 @@ export async function deleteRfqAttachments(input: DeleteAttachmentsInput) {
}
})
-
+
return {
success: true,
message: `${result.deletedCount}개의 첨부파일이 삭제되었습니다.`,
@@ -1012,7 +1016,7 @@ export async function deleteRfqAttachments(input: DeleteAttachmentsInput) {
} catch (error) {
console.error("deleteRfqAttachments error:", error)
-
+
return {
success: false,
message: error instanceof Error ? error.message : "첨부파일 삭제 중 오류가 발생했습니다.",
@@ -1026,119 +1030,119 @@ export async function deleteRfqAttachments(input: DeleteAttachmentsInput) {
export async function getInitialRfqDetail(input: GetInitialRfqDetailSchema, rfqId?: number) {
- try {
- const offset = (input.page - 1) * input.perPage;
-
- // 1) 고급 필터 조건
- let advancedWhere: SQL<unknown> | undefined = undefined;
- if (input.filters && input.filters.length > 0) {
- advancedWhere = filterColumns({
- table: initialRfqDetailView,
- filters: input.filters,
- joinOperator: input.joinOperator || 'and',
- });
- }
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 1) 고급 필터 조건
+ let advancedWhere: SQL<unknown> | undefined = undefined;
+ if (input.filters && input.filters.length > 0) {
+ advancedWhere = filterColumns({
+ table: initialRfqDetailView,
+ filters: input.filters,
+ joinOperator: input.joinOperator || 'and',
+ });
+ }
- // 2) 기본 필터 조건
- let basicWhere: SQL<unknown> | undefined = undefined;
- if (input.basicFilters && input.basicFilters.length > 0) {
- basicWhere = filterColumns({
- table: initialRfqDetailView,
- filters: input.basicFilters,
- joinOperator: input.basicJoinOperator || 'and',
- });
- }
+ // 2) 기본 필터 조건
+ let basicWhere: SQL<unknown> | undefined = undefined;
+ if (input.basicFilters && input.basicFilters.length > 0) {
+ basicWhere = filterColumns({
+ table: initialRfqDetailView,
+ filters: input.basicFilters,
+ joinOperator: input.basicJoinOperator || 'and',
+ });
+ }
- let rfqIdWhere: SQL<unknown> | undefined = undefined;
- if (rfqId) {
- rfqIdWhere = eq(initialRfqDetailView.rfqId, rfqId);
- }
+ let rfqIdWhere: SQL<unknown> | undefined = undefined;
+ if (rfqId) {
+ rfqIdWhere = eq(initialRfqDetailView.rfqId, rfqId);
+ }
- // 3) 글로벌 검색 조건
- let globalWhere: SQL<unknown> | undefined = undefined;
- if (input.search) {
- const s = `%${input.search}%`;
+ // 3) 글로벌 검색 조건
+ let globalWhere: SQL<unknown> | undefined = undefined;
+ if (input.search) {
+ const s = `%${input.search}%`;
- const validSearchConditions: SQL<unknown>[] = [];
+ const validSearchConditions: SQL<unknown>[] = [];
- const rfqCodeCondition = ilike(initialRfqDetailView.rfqCode, s);
- if (rfqCodeCondition) validSearchConditions.push(rfqCodeCondition);
+ const rfqCodeCondition = ilike(initialRfqDetailView.rfqCode, s);
+ if (rfqCodeCondition) validSearchConditions.push(rfqCodeCondition);
- const vendorNameCondition = ilike(initialRfqDetailView.vendorName, s);
- if (vendorNameCondition) validSearchConditions.push(vendorNameCondition);
+ const vendorNameCondition = ilike(initialRfqDetailView.vendorName, s);
+ if (vendorNameCondition) validSearchConditions.push(vendorNameCondition);
- const vendorCodeCondition = ilike(initialRfqDetailView.vendorCode, s);
- if (vendorCodeCondition) validSearchConditions.push(vendorCodeCondition);
+ const vendorCodeCondition = ilike(initialRfqDetailView.vendorCode, s);
+ if (vendorCodeCondition) validSearchConditions.push(vendorCodeCondition);
- const vendorCountryCondition = ilike(initialRfqDetailView.vendorCountry, s);
- if (vendorCountryCondition) validSearchConditions.push(vendorCountryCondition);
+ const vendorCountryCondition = ilike(initialRfqDetailView.vendorCountry, s);
+ if (vendorCountryCondition) validSearchConditions.push(vendorCountryCondition);
- const incotermsDescriptionCondition = ilike(initialRfqDetailView.incotermsDescription, s);
- if (incotermsDescriptionCondition) validSearchConditions.push(incotermsDescriptionCondition);
+ const incotermsDescriptionCondition = ilike(initialRfqDetailView.incotermsDescription, s);
+ if (incotermsDescriptionCondition) validSearchConditions.push(incotermsDescriptionCondition);
- const classificationCondition = ilike(initialRfqDetailView.classification, s);
- if (classificationCondition) validSearchConditions.push(classificationCondition);
+ const classificationCondition = ilike(initialRfqDetailView.classification, s);
+ if (classificationCondition) validSearchConditions.push(classificationCondition);
- const sparepartCondition = ilike(initialRfqDetailView.sparepart, s);
- if (sparepartCondition) validSearchConditions.push(sparepartCondition);
+ const sparepartCondition = ilike(initialRfqDetailView.sparepart, s);
+ if (sparepartCondition) validSearchConditions.push(sparepartCondition);
- if (validSearchConditions.length > 0) {
- globalWhere = or(...validSearchConditions);
- }
- }
+ if (validSearchConditions.length > 0) {
+ globalWhere = or(...validSearchConditions);
+ }
+ }
- // 5) 최종 WHERE 조건 생성
- const whereConditions: SQL<unknown>[] = [];
+ // 5) 최종 WHERE 조건 생성
+ const whereConditions: SQL<unknown>[] = [];
- if (advancedWhere) whereConditions.push(advancedWhere);
- if (basicWhere) whereConditions.push(basicWhere);
- if (globalWhere) whereConditions.push(globalWhere);
- if (rfqIdWhere) whereConditions.push(rfqIdWhere);
+ if (advancedWhere) whereConditions.push(advancedWhere);
+ if (basicWhere) whereConditions.push(basicWhere);
+ if (globalWhere) whereConditions.push(globalWhere);
+ if (rfqIdWhere) whereConditions.push(rfqIdWhere);
- const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
+ const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
- // 6) 전체 데이터 수 조회
- const totalResult = await db
- .select({ count: count() })
- .from(initialRfqDetailView)
- .where(finalWhere);
+ // 6) 전체 데이터 수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(initialRfqDetailView)
+ .where(finalWhere);
- const total = totalResult[0]?.count || 0;
+ const total = totalResult[0]?.count || 0;
- if (total === 0) {
- return { data: [], pageCount: 0, total: 0 };
- }
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 };
+ }
- console.log(totalResult);
- console.log(total);
+ console.log(totalResult);
+ console.log(total);
- // 7) 정렬 및 페이징 처리된 데이터 조회
- const orderByColumns = input.sort.map((sort) => {
- const column = sort.id as keyof typeof initialRfqDetailView.$inferSelect;
- return sort.desc ? desc(initialRfqDetailView[column]) : asc(initialRfqDetailView[column]);
- });
+ // 7) 정렬 및 페이징 처리된 데이터 조회
+ const orderByColumns = input.sort.map((sort) => {
+ const column = sort.id as keyof typeof initialRfqDetailView.$inferSelect;
+ return sort.desc ? desc(initialRfqDetailView[column]) : asc(initialRfqDetailView[column]);
+ });
- if (orderByColumns.length === 0) {
- orderByColumns.push(desc(initialRfqDetailView.createdAt));
- }
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(initialRfqDetailView.createdAt));
+ }
- const initialRfqData = await db
- .select()
- .from(initialRfqDetailView)
- .where(finalWhere)
- .orderBy(...orderByColumns)
- .limit(input.perPage)
- .offset(offset);
-
- const pageCount = Math.ceil(total / input.perPage);
-
- return { data: initialRfqData, pageCount, total };
- } catch (err) {
- console.error("Error in getInitialRfqDetail:", err);
- return { data: [], pageCount: 0, total: 0 };
- }
+ const initialRfqData = await db
+ .select()
+ .from(initialRfqDetailView)
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
+ .limit(input.perPage)
+ .offset(offset);
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data: initialRfqData, pageCount, total };
+ } catch (err) {
+ console.error("Error in getInitialRfqDetail:", err);
+ return { data: [], pageCount: 0, total: 0 };
+ }
}
export async function getVendorsForSelection() {
@@ -1177,7 +1181,7 @@ export async function getVendorsForSelection() {
export async function addInitialRfqRecord(data: AddInitialRfqFormData & { rfqId: number }) {
try {
console.log('Incoming data:', data);
-
+
const [newRecord] = await db
.insert(initialRfq)
.values({
@@ -1233,7 +1237,7 @@ export async function getIncotermsForSelection() {
}
export async function removeInitialRfqs(input: RemoveInitialRfqsSchema) {
- unstable_noStore ()
+ unstable_noStore()
try {
const { ids } = removeInitialRfqsSchema.parse(input)
@@ -1259,10 +1263,10 @@ interface ModifyInitialRfqInput extends UpdateInitialRfqSchema {
}
export async function modifyInitialRfq(input: ModifyInitialRfqInput) {
- unstable_noStore ()
+ unstable_noStore()
try {
const { id, ...updateData } = input
-
+
// validation
updateInitialRfqSchema.parse(updateData)
@@ -1427,17 +1431,17 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
// 각 벤더의 모든 유효한 이메일 주소를 정리하는 함수
function getAllVendorEmails(vendor: typeof vendorsWithAllEmails[0]): string[] {
const emails: string[] = []
-
+
// 벤더 기본 이메일
if (vendor.email) {
emails.push(vendor.email)
}
-
+
// 대표자 이메일
if (vendor.representativeEmail && vendor.representativeEmail !== vendor.email) {
emails.push(vendor.representativeEmail)
}
-
+
// 연락처 이메일들
if (vendor.contactEmails && Array.isArray(vendor.contactEmails)) {
vendor.contactEmails.forEach(contactEmail => {
@@ -1446,7 +1450,7 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
}
})
}
-
+
return emails.filter(email => email && email.trim() !== '')
}
@@ -1464,7 +1468,7 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
// 해당 RFQ의 첨부파일들
const rfqAttachments = attachments.filter(att => att.rfqId === rfqDetail.rfqId)
-
+
// 벤더 정보
const vendor = vendorsWithAllEmails.find(v => v.id === rfqDetail.vendorId)
if (!vendor) {
@@ -1474,7 +1478,7 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
// 해당 벤더의 모든 이메일 주소 수집
const vendorEmails = getAllVendorEmails(vendor)
-
+
if (vendorEmails.length === 0) {
errors.push(`벤더 이메일 주소가 없습니다: ${vendor.vendorName}`)
continue
@@ -1486,7 +1490,7 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
let revisionToUse = currentRfqRevision
// 첫 번째 첨부파일을 기준으로 기존 응답 조회 (리비전 상태 확인용)
- if (rfqAttachments.length > 0 && rfqDetail.initialRfqId) {
+ if (rfqAttachments.length > 0 && rfqDetail.initialRfqId) {
const existingResponses = await db
.select()
.from(vendorAttachmentResponses)
@@ -1501,7 +1505,7 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
if (existingResponses.length > 0) {
// 기존 응답이 있음
const existingRevision = parseInt(existingResponses[0].currentRevision?.replace("Rev.", "") || "0")
-
+
if (currentRfqRevision > existingRevision) {
// RFQ 리비전이 올라감 → 리비전 업데이트
emailType = "REVISION"
@@ -1555,7 +1559,7 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
})
.where(eq(vendorAttachmentResponses.id, existingResponse[0].id))
}
-
+
}
const formatDateSafely = (date: Date | string | null | undefined): string => {
@@ -1565,11 +1569,11 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
const dateObj = new Date(date)
// 유효한 날짜인지 확인
if (isNaN(dateObj.getTime())) return ""
-
- return dateObj.toLocaleDateString('en-US', {
- year: 'numeric',
- month: '2-digit',
- day: '2-digit'
+
+ return dateObj.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit'
})
} catch (error) {
console.error("Date formatting error:", error)
@@ -1579,7 +1583,7 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
// 7. 이메일 발송
const emailData: EmailData = {
- name:vendor.vendorName,
+ name: vendor.vendorName,
rfqCode: rfqDetail.rfqCode || "",
projectName: rfqDetail.rfqCode || "", // 실제 프로젝트명이 있다면 사용
projectCompany: rfqDetail.projectCompany || "",
@@ -1589,7 +1593,7 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
incotermsCode: rfqDetail.incotermsCode || "FOB",
incotermsDescription: rfqDetail.incotermsDescription || "FOB Finland Port",
dueDate: rfqDetail.dueDate ? formatDateSafely(rfqDetail.dueDate) : "",
- validDate: rfqDetail.validDate ?formatDateSafely(rfqDetail.validDate) : "",
+ validDate: rfqDetail.validDate ? formatDateSafely(rfqDetail.validDate) : "",
sparepart: rfqDetail.sparepart || "One(1) year operational spare parts",
vendorName: vendor.vendorName,
picName: session.user.name || rfqDetail.picName || "Procurement Manager",
@@ -1603,7 +1607,7 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
// 이메일 제목 생성 (타입에 따라 다르게)
let emailSubject = ""
const revisionText = revisionToUse > 0 ? ` Rev.${revisionToUse}` : ""
-
+
switch (emailType) {
case "NEW":
emailSubject = `[SHI RFQ] ${rfqDetail.rfqCode}${revisionText} Invitation to Bidder for ${emailData.packageName} * ${vendor.vendorName} * RFQ No. ${rfqDetail.rfqCode}`
@@ -1618,7 +1622,6 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
// nodemailer로 모든 이메일 주소에 한번에 발송
await sendEmail({
- // from: session.user.email || undefined,
to: vendorEmails.join(", "), // 콤마+공백으로 구분
subject: emailSubject,
template: "initial-rfq-invitation", // hbs 템플릿 파일명
@@ -1629,7 +1632,7 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
})
// 8. 초기 RFQ 상태 업데이트 (리비전은 변경하지 않음 - 이미 DB에 저장된 값 사용)
- if(rfqDetail.initialRfqId && rfqDetail.rfqId){
+ if (rfqDetail.initialRfqId && rfqDetail.rfqId) {
// Promise.all로 두 테이블 동시 업데이트
await Promise.all([
// initialRfq 테이블 업데이트
@@ -1640,7 +1643,7 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) {
updatedAt: new Date(),
})
.where(eq(initialRfq.id, rfqDetail.initialRfqId)),
-
+
// bRfqs 테이블 status도 함께 업데이트
db
.update(bRfqs)
@@ -1730,310 +1733,310 @@ export type VendorResponseDetail = VendorAttachmentResponse & {
};
export async function getVendorRfqResponses(input: GetVendorResponsesSchema, vendorId?: string, rfqId?: string) {
- try {
- // 페이지네이션 설정
- const page = input.page || 1;
- const perPage = input.perPage || 10;
- const offset = (page - 1) * perPage;
+ try {
+ // 페이지네이션 설정
+ const page = input.page || 1;
+ const perPage = input.perPage || 10;
+ const offset = (page - 1) * perPage;
- // 기본 조건
- let whereConditions = [];
+ // 기본 조건
+ let whereConditions = [];
- // 벤더 ID 조건
- if (vendorId) {
- whereConditions.push(eq(vendorAttachmentResponses.vendorId, Number(vendorId)));
- }
+ // 벤더 ID 조건
+ if (vendorId) {
+ whereConditions.push(eq(vendorAttachmentResponses.vendorId, Number(vendorId)));
+ }
- // RFQ 타입 조건
- // if (input.rfqType !== "ALL") {
- // whereConditions.push(eq(vendorAttachmentResponses.rfqType, input.rfqType as RfqType));
- // }
+ // RFQ 타입 조건
+ // if (input.rfqType !== "ALL") {
+ // whereConditions.push(eq(vendorAttachmentResponses.rfqType, input.rfqType as RfqType));
+ // }
- // 날짜 범위 조건
- if (input.from && input.to) {
- whereConditions.push(
- and(
- gte(vendorAttachmentResponses.requestedAt, new Date(input.from)),
- lte(vendorAttachmentResponses.requestedAt, new Date(input.to))
- )
- );
- }
+ // 날짜 범위 조건
+ if (input.from && input.to) {
+ whereConditions.push(
+ and(
+ gte(vendorAttachmentResponses.requestedAt, new Date(input.from)),
+ lte(vendorAttachmentResponses.requestedAt, new Date(input.to))
+ )
+ );
+ }
- const baseWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
-
- // 그룹핑된 응답 요약 데이터 조회
- const groupedResponses = await db
- .select({
- vendorId: vendorAttachmentResponses.vendorId,
- rfqRecordId: vendorAttachmentResponses.rfqRecordId,
- rfqType: vendorAttachmentResponses.rfqType,
-
- // 통계 계산 (조건부 COUNT 수정)
- totalAttachments: count(),
- respondedCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' THEN 1 ELSE 0 END)`,
- pendingCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'NOT_RESPONDED' THEN 1 ELSE 0 END)`,
- revisionRequestedCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'REVISION_REQUESTED' THEN 1 ELSE 0 END)`,
- waivedCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'WAIVED' THEN 1 ELSE 0 END)`,
-
- // 날짜 정보
- requestedAt: sql<Date>`MIN(${vendorAttachmentResponses.requestedAt})`,
- lastRespondedAt: sql<Date | null>`MAX(${vendorAttachmentResponses.respondedAt})`,
-
- // 코멘트 여부
- hasComments: sql<boolean>`BOOL_OR(${vendorAttachmentResponses.responseComment} IS NOT NULL OR ${vendorAttachmentResponses.vendorComment} IS NOT NULL)`,
- })
- .from(vendorAttachmentResponses)
- .where(baseWhere)
- .groupBy(
- vendorAttachmentResponses.vendorId,
- vendorAttachmentResponses.rfqRecordId,
- vendorAttachmentResponses.rfqType
- )
- .orderBy(desc(sql`MIN(${vendorAttachmentResponses.requestedAt})`))
- .offset(offset)
- .limit(perPage);
+ const baseWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
- // 벤더 정보와 RFQ 정보를 별도로 조회
- const vendorIds = [...new Set(groupedResponses.map(r => r.vendorId))];
- const rfqRecordIds = [...new Set(groupedResponses.map(r => r.rfqRecordId))];
+ // 그룹핑된 응답 요약 데이터 조회
+ const groupedResponses = await db
+ .select({
+ vendorId: vendorAttachmentResponses.vendorId,
+ rfqRecordId: vendorAttachmentResponses.rfqRecordId,
+ rfqType: vendorAttachmentResponses.rfqType,
- // 벤더 정보 조회
- const vendorsData = await db.query.vendors.findMany({
- where: or(...vendorIds.map(id => eq(vendors.id, id))),
- columns: {
- id: true,
- vendorCode: true,
- vendorName: true,
- country: true,
- businessSize: true,
- }
- });
-
- // RFQ 정보 조회 (초기 RFQ와 최종 RFQ 모두)
- const [initialRfqs] = await Promise.all([
- db.query.initialRfq.findMany({
- where: or(...rfqRecordIds.map(id => eq(initialRfq.id, id))),
- with: {
- rfq: {
- columns: {
- id: true,
- rfqCode: true,
- description: true,
- status: true,
- dueDate: true,
- }
- }
- }
- })
+ // 통계 계산 (조건부 COUNT 수정)
+ totalAttachments: count(),
+ respondedCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' THEN 1 ELSE 0 END)`,
+ pendingCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'NOT_RESPONDED' THEN 1 ELSE 0 END)`,
+ revisionRequestedCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'REVISION_REQUESTED' THEN 1 ELSE 0 END)`,
+ waivedCount: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'WAIVED' THEN 1 ELSE 0 END)`,
- ]);
+ // 날짜 정보
+ requestedAt: sql<Date>`MIN(${vendorAttachmentResponses.requestedAt})`,
+ lastRespondedAt: sql<Date | null>`MAX(${vendorAttachmentResponses.respondedAt})`,
- // 데이터 조합 및 변환
- const transformedResponses: VendorRfqResponseSummary[] = groupedResponses.map(response => {
- const vendor = vendorsData.find(v => v.id === response.vendorId);
-
- let rfqInfo = null;
- if (response.rfqType === "INITIAL") {
- const initialRfq = initialRfqs.find(r => r.id === response.rfqRecordId);
- rfqInfo = initialRfq?.rfq || null;
- }
+ // 코멘트 여부
+ hasComments: sql<boolean>`BOOL_OR(${vendorAttachmentResponses.responseComment} IS NOT NULL OR ${vendorAttachmentResponses.vendorComment} IS NOT NULL)`,
+ })
+ .from(vendorAttachmentResponses)
+ .where(baseWhere)
+ .groupBy(
+ vendorAttachmentResponses.vendorId,
+ vendorAttachmentResponses.rfqRecordId,
+ vendorAttachmentResponses.rfqType
+ )
+ .orderBy(desc(sql`MIN(${vendorAttachmentResponses.requestedAt})`))
+ .offset(offset)
+ .limit(perPage);
+
+ // 벤더 정보와 RFQ 정보를 별도로 조회
+ const vendorIds = [...new Set(groupedResponses.map(r => r.vendorId))];
+ const rfqRecordIds = [...new Set(groupedResponses.map(r => r.rfqRecordId))];
+
+ // 벤더 정보 조회
+ const vendorsData = await db.query.vendors.findMany({
+ where: or(...vendorIds.map(id => eq(vendors.id, id))),
+ columns: {
+ id: true,
+ vendorCode: true,
+ vendorName: true,
+ country: true,
+ businessSize: true,
+ }
+ });
- // 응답률 계산
- const responseRate = Number(response.totalAttachments) > 0
- ? Math.round((Number(response.respondedCount) / Number(response.totalAttachments)) * 100)
- : 0;
-
- // 완료율 계산 (응답완료 + 포기)
- const completionRate = Number(response.totalAttachments) > 0
- ? Math.round(((Number(response.respondedCount) + Number(response.waivedCount)) / Number(response.totalAttachments)) * 100)
- : 0;
-
- // 전체 상태 결정
- let overallStatus: ResponseStatus = "NOT_RESPONDED";
- if (Number(response.revisionRequestedCount) > 0) {
- overallStatus = "REVISION_REQUESTED";
- } else if (completionRate === 100) {
- overallStatus = Number(response.waivedCount) === Number(response.totalAttachments) ? "WAIVED" : "RESPONDED";
- } else if (Number(response.respondedCount) > 0) {
- overallStatus = "RESPONDED"; // 부분 응답
+ // RFQ 정보 조회 (초기 RFQ와 최종 RFQ 모두)
+ const [initialRfqs] = await Promise.all([
+ db.query.initialRfq.findMany({
+ where: or(...rfqRecordIds.map(id => eq(initialRfq.id, id))),
+ with: {
+ rfq: {
+ columns: {
+ id: true,
+ rfqCode: true,
+ description: true,
+ status: true,
+ dueDate: true,
+ }
}
+ }
+ })
- return {
- id: `${response.vendorId}-${response.rfqRecordId}-${response.rfqType}`,
- vendorId: response.vendorId,
- rfqRecordId: response.rfqRecordId,
- rfqType: response.rfqType,
- rfq: rfqInfo,
- vendor: vendor || null,
- totalAttachments: Number(response.totalAttachments),
- respondedCount: Number(response.respondedCount),
- pendingCount: Number(response.pendingCount),
- revisionRequestedCount: Number(response.revisionRequestedCount),
- waivedCount: Number(response.waivedCount),
- responseRate,
- completionRate,
- overallStatus,
- requestedAt: response.requestedAt,
- lastRespondedAt: response.lastRespondedAt,
- hasComments: response.hasComments,
- };
- });
-
- // 전체 개수 조회 (그룹핑 기준) - PostgreSQL 호환 방식
- const totalCountResult = await db
- .select({
- totalCount: sql<number>`COUNT(DISTINCT (${vendorAttachmentResponses.vendorId}, ${vendorAttachmentResponses.rfqRecordId}, ${vendorAttachmentResponses.rfqType}))`
- })
- .from(vendorAttachmentResponses)
- .where(baseWhere);
+ ]);
- const totalCount = Number(totalCountResult[0].totalCount);
- const pageCount = Math.ceil(totalCount / perPage);
+ // 데이터 조합 및 변환
+ const transformedResponses: VendorRfqResponseSummary[] = groupedResponses.map(response => {
+ const vendor = vendorsData.find(v => v.id === response.vendorId);
- return {
- data: transformedResponses,
- pageCount,
- totalCount
- };
+ let rfqInfo = null;
+ if (response.rfqType === "INITIAL") {
+ const initialRfq = initialRfqs.find(r => r.id === response.rfqRecordId);
+ rfqInfo = initialRfq?.rfq || null;
+ }
- } catch (err) {
- console.error("getVendorRfqResponses 에러:", err);
- return { data: [], pageCount: 0, totalCount: 0 };
+ // 응답률 계산
+ const responseRate = Number(response.totalAttachments) > 0
+ ? Math.round((Number(response.respondedCount) / Number(response.totalAttachments)) * 100)
+ : 0;
+
+ // 완료율 계산 (응답완료 + 포기)
+ const completionRate = Number(response.totalAttachments) > 0
+ ? Math.round(((Number(response.respondedCount) + Number(response.waivedCount)) / Number(response.totalAttachments)) * 100)
+ : 0;
+
+ // 전체 상태 결정
+ let overallStatus: ResponseStatus = "NOT_RESPONDED";
+ if (Number(response.revisionRequestedCount) > 0) {
+ overallStatus = "REVISION_REQUESTED";
+ } else if (completionRate === 100) {
+ overallStatus = Number(response.waivedCount) === Number(response.totalAttachments) ? "WAIVED" : "RESPONDED";
+ } else if (Number(response.respondedCount) > 0) {
+ overallStatus = "RESPONDED"; // 부분 응답
}
+
+ return {
+ id: `${response.vendorId}-${response.rfqRecordId}-${response.rfqType}`,
+ vendorId: response.vendorId,
+ rfqRecordId: response.rfqRecordId,
+ rfqType: response.rfqType,
+ rfq: rfqInfo,
+ vendor: vendor || null,
+ totalAttachments: Number(response.totalAttachments),
+ respondedCount: Number(response.respondedCount),
+ pendingCount: Number(response.pendingCount),
+ revisionRequestedCount: Number(response.revisionRequestedCount),
+ waivedCount: Number(response.waivedCount),
+ responseRate,
+ completionRate,
+ overallStatus,
+ requestedAt: response.requestedAt,
+ lastRespondedAt: response.lastRespondedAt,
+ hasComments: response.hasComments,
+ };
+ });
+
+ // 전체 개수 조회 (그룹핑 기준) - PostgreSQL 호환 방식
+ const totalCountResult = await db
+ .select({
+ totalCount: sql<number>`COUNT(DISTINCT (${vendorAttachmentResponses.vendorId}, ${vendorAttachmentResponses.rfqRecordId}, ${vendorAttachmentResponses.rfqType}))`
+ })
+ .from(vendorAttachmentResponses)
+ .where(baseWhere);
+
+ const totalCount = Number(totalCountResult[0].totalCount);
+ const pageCount = Math.ceil(totalCount / perPage);
+
+ return {
+ data: transformedResponses,
+ pageCount,
+ totalCount
+ };
+
+ } catch (err) {
+ console.error("getVendorRfqResponses 에러:", err);
+ return { data: [], pageCount: 0, totalCount: 0 };
+ }
}
/**
* 특정 RFQ의 첨부파일별 응답 상세 조회 (상세 페이지용)
*/
export async function getRfqAttachmentResponses(vendorId: string, rfqRecordId: string) {
- try {
- // 해당 RFQ의 모든 첨부파일 응답 조회
- const responses = await db.query.vendorAttachmentResponses.findMany({
- where: and(
- eq(vendorAttachmentResponses.vendorId, Number(vendorId)),
- eq(vendorAttachmentResponses.rfqRecordId, Number(rfqRecordId)),
- ),
+ try {
+ // 해당 RFQ의 모든 첨부파일 응답 조회
+ const responses = await db.query.vendorAttachmentResponses.findMany({
+ where: and(
+ eq(vendorAttachmentResponses.vendorId, Number(vendorId)),
+ eq(vendorAttachmentResponses.rfqRecordId, Number(rfqRecordId)),
+ ),
+ with: {
+ attachment: {
with: {
- attachment: {
+ rfq: {
+ columns: {
+ id: true,
+ rfqCode: true,
+ description: true,
+ status: true,
+ dueDate: true,
+ // 추가 정보
+ picCode: true,
+ picName: true,
+ EngPicName: true,
+ packageNo: true,
+ packageName: true,
+ projectId: true,
+ projectCompany: true,
+ projectFlag: true,
+ projectSite: true,
+ remark: true,
+ },
with: {
- rfq: {
+ project: {
columns: {
id: true,
- rfqCode: true,
- description: true,
- status: true,
- dueDate: true,
- // 추가 정보
- picCode: true,
- picName: true,
- EngPicName: true,
- packageNo: true,
- packageName: true,
- projectId: true,
- projectCompany: true,
- projectFlag: true,
- projectSite: true,
- remark: true,
- },
- with: {
- project: {
- columns: {
- id: true,
- code: true,
- name: true,
- type: true,
- }
- }
+ code: true,
+ name: true,
+ type: true,
}
}
}
- },
- vendor: {
- columns: {
- id: true,
- vendorCode: true,
- vendorName: true,
- country: true,
- businessSize: true,
- }
- },
- responseAttachments: true,
- },
- orderBy: [asc(vendorAttachmentResponses.attachmentId)]
- });
+ }
+ }
+ },
+ vendor: {
+ columns: {
+ id: true,
+ vendorCode: true,
+ vendorName: true,
+ country: true,
+ businessSize: true,
+ }
+ },
+ responseAttachments: true,
+ },
+ orderBy: [asc(vendorAttachmentResponses.attachmentId)]
+ });
- return {
- data: responses,
- rfqInfo: responses[0]?.attachment?.rfq || null,
- vendorInfo: responses[0]?.vendor || null,
- };
+ return {
+ data: responses,
+ rfqInfo: responses[0]?.attachment?.rfq || null,
+ vendorInfo: responses[0]?.vendor || null,
+ };
- } catch (err) {
- console.error("getRfqAttachmentResponses 에러:", err);
- return { data: [], rfqInfo: null, vendorInfo: null };
- }
+ } catch (err) {
+ console.error("getRfqAttachmentResponses 에러:", err);
+ return { data: [], rfqInfo: null, vendorInfo: null };
+ }
}
export async function getVendorResponseStatusCounts(vendorId?: string, rfqId?: string, rfqType?: RfqType) {
- try {
- const initial: Record<ResponseStatus, number> = {
- NOT_RESPONDED: 0,
- RESPONDED: 0,
- REVISION_REQUESTED: 0,
- WAIVED: 0,
- };
+ try {
+ const initial: Record<ResponseStatus, number> = {
+ NOT_RESPONDED: 0,
+ RESPONDED: 0,
+ REVISION_REQUESTED: 0,
+ WAIVED: 0,
+ };
- // 조건 설정
- let whereConditions = [];
+ // 조건 설정
+ let whereConditions = [];
- // 벤더 ID 조건
- if (vendorId) {
- whereConditions.push(eq(vendorAttachmentResponses.vendorId, Number(vendorId)));
- }
+ // 벤더 ID 조건
+ if (vendorId) {
+ whereConditions.push(eq(vendorAttachmentResponses.vendorId, Number(vendorId)));
+ }
- // RFQ ID 조건
- if (rfqId) {
- const attachmentIds = await db
- .select({ id: bRfqsAttachments.id })
- .from(bRfqsAttachments)
- .where(eq(bRfqsAttachments.rfqId, Number(rfqId)));
-
- if (attachmentIds.length > 0) {
- whereConditions.push(
- or(...attachmentIds.map(att => eq(vendorAttachmentResponses.attachmentId, att.id)))
- );
- }
- }
+ // RFQ ID 조건
+ if (rfqId) {
+ const attachmentIds = await db
+ .select({ id: bRfqsAttachments.id })
+ .from(bRfqsAttachments)
+ .where(eq(bRfqsAttachments.rfqId, Number(rfqId)));
- // RFQ 타입 조건
- if (rfqType) {
- whereConditions.push(eq(vendorAttachmentResponses.rfqType, rfqType));
- }
+ if (attachmentIds.length > 0) {
+ whereConditions.push(
+ or(...attachmentIds.map(att => eq(vendorAttachmentResponses.attachmentId, att.id)))
+ );
+ }
+ }
- const whereCondition = whereConditions.length > 0 ? and(...whereConditions) : undefined;
+ // RFQ 타입 조건
+ if (rfqType) {
+ whereConditions.push(eq(vendorAttachmentResponses.rfqType, rfqType));
+ }
- // 상태별 그룹핑 쿼리
- const rows = await db
- .select({
- status: vendorAttachmentResponses.responseStatus,
- count: count(),
- })
- .from(vendorAttachmentResponses)
- .where(whereCondition)
- .groupBy(vendorAttachmentResponses.responseStatus);
-
- // 결과 처리
- const result = rows.reduce<Record<ResponseStatus, number>>((acc, { status, count }) => {
- if (status) {
- acc[status as ResponseStatus] = Number(count);
- }
- return acc;
- }, initial);
+ const whereCondition = whereConditions.length > 0 ? and(...whereConditions) : undefined;
+
+ // 상태별 그룹핑 쿼리
+ const rows = await db
+ .select({
+ status: vendorAttachmentResponses.responseStatus,
+ count: count(),
+ })
+ .from(vendorAttachmentResponses)
+ .where(whereCondition)
+ .groupBy(vendorAttachmentResponses.responseStatus);
- return result;
- } catch (err) {
- console.error("getVendorResponseStatusCounts 에러:", err);
- return {} as Record<ResponseStatus, number>;
+ // 결과 처리
+ const result = rows.reduce<Record<ResponseStatus, number>>((acc, { status, count }) => {
+ if (status) {
+ acc[status as ResponseStatus] = Number(count);
}
+ return acc;
+ }, initial);
+
+ return result;
+ } catch (err) {
+ console.error("getVendorResponseStatusCounts 에러:", err);
+ return {} as Record<ResponseStatus, number>;
+ }
}
/**
@@ -2041,101 +2044,101 @@ export async function getVendorResponseStatusCounts(vendorId?: string, rfqId?: s
*/
export async function getRfqResponseSummary(rfqId: string, rfqType?: RfqType) {
- try {
- // RFQ의 첨부파일 목록 조회 (relations 사용)
- const attachments = await db.query.bRfqsAttachments.findMany({
- where: eq(bRfqsAttachments.rfqId, Number(rfqId)),
- columns: {
- id: true,
- attachmentType: true,
- serialNo: true,
- description: true,
- }
- });
-
- if (attachments.length === 0) {
- return {
- totalAttachments: 0,
- totalVendors: 0,
- responseRate: 0,
- completionRate: 0,
- statusCounts: {} as Record<ResponseStatus, number>
- };
- }
-
- // 조건 설정
- let whereConditions = [
- or(...attachments.map(att => eq(vendorAttachmentResponses.attachmentId, att.id)))
- ];
+ try {
+ // RFQ의 첨부파일 목록 조회 (relations 사용)
+ const attachments = await db.query.bRfqsAttachments.findMany({
+ where: eq(bRfqsAttachments.rfqId, Number(rfqId)),
+ columns: {
+ id: true,
+ attachmentType: true,
+ serialNo: true,
+ description: true,
+ }
+ });
- if (rfqType) {
- whereConditions.push(eq(vendorAttachmentResponses.rfqType, rfqType));
- }
+ if (attachments.length === 0) {
+ return {
+ totalAttachments: 0,
+ totalVendors: 0,
+ responseRate: 0,
+ completionRate: 0,
+ statusCounts: {} as Record<ResponseStatus, number>
+ };
+ }
- const whereCondition = and(...whereConditions);
+ // 조건 설정
+ let whereConditions = [
+ or(...attachments.map(att => eq(vendorAttachmentResponses.attachmentId, att.id)))
+ ];
- // 벤더 수 및 응답 통계 조회
- const [vendorStats, statusCounts] = await Promise.all([
- // 전체 벤더 수 및 응답 벤더 수 (조건부 COUNT 수정)
- db
- .select({
- totalVendors: count(),
- respondedVendors: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' THEN 1 ELSE 0 END)`,
- completedVendors: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' OR ${vendorAttachmentResponses.responseStatus} = 'WAIVED' THEN 1 ELSE 0 END)`,
- })
- .from(vendorAttachmentResponses)
- .where(whereCondition),
+ if (rfqType) {
+ whereConditions.push(eq(vendorAttachmentResponses.rfqType, rfqType));
+ }
- // 상태별 개수
- db
- .select({
- status: vendorAttachmentResponses.responseStatus,
- count: count(),
- })
- .from(vendorAttachmentResponses)
- .where(whereCondition)
- .groupBy(vendorAttachmentResponses.responseStatus)
- ]);
-
- const stats = vendorStats[0];
- const statusCountsMap = statusCounts.reduce<Record<ResponseStatus, number>>((acc, { status, count }) => {
- if (status) {
- acc[status as ResponseStatus] = Number(count);
- }
- return acc;
- }, {
- NOT_RESPONDED: 0,
- RESPONDED: 0,
- REVISION_REQUESTED: 0,
- WAIVED: 0,
- });
-
- const responseRate = stats.totalVendors > 0
- ? Math.round((Number(stats.respondedVendors) / Number(stats.totalVendors)) * 100)
- : 0;
-
- const completionRate = stats.totalVendors > 0
- ? Math.round((Number(stats.completedVendors) / Number(stats.totalVendors)) * 100)
- : 0;
+ const whereCondition = and(...whereConditions);
- return {
- totalAttachments: attachments.length,
- totalVendors: Number(stats.totalVendors),
- responseRate,
- completionRate,
- statusCounts: statusCountsMap
- };
+ // 벤더 수 및 응답 통계 조회
+ const [vendorStats, statusCounts] = await Promise.all([
+ // 전체 벤더 수 및 응답 벤더 수 (조건부 COUNT 수정)
+ db
+ .select({
+ totalVendors: count(),
+ respondedVendors: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' THEN 1 ELSE 0 END)`,
+ completedVendors: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' OR ${vendorAttachmentResponses.responseStatus} = 'WAIVED' THEN 1 ELSE 0 END)`,
+ })
+ .from(vendorAttachmentResponses)
+ .where(whereCondition),
- } catch (err) {
- console.error("getRfqResponseSummary 에러:", err);
- return {
- totalAttachments: 0,
- totalVendors: 0,
- responseRate: 0,
- completionRate: 0,
- statusCounts: {} as Record<ResponseStatus, number>
- };
+ // 상태별 개수
+ db
+ .select({
+ status: vendorAttachmentResponses.responseStatus,
+ count: count(),
+ })
+ .from(vendorAttachmentResponses)
+ .where(whereCondition)
+ .groupBy(vendorAttachmentResponses.responseStatus)
+ ]);
+
+ const stats = vendorStats[0];
+ const statusCountsMap = statusCounts.reduce<Record<ResponseStatus, number>>((acc, { status, count }) => {
+ if (status) {
+ acc[status as ResponseStatus] = Number(count);
}
+ return acc;
+ }, {
+ NOT_RESPONDED: 0,
+ RESPONDED: 0,
+ REVISION_REQUESTED: 0,
+ WAIVED: 0,
+ });
+
+ const responseRate = stats.totalVendors > 0
+ ? Math.round((Number(stats.respondedVendors) / Number(stats.totalVendors)) * 100)
+ : 0;
+
+ const completionRate = stats.totalVendors > 0
+ ? Math.round((Number(stats.completedVendors) / Number(stats.totalVendors)) * 100)
+ : 0;
+
+ return {
+ totalAttachments: attachments.length,
+ totalVendors: Number(stats.totalVendors),
+ responseRate,
+ completionRate,
+ statusCounts: statusCountsMap
+ };
+
+ } catch (err) {
+ console.error("getRfqResponseSummary 에러:", err);
+ return {
+ totalAttachments: 0,
+ totalVendors: 0,
+ responseRate: 0,
+ completionRate: 0,
+ statusCounts: {} as Record<ResponseStatus, number>
+ };
+ }
}
/**
@@ -2143,54 +2146,54 @@ export async function getRfqResponseSummary(rfqId: string, rfqType?: RfqType) {
*/
export async function getVendorResponseProgress(vendorId: string) {
- try {
- let whereConditions = [eq(vendorAttachmentResponses.vendorId, Number(vendorId))];
-
- const whereCondition = and(...whereConditions);
-
- const progress = await db
- .select({
- totalRequests: count(),
- responded: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' THEN 1 ELSE 0 END)`,
- pending: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'NOT_RESPONDED' THEN 1 ELSE 0 END)`,
- revisionRequested: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'REVISION_REQUESTED' THEN 1 ELSE 0 END)`,
- waived: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'WAIVED' THEN 1 ELSE 0 END)`,
- })
- .from(vendorAttachmentResponses)
- .where(whereCondition);
- console.log(progress,"progress")
-
- const stats = progress[0];
- const responseRate = Number(stats.totalRequests) > 0
- ? Math.round((Number(stats.responded) / Number(stats.totalRequests)) * 100)
- : 0;
-
- const completionRate = Number(stats.totalRequests) > 0
- ? Math.round(((Number(stats.responded) + Number(stats.waived)) / Number(stats.totalRequests)) * 100)
- : 0;
+ try {
+ let whereConditions = [eq(vendorAttachmentResponses.vendorId, Number(vendorId))];
- return {
- totalRequests: Number(stats.totalRequests),
- responded: Number(stats.responded),
- pending: Number(stats.pending),
- revisionRequested: Number(stats.revisionRequested),
- waived: Number(stats.waived),
- responseRate,
- completionRate,
- };
+ const whereCondition = and(...whereConditions);
- } catch (err) {
- console.error("getVendorResponseProgress 에러:", err);
- return {
- totalRequests: 0,
- responded: 0,
- pending: 0,
- revisionRequested: 0,
- waived: 0,
- responseRate: 0,
- completionRate: 0,
- };
- }
+ const progress = await db
+ .select({
+ totalRequests: count(),
+ responded: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'RESPONDED' THEN 1 ELSE 0 END)`,
+ pending: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'NOT_RESPONDED' THEN 1 ELSE 0 END)`,
+ revisionRequested: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'REVISION_REQUESTED' THEN 1 ELSE 0 END)`,
+ waived: sql<number>`SUM(CASE WHEN ${vendorAttachmentResponses.responseStatus} = 'WAIVED' THEN 1 ELSE 0 END)`,
+ })
+ .from(vendorAttachmentResponses)
+ .where(whereCondition);
+ console.log(progress, "progress")
+
+ const stats = progress[0];
+ const responseRate = Number(stats.totalRequests) > 0
+ ? Math.round((Number(stats.responded) / Number(stats.totalRequests)) * 100)
+ : 0;
+
+ const completionRate = Number(stats.totalRequests) > 0
+ ? Math.round(((Number(stats.responded) + Number(stats.waived)) / Number(stats.totalRequests)) * 100)
+ : 0;
+
+ return {
+ totalRequests: Number(stats.totalRequests),
+ responded: Number(stats.responded),
+ pending: Number(stats.pending),
+ revisionRequested: Number(stats.revisionRequested),
+ waived: Number(stats.waived),
+ responseRate,
+ completionRate,
+ };
+
+ } catch (err) {
+ console.error("getVendorResponseProgress 에러:", err);
+ return {
+ totalRequests: 0,
+ responded: 0,
+ pending: 0,
+ revisionRequested: 0,
+ waived: 0,
+ responseRate: 0,
+ completionRate: 0,
+ };
+ }
}
@@ -2214,7 +2217,7 @@ export async function getRfqAttachmentResponsesWithRevisions(vendorId: string, r
.from(rfqProgressSummaryView)
.where(eq(rfqProgressSummaryView.rfqId, responses[0]?.rfqId || 0))
.limit(1);
-
+
const progressSummary = progressSummaryResult[0] || null;
// 3. 각 응답의 첨부파일 리비전 히스토리 조회
@@ -2225,7 +2228,7 @@ export async function getRfqAttachmentResponsesWithRevisions(vendorId: string, r
.from(attachmentRevisionHistoryView)
.where(eq(attachmentRevisionHistoryView.attachmentId, response.attachmentId))
.orderBy(desc(attachmentRevisionHistoryView.clientRevisionCreatedAt));
-
+
return {
attachmentId: response.attachmentId,
revisions: history
@@ -2241,7 +2244,7 @@ export async function getRfqAttachmentResponsesWithRevisions(vendorId: string, r
.from(vendorResponseAttachmentsEnhanced)
.where(eq(vendorResponseAttachmentsEnhanced.vendorResponseId, response.responseId))
.orderBy(desc(vendorResponseAttachmentsEnhanced.uploadedAt));
-
+
return {
responseId: response.responseId,
files: files
@@ -2253,7 +2256,7 @@ export async function getRfqAttachmentResponsesWithRevisions(vendorId: string, r
const enhancedResponses = responses.map(response => {
const attachmentHistory = attachmentHistories.find(h => h.attachmentId === response.attachmentId);
const responseFileData = responseFiles.find(f => f.responseId === response.responseId);
-
+
return {
...response,
// 첨부파일 정보에 리비전 히스토리 추가
@@ -2293,7 +2296,7 @@ export async function getRfqAttachmentResponsesWithRevisions(vendorId: string, r
versionLag: response.versionLag,
needsUpdate: response.needsUpdate,
hasMultipleRevisions: response.hasMultipleRevisions,
-
+
// 새로 추가된 필드들
revisionRequestComment: response.revisionRequestComment,
revisionRequestedAt: response.revisionRequestedAt?.toISOString() || null,
@@ -2361,10 +2364,10 @@ export async function getRfqAttachmentResponsesWithRevisions(vendorId: string, r
} catch (err) {
console.error("getRfqAttachmentResponsesWithRevisions 에러:", err);
- return {
- data: [],
- rfqInfo: null,
- vendorInfo: null,
+ return {
+ data: [],
+ rfqInfo: null,
+ vendorInfo: null,
statistics: {
total: 0,
responded: 0,
@@ -2385,51 +2388,51 @@ export async function getRfqAttachmentResponsesWithRevisions(vendorId: string, r
// 첨부파일 리비전 히스토리 조회
export async function getAttachmentRevisionHistory(attachmentId: number) {
- try {
- const history = await db
- .select()
- .from(attachmentRevisionHistoryView)
- .where(eq(attachmentRevisionHistoryView.attachmentId, attachmentId))
- .orderBy(desc(attachmentRevisionHistoryView.clientRevisionCreatedAt));
+ try {
+ const history = await db
+ .select()
+ .from(attachmentRevisionHistoryView)
+ .where(eq(attachmentRevisionHistoryView.attachmentId, attachmentId))
+ .orderBy(desc(attachmentRevisionHistoryView.clientRevisionCreatedAt));
- return history;
- } catch (err) {
- console.error("getAttachmentRevisionHistory 에러:", err);
- return [];
- }
+ return history;
+ } catch (err) {
+ console.error("getAttachmentRevisionHistory 에러:", err);
+ return [];
}
+}
// RFQ 전체 진행 현황 조회
export async function getRfqProgressSummary(rfqId: number) {
- try {
- const summaryResult = await db
- .select()
- .from(rfqProgressSummaryView)
- .where(eq(rfqProgressSummaryView.rfqId, rfqId))
- .limit(1);
-
- return summaryResult[0] || null;
- } catch (err) {
- console.error("getRfqProgressSummary 에러:", err);
- return null;
- }
+ try {
+ const summaryResult = await db
+ .select()
+ .from(rfqProgressSummaryView)
+ .where(eq(rfqProgressSummaryView.rfqId, rfqId))
+ .limit(1);
+
+ return summaryResult[0] || null;
+ } catch (err) {
+ console.error("getRfqProgressSummary 에러:", err);
+ return null;
+ }
}
// 벤더 응답 파일 상세 조회 (향상된 정보 포함)
export async function getVendorResponseFiles(vendorResponseId: number) {
- try {
- const files = await db
- .select()
- .from(vendorResponseAttachmentsEnhanced)
- .where(eq(vendorResponseAttachmentsEnhanced.vendorResponseId, vendorResponseId))
- .orderBy(desc(vendorResponseAttachmentsEnhanced.uploadedAt));
+ try {
+ const files = await db
+ .select()
+ .from(vendorResponseAttachmentsEnhanced)
+ .where(eq(vendorResponseAttachmentsEnhanced.vendorResponseId, vendorResponseId))
+ .orderBy(desc(vendorResponseAttachmentsEnhanced.uploadedAt));
- return files;
- } catch (err) {
- console.error("getVendorResponseFiles 에러:", err);
- return [];
- }
+ return files;
+ } catch (err) {
+ console.error("getVendorResponseFiles 에러:", err);
+ return [];
}
+}
// 타입 정의 확장
@@ -2440,53 +2443,53 @@ export type EnhancedVendorResponse = {
rfqCode: string;
rfqType: "INITIAL" | "FINAL";
rfqRecordId: number;
-
+
// 첨부파일 정보
attachmentId: number;
attachmentType: string;
serialNo: string;
attachmentDescription?: string;
-
+
// 벤더 정보
vendorId: number;
vendorCode: string;
vendorName: string;
vendorCountry: string;
-
+
// 응답 상태
responseStatus: "NOT_RESPONDED" | "RESPONDED" | "REVISION_REQUESTED" | "WAIVED";
currentRevision: string;
respondedRevision?: string;
effectiveStatus: string;
-
+
// 코멘트 관련 필드들 (새로 추가된 필드 포함)
responseComment?: string; // 벤더가 응답할 때 작성하는 코멘트
vendorComment?: string; // 벤더 내부 메모
revisionRequestComment?: string; // 발주처가 수정 요청할 때 작성하는 사유 (새로 추가)
-
+
// 날짜 관련 필드들 (새로 추가된 필드 포함)
requestedAt: string;
respondedAt?: string;
revisionRequestedAt?: string; // 수정 요청 날짜 (새로 추가)
-
+
// 발주처 최신 리비전 정보
latestClientRevisionNo?: string;
latestClientFileName?: string;
latestClientFileSize?: number;
latestClientRevisionComment?: string;
-
+
// 리비전 분석
isVersionMatched: boolean;
versionLag?: number;
needsUpdate: boolean;
hasMultipleRevisions: boolean;
-
+
// 응답 파일 통계
totalResponseFiles: number;
latestResponseFileName?: string;
latestResponseFileSize?: number;
latestResponseUploadedAt?: string;
-
+
// 첨부파일 정보 (리비전 히스토리 포함)
attachment: {
id: number;
@@ -2506,7 +2509,7 @@ export type EnhancedVendorResponse = {
isLatest: boolean;
}>;
};
-
+
// 벤더 응답 파일들
responseAttachments: Array<{
id: number;
@@ -2592,10 +2595,366 @@ export async function requestRevision(
} catch (error) {
console.error("Request revision server action error:", error);
- return {
+ return {
success: false,
message: "내부 서버 오류가 발생했습니다",
error: "INTERNAL_ERROR",
};
}
+}
+
+
+
+export async function shortListConfirm(input: ShortListConfirmInput) {
+ try {
+ const validatedInput = shortListConfirmSchema.parse(input)
+ const { rfqId, selectedVendorIds, rejectedVendorIds } = validatedInput
+
+ // 1. RFQ 정보 조회
+ const rfqInfo = await db
+ .select()
+ .from(bRfqs)
+ .where(eq(bRfqs.id, rfqId))
+ .limit(1)
+
+ if (!rfqInfo.length) {
+ return { success: false, message: "RFQ를 찾을 수 없습니다." }
+ }
+
+ const rfq = rfqInfo[0]
+
+ // 2. 기존 initial_rfq에서 필요한 정보 조회
+ const initialRfqData = await db
+ .select({
+ id: initialRfq.id,
+ vendorId: initialRfq.vendorId,
+ dueDate: initialRfq.dueDate,
+ validDate: initialRfq.validDate,
+ incotermsCode: initialRfq.incotermsCode,
+ gtc: initialRfq.gtc,
+ gtcValidDate: initialRfq.gtcValidDate,
+ classification: initialRfq.classification,
+ sparepart: initialRfq.sparepart,
+ cpRequestYn: initialRfq.cpRequestYn,
+ prjectGtcYn: initialRfq.prjectGtcYn,
+ returnRevision: initialRfq.returnRevision,
+ })
+ .from(initialRfq)
+ .where(
+ and(
+ eq(initialRfq.rfqId, rfqId),
+ inArray(initialRfq.vendorId, [...selectedVendorIds, ...rejectedVendorIds])
+ )
+ )
+
+ if (!initialRfqData.length) {
+ return { success: false, message: "해당 RFQ의 초기 RFQ 데이터를 찾을 수 없습니다." }
+ }
+
+ // 3. 탈락된 벤더들의 이메일 정보 조회
+ let rejectedVendorEmails: Array<{
+ vendorId: number
+ vendorName: string
+ email: string
+ }> = []
+
+ if (rejectedVendorIds.length > 0) {
+ rejectedVendorEmails = await db
+ .select({
+ vendorId: vendors.id,
+ vendorName: vendors.vendorName,
+ email: vendors.email,
+ })
+ .from(vendors)
+ .where(inArray(vendors.id, rejectedVendorIds))
+ }
+
+ await db.transaction(async (tx) => {
+ // 4. 선택된 벤더들에 대해 final_rfq 테이블에 데이터 생성/업데이트
+ for (const vendorId of selectedVendorIds) {
+ const initialData = initialRfqData.find(data => data.vendorId === vendorId)
+
+ if (initialData) {
+ // 기존 final_rfq 레코드 확인
+ const existingFinalRfq = await tx
+ .select()
+ .from(finalRfq)
+ .where(
+ and(
+ eq(finalRfq.rfqId, rfqId),
+ eq(finalRfq.vendorId, vendorId)
+ )
+ )
+ .limit(1)
+
+ if (existingFinalRfq.length > 0) {
+ // 기존 레코드 업데이트
+ await tx
+ .update(finalRfq)
+ .set({
+ shortList: true,
+ finalRfqStatus: "DRAFT",
+ dueDate: initialData.dueDate,
+ validDate: initialData.validDate,
+ incotermsCode: initialData.incotermsCode,
+ gtc: initialData.gtc,
+ gtcValidDate: initialData.gtcValidDate,
+ classification: initialData.classification,
+ sparepart: initialData.sparepart,
+ cpRequestYn: initialData.cpRequestYn,
+ prjectGtcYn: initialData.prjectGtcYn,
+ updatedAt: new Date(),
+ })
+ .where(eq(finalRfq.id, existingFinalRfq[0].id))
+ } else {
+ // 새 레코드 생성
+ await tx
+ .insert(finalRfq)
+ .values({
+ rfqId,
+ vendorId,
+ finalRfqStatus: "DRAFT",
+ dueDate: initialData.dueDate,
+ validDate: initialData.validDate,
+ incotermsCode: initialData.incotermsCode,
+ gtc: initialData.gtc,
+ gtcValidDate: initialData.gtcValidDate,
+ classification: initialData.classification,
+ sparepart: initialData.sparepart,
+ shortList: true,
+ returnYn: false,
+ cpRequestYn: initialData.cpRequestYn,
+ prjectGtcYn: initialData.prjectGtcYn,
+ returnRevision: 0,
+ currency: "KRW",
+ taxCode: "VV",
+ deliveryDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30일 후
+ firsttimeYn: true,
+ materialPriceRelatedYn: false,
+ })
+ }
+ }
+ }
+
+ // 5. 탈락된 벤더들에 대해서는 shortList: false로 설정 (있다면)
+ if (rejectedVendorIds.length > 0) {
+ // 기존에 final_rfq에 있는 탈락 벤더들은 shortList를 false로 업데이트
+ await tx
+ .update(finalRfq)
+ .set({
+ shortList: false,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(finalRfq.rfqId, rfqId),
+ inArray(finalRfq.vendorId, rejectedVendorIds)
+ )
+ )
+ }
+
+ // 6. initial_rfq의 shortList 필드도 업데이트
+ if (selectedVendorIds.length > 0) {
+ await tx
+ .update(initialRfq)
+ .set({
+ shortList: true,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(initialRfq.rfqId, rfqId),
+ inArray(initialRfq.vendorId, selectedVendorIds)
+ )
+ )
+ }
+
+ if (rejectedVendorIds.length > 0) {
+ await tx
+ .update(initialRfq)
+ .set({
+ shortList: false,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(initialRfq.rfqId, rfqId),
+ inArray(initialRfq.vendorId, rejectedVendorIds)
+ )
+ )
+ }
+ })
+
+ // 7. 탈락된 벤더들에게 Letter of Regret 이메일 발송
+ const emailErrors: string[] = []
+
+ for (const rejectedVendor of rejectedVendorEmails) {
+ if (rejectedVendor.email) {
+ try {
+ await sendEmail({
+ to: rejectedVendor.email,
+ subject: `Letter of Regret - RFQ ${rfq.rfqCode}`,
+ template: "letter-of-regret",
+ context: {
+ rfqCode: rfq.rfqCode,
+ vendorName: rejectedVendor.vendorName,
+ projectTitle: rfq.projectTitle || "Project",
+ dateTime: new Date().toLocaleDateString("ko-KR", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ }),
+ companyName: "Your Company Name", // 실제 회사명으로 변경
+ language: "ko",
+ },
+ })
+ } catch (error) {
+ console.error(`Email sending failed for vendor ${rejectedVendor.vendorName}:`, error)
+ emailErrors.push(`${rejectedVendor.vendorName}에게 이메일 발송 실패`)
+ }
+ }
+ }
+
+ // 8. 페이지 revalidation
+ revalidatePath(`/evcp/a-rfq/${rfqId}`)
+ revalidatePath(`/evcp/b-rfq/${rfqId}`)
+
+ const successMessage = `Short List가 확정되었습니다. (선택: ${selectedVendorIds.length}개, 탈락: ${rejectedVendorIds.length}개)`
+
+ return {
+ success: true,
+ message: successMessage,
+ errors: emailErrors.length > 0 ? emailErrors : undefined,
+ data: {
+ selectedCount: selectedVendorIds.length,
+ rejectedCount: rejectedVendorIds.length,
+ emailsSent: rejectedVendorEmails.length - emailErrors.length,
+ },
+ }
+
+ } catch (error) {
+ console.error("Short List confirm error:", error)
+ return {
+ success: false,
+ message: "Short List 확정 중 오류가 발생했습니다.",
+ }
+ }
+}
+
+export async function getFinalRfqDetail(input: GetFinalRfqDetailSchema, rfqId?: number) {
+
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 1) 고급 필터 조건
+ let advancedWhere: SQL<unknown> | undefined = undefined;
+ if (input.filters && input.filters.length > 0) {
+ advancedWhere = filterColumns({
+ table: finalRfqDetailView,
+ filters: input.filters,
+ joinOperator: input.joinOperator || 'and',
+ });
+ }
+
+ // 2) 기본 필터 조건
+ let basicWhere: SQL<unknown> | undefined = undefined;
+ if (input.basicFilters && input.basicFilters.length > 0) {
+ basicWhere = filterColumns({
+ table: finalRfqDetailView,
+ filters: input.basicFilters,
+ joinOperator: input.basicJoinOperator || 'and',
+ });
+ }
+
+ let rfqIdWhere: SQL<unknown> | undefined = undefined;
+ if (rfqId) {
+ rfqIdWhere = eq(finalRfqDetailView.rfqId, rfqId);
+ }
+
+ // 3) 글로벌 검색 조건
+ let globalWhere: SQL<unknown> | undefined = undefined;
+ if (input.search) {
+ const s = `%${input.search}%`;
+
+ const validSearchConditions: SQL<unknown>[] = [];
+
+ const rfqCodeCondition = ilike(finalRfqDetailView.rfqCode, s);
+ if (rfqCodeCondition) validSearchConditions.push(rfqCodeCondition);
+
+ const vendorNameCondition = ilike(finalRfqDetailView.vendorName, s);
+ if (vendorNameCondition) validSearchConditions.push(vendorNameCondition);
+
+ const vendorCodeCondition = ilike(finalRfqDetailView.vendorCode, s);
+ if (vendorCodeCondition) validSearchConditions.push(vendorCodeCondition);
+
+ const vendorCountryCondition = ilike(finalRfqDetailView.vendorCountry, s);
+ if (vendorCountryCondition) validSearchConditions.push(vendorCountryCondition);
+
+ const incotermsDescriptionCondition = ilike(finalRfqDetailView.incotermsDescription, s);
+ if (incotermsDescriptionCondition) validSearchConditions.push(incotermsDescriptionCondition);
+
+ const paymentTermsDescriptionCondition = ilike(finalRfqDetailView.paymentTermsDescription, s);
+ if (paymentTermsDescriptionCondition) validSearchConditions.push(paymentTermsDescriptionCondition);
+
+ const classificationCondition = ilike(finalRfqDetailView.classification, s);
+ if (classificationCondition) validSearchConditions.push(classificationCondition);
+
+ const sparepartCondition = ilike(finalRfqDetailView.sparepart, s);
+ if (sparepartCondition) validSearchConditions.push(sparepartCondition);
+
+ if (validSearchConditions.length > 0) {
+ globalWhere = or(...validSearchConditions);
+ }
+ }
+
+ // 5) 최종 WHERE 조건 생성
+ const whereConditions: SQL<unknown>[] = [];
+
+ if (advancedWhere) whereConditions.push(advancedWhere);
+ if (basicWhere) whereConditions.push(basicWhere);
+ if (globalWhere) whereConditions.push(globalWhere);
+ if (rfqIdWhere) whereConditions.push(rfqIdWhere);
+
+ const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
+
+ // 6) 전체 데이터 수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(finalRfqDetailView)
+ .where(finalWhere);
+
+ const total = totalResult[0]?.count || 0;
+
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 };
+ }
+
+ console.log(totalResult);
+ console.log(total);
+
+ // 7) 정렬 및 페이징 처리된 데이터 조회
+ const orderByColumns = input.sort.map((sort) => {
+ const column = sort.id as keyof typeof finalRfqDetailView.$inferSelect;
+ return sort.desc ? desc(finalRfqDetailView[column]) : asc(finalRfqDetailView[column]);
+ });
+
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(finalRfqDetailView.createdAt));
+ }
+
+ const finalRfqData = await db
+ .select()
+ .from(finalRfqDetailView)
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
+ .limit(input.perPage)
+ .offset(offset);
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data: finalRfqData, pageCount, total };
+ } catch (err) {
+ console.error("Error in getFinalRfqDetail:", err);
+ return { data: [], pageCount: 0, total: 0 };
+ }
} \ No newline at end of file
diff --git a/lib/b-rfq/validations.ts b/lib/b-rfq/validations.ts
index f9473656..bee10a11 100644
--- a/lib/b-rfq/validations.ts
+++ b/lib/b-rfq/validations.ts
@@ -7,7 +7,7 @@ import { createSearchParamsCache,
import * as z from "zod"
import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
-import { VendorAttachmentResponse } from "@/db/schema";
+import { FinalRfqDetailView, VendorAttachmentResponse } from "@/db/schema";
export const searchParamsRFQDashboardCache = createSearchParamsCache({
// 공통 플래그
@@ -273,6 +273,8 @@ export const updateInitialRfqSchema = z.object({
required_error: "마감일을 선택해주세요.",
}),
validDate: z.date().optional(),
+ gtc: z.string().optional(),
+ gtcValidDate: z.string().optional(),
incotermsCode: z.string().max(20, "Incoterms 코드는 20자 이하여야 합니다.").optional(),
classification: z.string().max(255, "분류는 255자 이하여야 합니다.").optional(),
sparepart: z.string().max(255, "예비부품은 255자 이하여야 합니다.").optional(),
@@ -403,4 +405,43 @@ export type RequestRevisionResult = {
success: boolean;
message: string;
error?: string;
-}; \ No newline at end of file
+};
+
+export const shortListConfirmSchema = z.object({
+ rfqId: z.number(),
+ selectedVendorIds: z.array(z.number()).min(1),
+ rejectedVendorIds: z.array(z.number()),
+})
+
+export type ShortListConfirmInput = z.infer<typeof shortListConfirmSchema>
+
+
+export const searchParamsFinalRfqDetailCache = createSearchParamsCache({
+ // 공통 플래그
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬 - initialRfqDetailView 기반
+ sort: getSortingStateParser<FinalRfqDetailView>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 기본 필터
+ basicFilters: getFiltersStateParser().withDefault([]),
+ basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 검색 키워드
+ search: parseAsString.withDefault(""),
+
+
+});
+
+export type GetFinalRfqDetailSchema = Awaited<ReturnType<typeof searchParamsFinalRfqDetailCache.parse>>;
+
diff --git a/lib/evaluation/table/evaluation-table.tsx b/lib/evaluation/table/evaluation-table.tsx
index cecaeeaa..0a5db3cb 100644
--- a/lib/evaluation/table/evaluation-table.tsx
+++ b/lib/evaluation/table/evaluation-table.tsx
@@ -452,7 +452,6 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className }
<PeriodicEvaluationsTableToolbarActions
table={table}
/>
- {/* TODO: PeriodicEvaluationsTableToolbarActions 구현 */}
</div>
</DataTableAdvancedToolbar>
</DataTable>
diff --git a/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx b/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx
index bb63a1fd..39a95cc7 100644
--- a/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx
+++ b/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx
@@ -29,6 +29,8 @@ import { PeriodicEvaluationView } from "@/db/schema"
import { exportTableToExcel } from "@/lib/export"
import { FinalizeEvaluationDialog } from "./periodic-evaluation-finalize-dialogs"
+import { useAuthRole } from "@/hooks/use-auth-role"
+
interface PeriodicEvaluationsTableToolbarActionsProps {
table: Table<PeriodicEvaluationView>
onRefresh?: () => void
@@ -45,16 +47,20 @@ export function PeriodicEvaluationsTableToolbarActions({
const [finalizeEvaluationDialogOpen, setFinalizeEvaluationDialogOpen] = React.useState(false)
const router = useRouter()
+ // 권한 체크 (방법 1 또는 방법 2 중 선택)
+ const { hasRole, isLoading: roleLoading } = useAuthRole()
+ const canManageEvaluations = hasRole('정기평가')
+
// 선택된 행들
const selectedRows = table.getFilteredSelectedRowModel().rows
const hasSelection = selectedRows.length > 0
- // ✅ selectedEvaluations를 useMemo로 안정화 (VendorsTable 방식과 동일)
+ // ✅ selectedEvaluations를 useMemo로 안정화
const selectedEvaluations = React.useMemo(() => {
return selectedRows.map(row => row.original)
}, [selectedRows])
- // ✅ 각 상태별 평가들을 개별적으로 메모이제이션 (VendorsTable 방식과 동일)
+ // ✅ 각 상태별 평가들을 개별적으로 메모이제이션
const pendingSubmissionEvaluations = React.useMemo(() => {
return table
.getFilteredSelectedRowModel()
@@ -132,7 +138,6 @@ export function PeriodicEvaluationsTableToolbarActions({
selectedEvaluations.length
])
-
// ----------------------------------------------------------------
// 다이얼로그 성공 핸들러
// ----------------------------------------------------------------
@@ -152,6 +157,25 @@ export function PeriodicEvaluationsTableToolbarActions({
})
}, [table])
+ // 권한이 없거나 로딩 중인 경우 내보내기 버튼만 표시
+ if (roleLoading) {
+ return (
+ <div className="flex items-center gap-2">
+ <div className="flex items-center gap-1 border-l pl-2 ml-2">
+ <Button
+ variant="outline"
+ size="sm"
+ disabled
+ className="gap-2"
+ >
+ <Download className="size-4 animate-spin" aria-hidden="true" />
+ <span className="hidden sm:inline">로딩중...</span>
+ </Button>
+ </div>
+ </div>
+ )
+ }
+
return (
<>
<div className="flex items-center gap-2">
@@ -169,8 +193,8 @@ export function PeriodicEvaluationsTableToolbarActions({
</Button>
</div>
- {/* 선택된 항목 액션 버튼들 */}
- {hasSelection && (
+ {/* 선택된 항목 액션 버튼들 - 정기평가 권한이 있는 경우만 표시 */}
+ {canManageEvaluations && hasSelection && (
<div className="flex items-center gap-1 border-l pl-2 ml-2">
{/* 협력업체 자료 요청 버튼 */}
{selectedStats.canRequestDocuments && (
@@ -221,31 +245,45 @@ export function PeriodicEvaluationsTableToolbarActions({
)}
</div>
)}
+
+ {/* 권한이 없는 경우 안내 메시지 (선택사항) */}
+ {!canManageEvaluations && hasSelection && (
+ <div className="flex items-center gap-1 border-l pl-2 ml-2">
+ <div className="text-xs text-muted-foreground px-2 py-1">
+ 평가 관리 권한이 필요합니다
+ </div>
+ </div>
+ )}
</div>
- {/* 협력업체 자료 요청 다이얼로그 */}
- <RequestDocumentsDialog
- open={requestDocumentsDialogOpen}
- onOpenChange={setRequestDocumentsDialogOpen}
- evaluations={selectedEvaluations}
- onSuccess={handleActionSuccess}
- />
-
- {/* 평가자 평가 요청 다이얼로그 */}
- <RequestEvaluationDialog
- open={requestEvaluationDialogOpen}
- onOpenChange={setRequestEvaluationDialogOpen}
- evaluations={selectedEvaluations}
- onSuccess={handleActionSuccess}
- />
-
- {/* 평가 확정 다이얼로그 */}
- <FinalizeEvaluationDialog
- open={finalizeEvaluationDialogOpen}
- onOpenChange={setFinalizeEvaluationDialogOpen}
- evaluations={reviewCompletedEvaluations}
- onSuccess={handleActionSuccess}
- />
+ {/* 다이얼로그들 - 권한이 있는 경우만 렌더링 */}
+ {canManageEvaluations && (
+ <>
+ {/* 협력업체 자료 요청 다이얼로그 */}
+ <RequestDocumentsDialog
+ open={requestDocumentsDialogOpen}
+ onOpenChange={setRequestDocumentsDialogOpen}
+ evaluations={selectedEvaluations}
+ onSuccess={handleActionSuccess}
+ />
+
+ {/* 평가자 평가 요청 다이얼로그 */}
+ <RequestEvaluationDialog
+ open={requestEvaluationDialogOpen}
+ onOpenChange={setRequestEvaluationDialogOpen}
+ evaluations={selectedEvaluations}
+ onSuccess={handleActionSuccess}
+ />
+
+ {/* 평가 확정 다이얼로그 */}
+ <FinalizeEvaluationDialog
+ open={finalizeEvaluationDialogOpen}
+ onOpenChange={setFinalizeEvaluationDialogOpen}
+ evaluations={reviewCompletedEvaluations}
+ onSuccess={handleActionSuccess}
+ />
+ </>
+ )}
</>
)
-}
+} \ No newline at end of file
diff --git a/lib/mail/mailer.ts b/lib/mail/mailer.ts
index 61201e99..0387d7dd 100644
--- a/lib/mail/mailer.ts
+++ b/lib/mail/mailer.ts
@@ -23,54 +23,96 @@ function registerHandlebarsHelpers() {
return i18next.t(key, options.hash || {});
});
- // eq 헬퍼 등록 - 두 값을 비교 (블록 헬퍼)
- handlebars.registerHelper('eq', function(a: any, b: any, options: any) {
- if (a === b) {
- return options.fn(this);
- } else {
- return options.inverse(this);
+ // eq 헬퍼 등록 - 인라인과 블록 헬퍼 둘 다 지원
+ handlebars.registerHelper('eq', function(a: any, b: any, options?: any) {
+ const result = a === b;
+
+ // 블록 헬퍼로 사용된 경우 (options가 있고 fn 함수가 있는 경우)
+ if (options && typeof options.fn === 'function') {
+ if (result) {
+ return options.fn(this);
+ } else {
+ return options.inverse(this);
+ }
}
+
+ // 인라인 헬퍼로 사용된 경우
+ return result;
});
- // 기타 유용한 헬퍼들
- handlebars.registerHelper('ne', function(a: any, b: any, options: any) {
- if (a !== b) {
- return options.fn(this);
- } else {
- return options.inverse(this);
+ // ne 헬퍼 - 인라인과 블록 헬퍼 둘 다 지원
+ handlebars.registerHelper('ne', function(a: any, b: any, options?: any) {
+ const result = a !== b;
+
+ if (options && typeof options.fn === 'function') {
+ if (result) {
+ return options.fn(this);
+ } else {
+ return options.inverse(this);
+ }
}
+
+ return result;
});
- handlebars.registerHelper('gt', function(a: any, b: any, options: any) {
- if (a > b) {
- return options.fn(this);
- } else {
- return options.inverse(this);
+ // gt 헬퍼 - 인라인과 블록 헬퍼 둘 다 지원
+ handlebars.registerHelper('gt', function(a: any, b: any, options?: any) {
+ const result = a > b;
+
+ if (options && typeof options.fn === 'function') {
+ if (result) {
+ return options.fn(this);
+ } else {
+ return options.inverse(this);
+ }
}
+
+ return result;
});
- handlebars.registerHelper('gte', function(a: any, b: any, options: any) {
- if (a >= b) {
- return options.fn(this);
- } else {
- return options.inverse(this);
+ // gte 헬퍼 - 인라인과 블록 헬퍼 둘 다 지원
+ handlebars.registerHelper('gte', function(a: any, b: any, options?: any) {
+ const result = a >= b;
+
+ if (options && typeof options.fn === 'function') {
+ if (result) {
+ return options.fn(this);
+ } else {
+ return options.inverse(this);
+ }
}
+
+ return result;
});
- handlebars.registerHelper('lt', function(a: any, b: any, options: any) {
- if (a < b) {
- return options.fn(this);
- } else {
- return options.inverse(this);
+ // lt 헬퍼 - 인라인과 블록 헬퍼 둘 다 지원
+ handlebars.registerHelper('lt', function(a: any, b: any, options?: any) {
+ const result = a < b;
+
+ if (options && typeof options.fn === 'function') {
+ if (result) {
+ return options.fn(this);
+ } else {
+ return options.inverse(this);
+ }
}
+
+ return result;
});
- handlebars.registerHelper('lte', function(a: any, b: any, options: any) {
- if (a <= b) {
- return options.fn(this);
- } else {
- return options.inverse(this);
+ // lte 헬퍼 - 인라인과 블록 헬퍼 둘 다 지원
+ handlebars.registerHelper('lte', function(a: any, b: any, options?: any) {
+ const result = a <= b;
+
+ if (options && typeof options.fn === 'function') {
+ if (result) {
+ return options.fn(this);
+ } else {
+ return options.inverse(this);
+ }
}
+
+ return result;
});
// and 헬퍼 - 모든 조건이 true인지 확인 (블록 헬퍼)
@@ -78,12 +120,17 @@ function registerHandlebarsHelpers() {
// 마지막 인자는 Handlebars 옵션
const options = args[args.length - 1];
const values = args.slice(0, -1);
+ const result = values.every(Boolean);
- if (values.every(Boolean)) {
- return options.fn(this);
- } else {
- return options.inverse(this);
+ if (typeof options.fn === 'function') {
+ if (result) {
+ return options.fn(this);
+ } else {
+ return options.inverse(this);
+ }
}
+
+ return result;
});
// or 헬퍼 - 하나라도 true인지 확인 (블록 헬퍼)
@@ -91,21 +138,32 @@ function registerHandlebarsHelpers() {
// 마지막 인자는 Handlebars 옵션
const options = args[args.length - 1];
const values = args.slice(0, -1);
+ const result = values.some(Boolean);
- if (values.some(Boolean)) {
- return options.fn(this);
- } else {
- return options.inverse(this);
+ if (typeof options.fn === 'function') {
+ if (result) {
+ return options.fn(this);
+ } else {
+ return options.inverse(this);
+ }
}
+
+ return result;
});
// not 헬퍼 - 값 반전 (블록 헬퍼)
handlebars.registerHelper('not', function(value: any, options: any) {
- if (!value) {
- return options.fn(this);
- } else {
- return options.inverse(this);
+ const result = !value;
+
+ if (typeof options.fn === 'function') {
+ if (result) {
+ return options.fn(this);
+ } else {
+ return options.inverse(this);
+ }
}
+
+ return result;
});
// formatDate 헬퍼 - 날짜 포맷팅
diff --git a/lib/mail/sendEmail.ts b/lib/mail/sendEmail.ts
index 3f88cb04..b4d2707a 100644
--- a/lib/mail/sendEmail.ts
+++ b/lib/mail/sendEmail.ts
@@ -29,14 +29,23 @@ export async function sendEmail({
// i18n 설정
const { t, i18n } = await useTranslation(context.language ?? "en", "translation");
- // t 헬퍼만 동적으로 등록 (이미 mailer.ts에서 기본 등록되어 있지만, 언어별로 다시 등록)
+ // t 헬퍼를 언어별로 동적으로 재등록 (기존 헬퍼 덮어쓰기)
+ // 헬퍼가 이미 등록되어 있더라도 안전하게 재등록
+ handlebars.unregisterHelper('t'); // 기존 헬퍼 제거
handlebars.registerHelper("t", function (key: string, options: any) {
// 여기서 i18n은 로컬 인스턴스
- return i18n.t(key, options.hash || {});
+ return i18n.t(key, options?.hash || {});
});
+ // 템플릿 데이터에 i18n 인스턴스와 번역 함수 추가
+ const templateData = {
+ ...context,
+ t: (key: string, options?: any) => i18n.t(key, options || {}),
+ i18n: i18n
+ };
+
// 템플릿 컴파일 및 HTML 생성
- const html = loadTemplate(template, context);
+ const html = loadTemplate(template, templateData);
// 이메일 발송
const result = await transporter.sendMail({
diff --git a/lib/mail/templates/letter-of-regret.hbs b/lib/mail/templates/letter-of-regret.hbs
new file mode 100644
index 00000000..13fab6fc
--- /dev/null
+++ b/lib/mail/templates/letter-of-regret.hbs
@@ -0,0 +1,190 @@
+<!DOCTYPE html>
+<html lang="{{#if (eq language 'ko')}}ko{{else}}en{{/if}}">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>{{#if (eq language 'ko')}}Letter of Regret{{else}}Letter of Regret{{/if}}</title>
+ <style>
+ body {
+ font-family: 'Arial', sans-serif;
+ line-height: 1.6;
+ color: #333;
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 20px;
+ background-color: #f9f9f9;
+ }
+ .container {
+ background-color: white;
+ padding: 40px;
+ border-radius: 8px;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
+ }
+ .header {
+ text-align: center;
+ border-bottom: 2px solid #e74c3c;
+ padding-bottom: 20px;
+ margin-bottom: 30px;
+ }
+ .company-logo {
+ font-size: 24px;
+ font-weight: bold;
+ color: #2c3e50;
+ margin-bottom: 10px;
+ }
+ .letter-title {
+ font-size: 20px;
+ color: #e74c3c;
+ font-weight: bold;
+ margin: 0;
+ }
+ .content {
+ margin-bottom: 30px;
+ }
+ .greeting {
+ font-weight: bold;
+ margin-bottom: 20px;
+ font-size: 16px;
+ }
+ .body-text {
+ margin-bottom: 15px;
+ text-align: justify;
+ }
+ .project-info {
+ background-color: #f8f9fa;
+ padding: 15px;
+ border-left: 4px solid #e74c3c;
+ margin: 20px 0;
+ }
+ .project-info strong {
+ color: #2c3e50;
+ }
+ .closing {
+ margin-top: 30px;
+ }
+ .signature {
+ margin-top: 40px;
+ border-top: 1px solid #ddd;
+ padding-top: 20px;
+ }
+ .signature-line {
+ margin-bottom: 5px;
+ }
+ .footer {
+ margin-top: 30px;
+ padding-top: 20px;
+ border-top: 1px solid #ddd;
+ font-size: 12px;
+ color: #666;
+ text-align: center;
+ }
+ .highlight {
+ color: #e74c3c;
+ font-weight: bold;
+ }
+ </style>
+</head>
+<body>
+ <div class="container">
+ <div class="header">
+ <div class="company-logo">{{companyName}}</div>
+ <h1 class="letter-title">
+ {{#if (eq language 'ko')}}
+ 협력 제안 결과 안내서 (Letter of Regret)
+ {{else}}
+ Letter of Regret
+ {{/if}}
+ </h1>
+ </div>
+
+ <div class="content">
+ <div class="greeting">
+ {{#if (eq language 'ko')}}
+ {{vendorName}} 귀하
+ {{else}}
+ Dear {{vendorName}},
+ {{/if}}
+ </div>
+
+ <div class="body-text">
+ {{#if (eq language 'ko')}}
+ 안녕하십니까. {{companyName}}입니다.
+ {{else}}
+ Greetings from {{companyName}}.
+ {{/if}}
+ </div>
+
+ <div class="body-text">
+ {{#if (eq language 'ko')}}
+ 먼저 저희 프로젝트에 관심을 가져주시고 귀중한 시간을 할애하여 RFQ에 응답해 주신 점에 대해 깊이 감사드립니다.
+ {{else}}
+ First, we would like to express our sincere gratitude for your interest in our project and for taking the time to respond to our RFQ.
+ {{/if}}
+ </div>
+
+ <div class="project-info">
+ <strong>
+ {{#if (eq language 'ko')}}프로젝트 정보{{else}}Project Information{{/if}}:
+ </strong><br>
+ <strong>RFQ {{#if (eq language 'ko')}}번호{{else}}Number{{/if}}:</strong> {{rfqCode}}<br>
+ <strong>{{#if (eq language 'ko')}}프로젝트명{{else}}Project Title{{/if}}:</strong> {{projectTitle}}<br>
+ <strong>{{#if (eq language 'ko')}}통지일{{else}}Notification Date{{/if}}:</strong> {{dateTime}}
+ </div>
+
+ <div class="body-text">
+ {{#if (eq language 'ko')}}
+ 신중한 검토와 평가를 거쳐 진행한 결과, 아쉽게도 이번 프로젝트에서는 다른 업체와 함께 진행하기로 결정하였음을 알려드립니다.
+ {{else}}
+ After careful review and evaluation, we regret to inform you that we have decided to proceed with another vendor for this project.
+ {{/if}}
+ </div>
+
+ <div class="body-text">
+ {{#if (eq language 'ko')}}
+ 이번 결정이 귀하의 기술력이나 역량에 대한 평가와는 무관함을 말씀드리며, 향후 다른 프로젝트에서 함께 할 수 있는 기회가 있기를 기대합니다.
+ {{else}}
+ Please note that this decision is not a reflection of your technical capabilities or competence, and we look forward to potential collaboration opportunities in future projects.
+ {{/if}}
+ </div>
+
+ <div class="body-text">
+ {{#if (eq language 'ko')}}
+ 다시 한번 저희 RFQ에 참여해 주신 것에 대해 진심으로 감사드리며, 앞으로도 지속적인 관심과 협력을 부탁드립니다.
+ {{else}}
+ Once again, we sincerely thank you for your participation in our RFQ process and look forward to your continued interest and cooperation.
+ {{/if}}
+ </div>
+
+ <div class="closing">
+ {{#if (eq language 'ko')}}
+ 감사합니다.
+ {{else}}
+ Thank you for your understanding.
+ {{/if}}
+ </div>
+ </div>
+
+ <div class="signature">
+ <div class="signature-line">
+ <strong>{{#if (eq language 'ko')}}발신{{else}}From{{/if}}:</strong> {{companyName}}
+ </div>
+ <div class="signature-line">
+ <strong>{{#if (eq language 'ko')}}구매팀{{else}}Procurement Department{{/if}}</strong>
+ </div>
+ <div class="signature-line">
+ <strong>{{#if (eq language 'ko')}}일자{{else}}Date{{/if}}:</strong> {{dateTime}}
+ </div>
+ </div>
+
+ <div class="footer">
+ <p>
+ {{#if (eq language 'ko')}}
+ 본 메일은 자동으로 발송된 메일입니다. 문의사항이 있으시면 담당자에게 직접 연락해 주시기 바랍니다.
+ {{else}}
+ This is an automatically generated email. For any inquiries, please contact the relevant department directly.
+ {{/if}}
+ </p>
+ </div>
+ </div>
+</body>
+</html> \ No newline at end of file
diff --git a/lib/users/service.ts b/lib/users/service.ts
index 9671abfb..e32d450e 100644
--- a/lib/users/service.ts
+++ b/lib/users/service.ts
@@ -4,7 +4,7 @@
import { Otp } from '@/types/user';
import { getAllUsers, createUser, getUserById, updateUser, deleteUser, getUserByEmail, createOtp,getOtpByEmailAndToken, updateOtp, findOtpByEmail ,getOtpByEmailAndCode, findAllRoles, getRoleAssignedUsers} from './repository';
import logger from '@/lib/logger';
-import { Role, userRoles, users, userView, type User } from '@/db/schema/users';
+import { Role, roles, userRoles, users, userView, type User } from '@/db/schema/users';
import { saveDocument } from '../storage';
import { GetSimpleUsersSchema, GetUsersSchema } from '../admin-users/validations';
import { revalidatePath, revalidateTag, unstable_cache, unstable_noStore } from 'next/cache';
@@ -748,4 +748,22 @@ export async function getPendingUsers() {
data: []
}
}
-} \ No newline at end of file
+}
+
+// ✅ Role 정보 조회 함수 추가
+export async function getUserRoles(userId: number): Promise<string[]> {
+ try {
+ const userWithRoles = await db
+ .select({
+ roleName: roles.name,
+ })
+ .from(userRoles)
+ .innerJoin(roles, eq(userRoles.roleId, roles.id))
+ .where(eq(userRoles.userId, userId))
+
+ return userWithRoles.map(r => r.roleName)
+ } catch (error) {
+ console.error('Error fetching user roles:', error)
+ return []
+ }
+}