From 53fce4bf4ac8310bd02d77e564f28d3c12228bd1 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 18 Sep 2025 02:56:27 +0000 Subject: (최겸) 구매 입찰 히스토리 개발 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bid-history-table-columns.tsx | 327 +++++++++++++++++++++ .../bid-history-table/bid-history-table.tsx | 208 +++++++++++++ lib/vendors/service.ts | 128 +++++++- lib/vendors/validations.ts | 27 +- 4 files changed, 688 insertions(+), 2 deletions(-) create mode 100644 lib/vendors/bid-history-table/bid-history-table-columns.tsx create mode 100644 lib/vendors/bid-history-table/bid-history-table.tsx (limited to 'lib') diff --git a/lib/vendors/bid-history-table/bid-history-table-columns.tsx b/lib/vendors/bid-history-table/bid-history-table-columns.tsx new file mode 100644 index 00000000..b235917f --- /dev/null +++ b/lib/vendors/bid-history-table/bid-history-table-columns.tsx @@ -0,0 +1,327 @@ +"use client"; + +import * as React from "react"; +import { type DataTableRowAction } from "@/types/table"; +import { type ColumnDef } from "@tanstack/react-table"; +import { Ellipsis } 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, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +import { BidHistoryRow } from "./bid-history-table"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"; +import { bidHistoryColumnsConfig } from "@/config/bidHistoryColumnsConfig"; + +interface GetColumnsProps { + setRowAction: React.Dispatch | null>>; + onViewDetails: (biddingId: number) => void; +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction, onViewDetails }: GetColumnsProps): ColumnDef[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef = { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }; + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + return ( + + + + + + onViewDetails(row.original.biddingId)} + > + 입찰상세 + + + + ); + }, + size: 40, + }; + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들 + // ---------------------------------------------------------------- + const basicColumns: ColumnDef[] = bidHistoryColumnsConfig.map((cfg) => { + const column: ColumnDef = { + accessorKey: cfg.id, + header: ({ column }) => ( + + ), + size: cfg.size, + }; + + // 계약구분 표시 + if (cfg.id === "contractType") { + column.cell = ({ row }) => { + const contractType = row.original.contractType; + if (!contractType) return null; + + const contractTypeLabels = { + unit_price: "단가", + general: "일반", + sale: "매각" + }; + + return ( + + {contractTypeLabels[contractType] || contractType} + + ); + }; + } + + // 입찰유형 표시 + if (cfg.id === "biddingType") { + column.cell = ({ row }) => { + const biddingType = row.original.biddingType; + if (!biddingType) return null; + + const biddingTypeLabels = { + equipment: "기자재", + construction: "공사", + service: "용역", + lease: "임차", + steel_stock: "형강스톡", + piping: "배관", + transport: "운송", + waste: "폐기물", + sale: "매각" + }; + + return ( + + {biddingTypeLabels[biddingType] || biddingType} + + ); + }; + } + + // 입찰상태 표시 + if (cfg.id === "biddingStatus") { + column.cell = ({ row }) => { + const biddingStatus = row.original.biddingStatus; + if (!biddingStatus) return null; + + const statusLabels = { + bidding_generated: "입찰생성", + request_for_quotation: "사전견적요청", + received_quotation: "사전견적접수", + set_target_price: "내정가산정", + bidding_opened: "입찰공고", + bidding_closed: "입찰마감", + evaluation_of_bidding: "입찰평가중", + bidding_disposal: "유찰", + vendor_selected: "업체선정" + }; + + const statusColors = { + bidding_generated: "secondary", + request_for_quotation: "outline", + received_quotation: "outline", + set_target_price: "outline", + bidding_opened: "default", + bidding_closed: "destructive", + evaluation_of_bidding: "secondary", + bidding_disposal: "destructive", + vendor_selected: "default" + }; + + return ( + + {statusLabels[biddingStatus] || biddingStatus} + + ); + }; + } + + // 입찰번호 표시 (Rev. 포함) + if (cfg.id === "biddingNumber") { + column.cell = ({ row }) => { + const biddingNumber = row.original.biddingNumber; + const revision = row.original.revision; + if (!biddingNumber) return null; + + return ( +
+ {biddingNumber} +
+ ); + }; + } + + // 품목명 표시 (자재그룹 포함) + if (cfg.id === "itemName") { + column.cell = ({ row }) => { + const itemName = row.original.itemName; + const materialGroup = row.original.materialGroup; + const materialGroupName = row.original.materialGroupName; + + if (!itemName) return null; + + const displayText = materialGroup && materialGroupName + ? `${itemName} (${materialGroup}/${materialGroupName})` + : itemName; + + return ( + + +
+ {displayText} +
+
+ + {displayText} + +
+ ); + }; + } + + // 입찰명 표시 + if (cfg.id === "biddingTitle") { + column.cell = ({ row }) => { + const title = row.original.biddingTitle; + if (!title) return null; + + return ( + + +
+ {title} +
+
+ + {title} + +
+ ); + }; + } + + // 계약정보 표시 (PO/계약정보) + if (cfg.id === "contractNumber") { + column.cell = ({ row }) => { + const poNumber = row.original.poNumber; + const contractNumber = row.original.contractNumber; + + if (!poNumber && !contractNumber) return null; + + const displayText = poNumber && contractNumber + ? `${poNumber}/${contractNumber}` + : poNumber || contractNumber; + + return ( +
+ {displayText} +
+ ); + }; + } + + // 날짜 필드들 표시 + if (cfg.id === "biddingRequestDate" || cfg.id === "biddingDeadline") { + column.cell = ({ row }) => ( +
+ {formatDate(row.getValue(cfg.id), "KR")} +
+ ); + } + + // 금액 필드들 표시 + if (cfg.id === "finalBidPrice" || cfg.id === "expectedAmount" || cfg.id === "preQuotePrice") { + column.cell = ({ row }) => { + const amount = row.getValue(cfg.id) as string; + const currency = row.original.currency; + if (!amount || !currency) return null; + + const numericAmount = parseFloat(amount); + if (isNaN(numericAmount)) return null; + + return ( +
+ {`${currency} ${numericAmount.toLocaleString()}`} +
+ ); + }; + } + + // 발주비율 표시 + if (cfg.id === "awardRatio") { + column.cell = ({ row }) => { + const ratio = row.original.awardRatio; + if (!ratio) return null; + + const numericRatio = parseFloat(ratio); + if (isNaN(numericRatio)) return null; + + return ( +
+ {`${numericRatio}%`} +
+ ); + }; + } + + return column; + }); + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열 + // ---------------------------------------------------------------- + return [ + selectColumn, + ...basicColumns, + actionsColumn, + ]; +} diff --git a/lib/vendors/bid-history-table/bid-history-table.tsx b/lib/vendors/bid-history-table/bid-history-table.tsx new file mode 100644 index 00000000..ec810429 --- /dev/null +++ b/lib/vendors/bid-history-table/bid-history-table.tsx @@ -0,0 +1,208 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} 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 { getColumns } from "./bid-history-table-columns"; +import { getBidHistory } from "../service"; +import { TooltipProvider } from "@/components/ui/tooltip"; + +export interface BidHistoryRow { + id: number; + biddingId: number; + biddingNumber: string | null; + revision: number | null; + contractType: "unit_price" | "general" | "sale"; + biddingType: "equipment" | "construction" | "service" | "lease" | "steel_stock" | "piping" | "transport" | "waste" | "sale"; + biddingStatus: "bidding_generated" | "request_for_quotation" | "received_quotation" | "set_target_price" | "bidding_opened" | "bidding_closed" | "evaluation_of_bidding" | "bidding_disposal" | "vendor_selected"; + projectCode: string | null; + projectName: string | null; + itemName: string | null; + materialGroup: string | null; + materialGroupName: string | null; + biddingTitle: string | null; + poNumber: string | null; + contractNumber: string | null; + biddingRequestDate: Date | null; + biddingDeadline: Date | null; + biddingManager: string | null; + currency: string | null; + finalBidPrice: string | null; + expectedAmount: string | null; + awardRatio: string | null; + preQuotePrice: string | null; + createdAt: Date; + updatedAt: Date; +} + +interface BidHistoryTableProps { + promises: Promise< + [ + Awaited>, + ] + >; + lng: string; +} + +export function VendorBidHistoryTable({ promises, lng }: BidHistoryTableProps) { + const router = useRouter(); + + // SSR을 위해 React.use() 사용 - 서버에서 렌더링된 데이터를 클라이언트에서 사용 + const [{ data = [], pageCount = 0 }] = React.use(promises); + + const [, setRowAction] = React.useState | null>(null); + + const handleViewDetails = React.useCallback((biddingId: number) => { + router.push(`/${lng}/evcp/bid/${biddingId}/detail`); + }, [router, lng]); + + const columns = React.useMemo(() => getColumns({ + setRowAction, + onViewDetails: handleViewDetails, + }), [setRowAction, handleViewDetails]); + + const filterFields: DataTableFilterField[] = [ + { + id: "biddingNumber", + label: "입찰번호", + placeholder: "입찰번호로 검색...", + }, + { + id: "contractType", + label: "계약구분", + options: [ + { label: "단가", value: "unit_price" }, + { label: "일반", value: "general" }, + { label: "매각", value: "sale" } + ], + }, + { + id: "biddingType", + label: "입찰유형", + options: [ + { label: "기자재", value: "equipment" }, + { label: "공사", value: "construction" }, + { label: "용역", value: "service" }, + { label: "임차", value: "lease" }, + { label: "형강스톡", value: "steel_stock" }, + { label: "배관", value: "piping" }, + { label: "운송", value: "transport" }, + { label: "폐기물", value: "waste" }, + { label: "매각", value: "sale" } + ], + }, + { + id: "biddingStatus", + label: "입찰상태", + options: [ + { label: "입찰생성", value: "bidding_generated" }, + { label: "사전견적요청", value: "request_for_quotation" }, + { label: "사전견적접수", value: "received_quotation" }, + { label: "내정가산정", value: "set_target_price" }, + { label: "입찰공고", value: "bidding_opened" }, + { label: "입찰마감", value: "bidding_closed" }, + { label: "입찰평가중", value: "evaluation_of_bidding" }, + { label: "유찰", value: "bidding_disposal" }, + { label: "업체선정", value: "vendor_selected" } + ], + }, + { + id: "projectName", + label: "프로젝트명", + placeholder: "프로젝트명으로 검색...", + } + ]; + + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { id: "biddingNumber", label: "입찰번호", type: "text" }, + { id: "projectCode", label: "프로젝트코드", type: "text" }, + { id: "projectName", label: "프로젝트명", type: "text" }, + { + id: "contractType", + label: "계약구분", + type: "multi-select", + options: [ + { label: "단가", value: "unit_price" }, + { label: "일반", value: "general" }, + { label: "매각", value: "sale" } + ], + }, + { + id: "biddingType", + label: "입찰유형", + type: "multi-select", + options: [ + { label: "기자재", value: "equipment" }, + { label: "공사", value: "construction" }, + { label: "용역", value: "service" }, + { label: "임차", value: "lease" }, + { label: "형강스톡", value: "steel_stock" }, + { label: "배관", value: "piping" }, + { label: "운송", value: "transport" }, + { label: "폐기물", value: "waste" }, + { label: "매각", value: "sale" } + ], + }, + { + id: "biddingStatus", + label: "입찰상태", + type: "multi-select", + options: [ + { label: "입찰생성", value: "bidding_generated" }, + { label: "사전견적요청", value: "request_for_quotation" }, + { label: "사전견적접수", value: "received_quotation" }, + { label: "내정가산정", value: "set_target_price" }, + { label: "입찰공고", value: "bidding_opened" }, + { label: "입찰마감", value: "bidding_closed" }, + { label: "입찰평가중", value: "evaluation_of_bidding" }, + { label: "유찰", value: "bidding_disposal" }, + { label: "업체선정", value: "vendor_selected" } + ], + }, + { id: "biddingManager", label: "입찰담당자", type: "text" }, + { id: "biddingRequestDate", label: "입찰요청일", type: "date" }, + { id: "biddingDeadline", label: "입찰마감일", type: "date" }, + { id: "createdAt", label: "생성일", type: "date" }, + ]; + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow, index) => originalRow?.id ? String(originalRow.id) : String(index), + shallow: false, + clearOnDefault: true, + }); + + return ( + <> + + + + + + + + ); +} diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts index 9a37f5d7..9362a88c 100644 --- a/lib/vendors/service.ts +++ b/lib/vendors/service.ts @@ -45,6 +45,7 @@ import type { CreateVendorItemSchema, GetRfqHistorySchema, GetVendorMaterialsSchema, + GetBidHistorySchema, } from "./validations"; import { asc, desc, ilike, inArray, and, or, eq, isNull, sql } from "drizzle-orm"; @@ -55,7 +56,7 @@ import { items, materials } from "@/db/schema/items"; import { mfaTokens, roles, userRoles, users } from "@/db/schema/users"; import { getServerSession } from "next-auth"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; -import { contractsDetailView, projects, vendorPQSubmissions, vendorsLogs } from "@/db/schema"; +import { contractsDetailView, projects, vendorPQSubmissions, vendorsLogs, biddingCompanies, biddings } from "@/db/schema"; import { deleteFile, saveFile, saveBuffer } from "../file-stroage"; import { basicContractTemplates } from "@/db/schema/basicContractDocumnet"; import { basicContract } from "@/db/schema/basicContractDocumnet"; @@ -3213,3 +3214,128 @@ export async function getVendorByTaxId(taxId: string) { }; } } +export async function getBidHistory(input: GetBidHistorySchema, vendorId: number) { + try { + const offset = (input.page - 1) * input.perPage; + + // 기본 where 조건 (vendorId) + const vendorWhere = eq(biddingCompanies.companyId, vendorId); + + // 고급 필터링 + const advancedWhere = filterColumns({ + table: biddings, + filters: input.filters, + joinOperator: input.joinOperator, + joinedTables: { + biddingCompanies, + projects, + }, + customColumnMapping: { + biddingManager: biddingCompanies.contactPerson, + projectCode: projects.code, + }, + }); + + // 글로벌 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(biddings.biddingNumber, s), + ilike(biddings.projectName, s), + ilike(biddings.itemName, s), + ilike(biddings.title, s) + ); + } + + const finalWhere = and( + advancedWhere, + globalWhere, + vendorWhere + ); + + // 정렬 조건 - 동적 매핑 (하드코딩 최소화) + // biddings, biddingCompanies, projects 등에서 정렬 가능한 컬럼을 동적으로 매핑 + const sortFieldMap: Record = { + biddingNumber: biddings.biddingNumber, + revision: biddings.revision, + contractType: biddings.contractType, + biddingType: biddings.biddingType, + biddingStatus: biddings.status, + projectName: biddings.projectName, + itemName: biddings.itemName, + biddingTitle: biddings.title, + biddingRequestDate: biddings.submissionStartDate, + biddingDeadline: biddings.submissionEndDate, + biddingManager: biddingCompanies.contactPerson, + projectCode: projects.code, + createdAt: biddings.createdAt, + }; + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => { + const field = sortFieldMap[item.id] ?? biddings.createdAt; + return item.desc ? desc(field) : asc(field); + }) + : [desc(biddings.createdAt)]; + + // 트랜잭션으로 데이터 조회 + const { data, total } = await db.transaction(async (tx) => { + + // 데이터 조회 (biddingCompanies와 biddings 조인) + const bidHistoryData = await tx + .select({ + id: biddingCompanies.id, + biddingId: biddings.id, + biddingNumber: biddings.biddingNumber, + revision: biddings.revision, + contractType: biddings.contractType, + biddingType: biddings.biddingType, + biddingStatus: biddings.status, + projectCode: projects.code, + projectName: biddings.projectName, + itemName: biddings.itemName, + // materialGroup: sql`null`, + // materialGroupName: sql`null`, + biddingTitle: biddings.title, + poNumber: sql`null`, + contractNumber: sql`null`, + biddingRequestDate: biddings.submissionStartDate, + biddingDeadline: biddings.submissionEndDate, + biddingManager: biddingCompanies.contactPerson, + currency: biddings.currency, + finalBidPrice: biddingCompanies.finalQuoteAmount, + expectedAmount: biddings.targetPrice, + awardRatio: biddingCompanies.awardRatio, + preQuotePrice: biddingCompanies.preQuoteAmount, + createdAt: biddings.createdAt, + updatedAt: biddings.updatedAt, + }) + .from(biddingCompanies) + .innerJoin(biddings, eq(biddingCompanies.biddingId, biddings.id)) + .leftJoin(projects, eq(biddings.projectId, projects.id)) + .where(finalWhere) + .orderBy(...orderBy) + .limit(input.perPage) + .offset(offset); + + + const total = await tx + .select({ count: sql`count(*)` }) + .from(biddingCompanies) + .innerJoin(biddings, eq(biddingCompanies.biddingId, biddings.id)) + .leftJoin(projects, eq(biddings.projectId, projects.id)) + .where(finalWhere); + + const totalCount = total[0]?.count ?? 0; + return { data: bidHistoryData, total: totalCount }; + }); + + const pageCount = Math.ceil(total / input.perPage); + return { data, pageCount }; + } catch (err) { + console.error("Error fetching bid history:", err); + return { data: [], pageCount: 0 }; + } +} \ No newline at end of file diff --git a/lib/vendors/validations.ts b/lib/vendors/validations.ts index d1902866..44237963 100644 --- a/lib/vendors/validations.ts +++ b/lib/vendors/validations.ts @@ -11,7 +11,7 @@ import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" import { VendorContact, VendorItemsView, VendorMaterialsView, vendors, VendorWithType } from "@/db/schema/vendors"; import { rfqs } from "@/db/schema/rfq" import { countryDialCodes } from '@/components/common/country-dial-codes'; - +import { biddings } from "@/db/schema/bidding"; export const searchParamsCache = createSearchParamsCache({ @@ -537,3 +537,28 @@ export const searchParamsMaterialCache = createSearchParamsCache({ }); export type GetVendorMaterialsSchema = Awaited> + +export const searchParamsBidHistoryCache = createSearchParamsCache({ + // 공통 플래그 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + + // 페이징 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 + sort: getSortingStateParser().withDefault([ + { id: "createdAt", desc: true }, + ]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 검색 키워드 + search: parseAsString.withDefault(""), +}); + +export type GetBidHistorySchema = Awaited> -- cgit v1.2.3