diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/b-rfq/final/final-rfq-detail-columns.tsx | 589 | ||||
| -rw-r--r-- | lib/b-rfq/final/final-rfq-detail-table.tsx | 297 | ||||
| -rw-r--r-- | lib/b-rfq/final/final-rfq-detail-toolbar-actions.tsx | 201 | ||||
| -rw-r--r-- | lib/b-rfq/final/update-final-rfq-sheet.tsx | 70 | ||||
| -rw-r--r-- | lib/b-rfq/initial/initial-rfq-detail-columns.tsx | 4 | ||||
| -rw-r--r-- | lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx | 77 | ||||
| -rw-r--r-- | lib/b-rfq/initial/short-list-confirm-dialog.tsx | 269 | ||||
| -rw-r--r-- | lib/b-rfq/service.ts | 2025 | ||||
| -rw-r--r-- | lib/b-rfq/validations.ts | 45 | ||||
| -rw-r--r-- | lib/evaluation/table/evaluation-table.tsx | 1 | ||||
| -rw-r--r-- | lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx | 96 | ||||
| -rw-r--r-- | lib/mail/mailer.ts | 146 | ||||
| -rw-r--r-- | lib/mail/sendEmail.ts | 15 | ||||
| -rw-r--r-- | lib/mail/templates/letter-of-regret.hbs | 190 | ||||
| -rw-r--r-- | lib/users/service.ts | 22 |
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 [] + } +} |
