diff options
Diffstat (limited to 'lib/bidding/list/biddings-table-columns.tsx')
| -rw-r--r-- | lib/bidding/list/biddings-table-columns.tsx | 578 |
1 files changed, 578 insertions, 0 deletions
diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx new file mode 100644 index 00000000..34fc574e --- /dev/null +++ b/lib/bidding/list/biddings-table-columns.tsx @@ -0,0 +1,578 @@ +"use client" + +import * as React from "react" +import { type ColumnDef } from "@tanstack/react-table" +import { Checkbox } from "@/components/ui/checkbox" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Eye, Edit, MoreHorizontal, FileText, Users, Calendar, + Building, Package, DollarSign, Clock, CheckCircle, XCircle +} from "lucide-react" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { BiddingListItem } from "@/db/schema" +import { DataTableRowAction } from "@/types/table" +import { + biddingStatusLabels, + contractTypeLabels, + biddingTypeLabels, + awardCountLabels +} from "@/db/schema" +import { formatDate } from "@/lib/utils" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BiddingListItem> | null>> +} + +// 상태별 배지 색상 +const getStatusBadgeVariant = (status: string) => { + switch (status) { + case 'bidding_generated': + return 'outline' + case 'request_for_quotation': + case 'received_quotation': + return 'secondary' + case 'set_target_price': + case 'bidding_opened': + return 'default' + case 'bidding_closed': + case 'evaluation_of_bidding': + return 'default' + case 'vendor_selected': + return 'default' + case 'bidding_disposal': + return 'destructive' + default: + return 'outline' + } +} + +// 금액 포맷팅 +const formatCurrency = (amount: string | number | null, currency = 'KRW') => { + if (!amount) return '-' + + const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount + if (isNaN(numAmount)) return '-' + + return new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: currency, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(numAmount) +} + + + +export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef<BiddingListItem>[] { + return [ + // ═══════════════════════════════════════════════════════════════ + // 선택 및 기본 정보 + // ═══════════════════════════════════════════════════════════════ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")} + onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} + aria-label="select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(v) => row.toggleSelected(!!v)} + aria-label="select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + + // ░░░ 입찰 No. ░░░ + { + accessorKey: "biddingNumber", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰 No." />, + cell: ({ row }) => ( + <div className="font-mono text-sm"> + {row.original.biddingNumber} + {row.original.revision > 0 && ( + <span className="ml-1 text-xs text-muted-foreground"> + Rev.{row.original.revision} + </span> + )} + </div> + ), + size: 120, + meta: { excelHeader: "입찰 No." }, + }, + + // ░░░ 입찰상태 ░░░ + { + accessorKey: "status", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰상태" />, + cell: ({ row }) => ( + <Badge variant={getStatusBadgeVariant(row.original.status)}> + {biddingStatusLabels[row.original.status]} + </Badge> + ), + size: 120, + meta: { excelHeader: "입찰상태" }, + }, + + // ░░░ 사전견적 ░░░ + { + id: "preQuote", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="사전견적" />, + cell: ({ row }) => { + const hasPreQuote = ['request_for_quotation', 'received_quotation'].includes(row.original.status) + const preQuoteDate = row.original.preQuoteDate + + return hasPreQuote ? ( + <div className="flex items-center gap-1"> + <CheckCircle className="h-4 w-4 text-green-600" /> + {preQuoteDate && ( + <span className="text-xs text-muted-foreground"> + {formatDate(preQuoteDate, "KR")} + </span> + )} + </div> + ) : ( + <XCircle className="h-4 w-4 text-gray-400" /> + ) + }, + size: 90, + meta: { excelHeader: "사전견적" }, + }, + + // ░░░ 입찰담당자 ░░░ + { + accessorKey: "managerName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰담당자" />, + cell: ({ row }) => ( + <div className="truncate max-w-[100px]" title={row.original.managerName || ''}> + {row.original.managerName || '-'} + </div> + ), + size: 100, + meta: { excelHeader: "입찰담당자" }, + }, + + // ═══════════════════════════════════════════════════════════════ + // 프로젝트 정보 + // ═══════════════════════════════════════════════════════════════ + { + header: "프로젝트 정보", + columns: [ + { + accessorKey: "projectName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트명" />, + cell: ({ row }) => ( + <div className="truncate max-w-[150px]" title={row.original.projectName || ''}> + {row.original.projectName || '-'} + </div> + ), + size: 150, + meta: { excelHeader: "프로젝트명" }, + }, + + { + accessorKey: "itemName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="품목명" />, + cell: ({ row }) => ( + <div className="truncate max-w-[150px]" title={row.original.itemName || ''}> + {row.original.itemName || '-'} + </div> + ), + size: 150, + meta: { excelHeader: "품목명" }, + }, + + { + accessorKey: "title", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰명" />, + cell: ({ row }) => ( + <div className="truncate max-w-[200px]" title={row.original.title}> + <Button + variant="link" + className="p-0 h-auto text-left justify-start" + onClick={() => setRowAction({ row, type: "view" })} + > + {row.original.title} + </Button> + </div> + ), + size: 200, + meta: { excelHeader: "입찰명" }, + }, + ] + }, + + // ═══════════════════════════════════════════════════════════════ + // 계약 정보 + // ═══════════════════════════════════════════════════════════════ + { + header: "계약 정보", + columns: [ + { + accessorKey: "contractType", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약구분" />, + cell: ({ row }) => ( + <Badge variant="outline"> + {contractTypeLabels[row.original.contractType]} + </Badge> + ), + size: 100, + meta: { excelHeader: "계약구분" }, + }, + + { + accessorKey: "biddingType", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰유형" />, + cell: ({ row }) => ( + <Badge variant="secondary"> + {biddingTypeLabels[row.original.biddingType]} + </Badge> + ), + size: 100, + meta: { excelHeader: "입찰유형" }, + }, + + { + accessorKey: "awardCount", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="낙찰수" />, + cell: ({ row }) => ( + <Badge variant="outline"> + {awardCountLabels[row.original.awardCount]} + </Badge> + ), + size: 80, + meta: { excelHeader: "낙찰수" }, + }, + + { + accessorKey: "contractPeriod", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약기간" />, + cell: ({ row }) => ( + <span className="text-sm">{row.original.contractPeriod || '-'}</span> + ), + size: 100, + meta: { excelHeader: "계약기간" }, + }, + ] + }, + + // ═══════════════════════════════════════════════════════════════ + // 일정 정보 + // ═══════════════════════════════════════════════════════════════ + { + header: "일정 정보", + columns: [ + { + id: "submissionPeriod", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰서제출기간" />, + cell: ({ row }) => { + const startDate = row.original.submissionStartDate + const endDate = row.original.submissionEndDate + + if (!startDate || !endDate) return <span className="text-muted-foreground">-</span> + + const now = new Date() + const isActive = now >= new Date(startDate) && now <= new Date(endDate) + const isPast = now > new Date(endDate) + + return ( + <div className="text-xs"> + <div className={`${isActive ? 'text-green-600 font-medium' : isPast ? 'text-red-600' : 'text-gray-600'}`}> + {formatDate(startDate, "KR")} ~ {formatDate(endDate, "KR")} + </div> + {isActive && ( + <Badge variant="default" className="text-xs mt-1">진행중</Badge> + )} + </div> + ) + }, + size: 140, + meta: { excelHeader: "입찰서제출기간" }, + }, + + { + accessorKey: "hasSpecificationMeeting", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="사양설명회" />, + cell: ({ row }) => { + const hasMeeting = row.original.hasSpecificationMeeting + + return ( + <Button + variant="ghost" + size="sm" + className={`p-1 h-auto ${hasMeeting ? 'text-blue-600' : 'text-gray-400'}`} + onClick={() => hasMeeting && setRowAction({ row, type: "specification_meeting" })} + disabled={!hasMeeting} + > + {hasMeeting ? 'Yes' : 'No'} + </Button> + ) + }, + size: 100, + meta: { excelHeader: "사양설명회" }, + }, + ] + }, + + // ═══════════════════════════════════════════════════════════════ + // 가격 정보 + // ═══════════════════════════════════════════════════════════════ + { + header: "가격 정보", + columns: [ + { + accessorKey: "currency", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="통화" />, + cell: ({ row }) => ( + <span className="font-mono text-sm">{row.original.currency}</span> + ), + size: 60, + meta: { excelHeader: "통화" }, + }, + + { + accessorKey: "budget", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="예산" />, + cell: ({ row }) => ( + <span className="text-sm font-medium"> + {formatCurrency(row.original.budget, row.original.currency)} + </span> + ), + size: 120, + meta: { excelHeader: "예산" }, + }, + + { + accessorKey: "targetPrice", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="내정가" />, + cell: ({ row }) => ( + <span className="text-sm font-medium text-orange-600"> + {formatCurrency(row.original.targetPrice, row.original.currency)} + </span> + ), + size: 120, + meta: { excelHeader: "내정가" }, + }, + + { + accessorKey: "finalBidPrice", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종입찰가" />, + cell: ({ row }) => ( + <span className="text-sm font-medium text-green-600"> + {formatCurrency(row.original.finalBidPrice, row.original.currency)} + </span> + ), + size: 120, + meta: { excelHeader: "최종입찰가" }, + }, + ] + }, + + // ═══════════════════════════════════════════════════════════════ + // 참여 현황 + // ═══════════════════════════════════════════════════════════════ + { + header: "참여 현황", + columns: [ + { + id: "participantExpected", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여예정" />, + cell: ({ row }) => ( + <Badge variant="outline" className="font-mono"> + {row.original.participantStats.expected} + </Badge> + ), + size: 80, + meta: { excelHeader: "참여예정" }, + }, + + { + id: "participantParticipated", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여" />, + cell: ({ row }) => ( + <Badge variant="default" className="font-mono"> + {row.original.participantStats.participated} + </Badge> + ), + size: 60, + meta: { excelHeader: "참여" }, + }, + + { + id: "participantDeclined", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="포기" />, + cell: ({ row }) => ( + <Badge variant="destructive" className="font-mono"> + {row.original.participantStats.declined} + </Badge> + ), + size: 60, + meta: { excelHeader: "포기" }, + }, + ] + }, + + // ═══════════════════════════════════════════════════════════════ + // PR 정보 + // ═══════════════════════════════════════════════════════════════ + { + header: "PR 정보", + columns: [ + { + accessorKey: "prNumber", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PR No." />, + cell: ({ row }) => ( + <span className="font-mono text-sm">{row.original.prNumber || '-'}</span> + ), + size: 100, + meta: { excelHeader: "PR No." }, + }, + + { + accessorKey: "hasPrDocument", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PR 문서" />, + cell: ({ row }) => { + const hasPrDoc = row.original.hasPrDocument + + return ( + <Button + variant="ghost" + size="sm" + className={`p-1 h-auto ${hasPrDoc ? 'text-blue-600' : 'text-gray-400'}`} + onClick={() => hasPrDoc && setRowAction({ row, type: "pr_documents" })} + disabled={!hasPrDoc} + > + {hasPrDoc ? 'Yes' : 'No'} + </Button> + ) + }, + size: 80, + meta: { excelHeader: "PR 문서" }, + }, + ] + }, + + // ═══════════════════════════════════════════════════════════════ + // 메타 정보 + // ═══════════════════════════════════════════════════════════════ + { + header: "메타 정보", + columns: [ + { + accessorKey: "preQuoteDate", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="사전견적일" />, + cell: ({ row }) => ( + <span className="text-sm">{formatDate(row.original.preQuoteDate, "KR")}</span> + ), + size: 90, + meta: { excelHeader: "사전견적일" }, + }, + + { + accessorKey: "biddingRegistrationDate", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰등록일" />, + cell: ({ row }) => ( + <span className="text-sm">{formatDate(row.original.biddingRegistrationDate , "KR")}</span> + ), + size: 100, + meta: { excelHeader: "입찰등록일" }, + }, + + { + accessorKey: "updatedAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정일" />, + cell: ({ row }) => ( + <span className="text-sm">{formatDate(row.original.updatedAt, "KR")}</span> + ), + size: 100, + meta: { excelHeader: "최종수정일" }, + }, + + { + accessorKey: "updatedBy", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정자" />, + cell: ({ row }) => ( + <span className="text-sm">{row.original.updatedBy || '-'}</span> + ), + size: 100, + meta: { excelHeader: "최종수정자" }, + }, + ] + }, + + // ░░░ 비고 ░░░ + { + accessorKey: "remarks", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="비고" />, + cell: ({ row }) => ( + <div className="truncate max-w-[150px]" title={row.original.remarks || ''}> + {row.original.remarks || '-'} + </div> + ), + size: 150, + meta: { excelHeader: "비고" }, + }, + + // ═══════════════════════════════════════════════════════════════ + // 액션 + // ═══════════════════════════════════════════════════════════════ + { + id: "actions", + header: "작업", + cell: ({ row }) => ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="h-8 w-8 p-0"> + <span className="sr-only">메뉴 열기</span> + <MoreHorizontal className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onClick={() => setRowAction({ row, type: "view" })}> + <Eye className="mr-2 h-4 w-4" /> + 상세보기 + </DropdownMenuItem> + <DropdownMenuItem onClick={() => setRowAction({ row, type: "edit" })}> + <Edit className="mr-2 h-4 w-4" /> + 수정 + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem onClick={() => setRowAction({ row, type: "copy" })}> + <Package className="mr-2 h-4 w-4" /> + 복사 생성 + </DropdownMenuItem> + <DropdownMenuItem onClick={() => setRowAction({ row, type: "manage_companies" })}> + <Users className="mr-2 h-4 w-4" /> + 참여업체 관리 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ), + size: 50, + enableSorting: false, + enableHiding: false, + }, + ] +}
\ No newline at end of file |
