diff options
Diffstat (limited to 'lib/bidding/vendor')
7 files changed, 468 insertions, 60 deletions
diff --git a/lib/bidding/vendor/components/pr-items-pricing-table.tsx b/lib/bidding/vendor/components/pr-items-pricing-table.tsx index 7dd8384e..6910e360 100644 --- a/lib/bidding/vendor/components/pr-items-pricing-table.tsx +++ b/lib/bidding/vendor/components/pr-items-pricing-table.tsx @@ -4,7 +4,17 @@ import * as React from 'react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' - +import { Button } from '@/components/ui/button' +import { Calendar } from '@/components/ui/calendar' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { Badge } from '@/components/ui/badge' import { Table, @@ -16,10 +26,12 @@ import { } from '@/components/ui/table' import { Package, - Download, - Calculator + Calculator, + CalendarIcon } from 'lucide-react' +import { format } from 'date-fns' +import { cn } from '@/lib/utils' import { formatDate } from '@/lib/utils' import { downloadFile, formatFileSize, getFileInfo } from '@/lib/file-download' import { getSpecDocumentsForPrItem } from '../../pre-quote/service' @@ -186,6 +198,8 @@ export function PrItemsPricingTable({ }: PrItemsPricingTableProps) { const [quotations, setQuotations] = React.useState<PrItemQuotation[]>([]) const [specDocuments, setSpecDocuments] = React.useState<Record<number, SpecDocument[]>>({}) + const [showBulkDateDialog, setShowBulkDateDialog] = React.useState(false) + const [bulkDeliveryDate, setBulkDeliveryDate] = React.useState<Date | undefined>(undefined) // 초기 견적 데이터 설정 및 SPEC 문서 로드 React.useEffect(() => { @@ -279,6 +293,21 @@ export function PrItemsPricingTable({ onTotalAmountChange(totalAmount) } + // 일괄 납기일 적용 + const applyBulkDeliveryDate = () => { + if (bulkDeliveryDate && quotations.length > 0) { + const formattedDate = format(bulkDeliveryDate, 'yyyy-MM-dd') + const updatedQuotations = quotations.map(q => ({ + ...q, + proposedDeliveryDate: formattedDate + })) + + setQuotations(updatedQuotations) + onQuotationsChange(updatedQuotations) + setShowBulkDateDialog(false) + setBulkDeliveryDate(undefined) + } + } // 통화 포맷팅 const formatCurrency = (amount: number) => { @@ -292,12 +321,26 @@ export function PrItemsPricingTable({ const totalAmount = quotations.reduce((sum, q) => sum + q.bidAmount, 0) return ( + <> <Card> <CardHeader> - <CardTitle className="flex items-center gap-2"> - <Package className="w-5 h-5" /> - 품목별 입찰 작성 - </CardTitle> + <div className="flex items-center justify-between"> + <CardTitle className="flex items-center gap-2"> + <Package className="w-5 h-5" /> + 품목별 입찰 작성 + </CardTitle> + {!readOnly && ( + <Button + type="button" + variant="outline" + size="sm" + onClick={() => setShowBulkDateDialog(true)} + > + <CalendarIcon className="h-4 w-4 mr-1" /> + 전체 납품예정일 설정 + </Button> + )} + </div> </CardHeader> <CardContent> <div className="space-y-4"> @@ -382,18 +425,14 @@ export function PrItemsPricingTable({ </span> ) : ( <Input - type="number" - inputMode="decimal" - min={0} - pattern="^(0|[1-9][0-9]*)(\.[0-9]+)?$" - value={quotation.bidUnitPrice === 0 ? '' : quotation.bidUnitPrice} + type="text" + inputMode="numeric" + value={quotation.bidUnitPrice === 0 ? '' : quotation.bidUnitPrice.toLocaleString()} onChange={(e) => { - let value = e.target.value - if (/^0[0-9]+/.test(value)) { - value = value.replace(/^0+/, '') - if (value === '') value = '0' - } - const numericValue = parseFloat(value) + // 콤마 제거 및 숫자만 허용 + const value = e.target.value.replace(/,/g, '').replace(/[^0-9]/g, '') + const numericValue = Number(value) + updateQuotation( item.id, 'bidUnitPrice', @@ -471,5 +510,73 @@ export function PrItemsPricingTable({ </div> </CardContent> </Card> + + {/* 일괄 납품예정일 설정 다이얼로그 */} + <Dialog open={showBulkDateDialog} onOpenChange={setShowBulkDateDialog}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle>전체 납품예정일 설정</DialogTitle> + <DialogDescription> + 모든 PR 아이템에 동일한 납품예정일을 적용합니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + <div className="space-y-2"> + <Label>납품예정일 선택</Label> + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + className={cn( + "w-full justify-start text-left font-normal", + !bulkDeliveryDate && "text-muted-foreground" + )} + > + <CalendarIcon className="mr-2 h-4 w-4" /> + {bulkDeliveryDate ? format(bulkDeliveryDate, "yyyy-MM-dd") : "날짜 선택"} + </Button> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={bulkDeliveryDate} + onSelect={setBulkDeliveryDate} + initialFocus + /> + </PopoverContent> + </Popover> + </div> + + <div className="bg-muted/50 rounded-lg p-3"> + <p className="text-sm text-muted-foreground"> + 선택된 날짜가 <strong>{prItems.length}개</strong>의 모든 PR 아이템에 적용됩니다. + 기존에 설정된 납품예정일은 모두 교체됩니다. + </p> + </div> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => { + setShowBulkDateDialog(false) + setBulkDeliveryDate(undefined) + }} + > + 취소 + </Button> + <Button + type="button" + onClick={applyBulkDeliveryDate} + disabled={!bulkDeliveryDate} + > + 전체 적용 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </> ) } diff --git a/lib/bidding/vendor/export-partners-biddings-to-excel.ts b/lib/bidding/vendor/export-partners-biddings-to-excel.ts new file mode 100644 index 00000000..e1d985fe --- /dev/null +++ b/lib/bidding/vendor/export-partners-biddings-to-excel.ts @@ -0,0 +1,275 @@ +import { type Table } from "@tanstack/react-table" +import ExcelJS from "exceljs" +import { PartnersBiddingListItem } from '../detail/service' +import { + biddingStatusLabels, + contractTypeLabels, +} from "@/db/schema" +import { formatDate } from "@/lib/utils" + +/** + * Partners 입찰 목록을 Excel로 내보내기 + * - 계약구분, 진행상태는 라벨(명칭)로 변환 + * - 입찰기간은 submissionStartDate, submissionEndDate 기준 + * - 날짜는 적절한 형식으로 변환 + */ +export async function exportPartnersBiddingsToExcel( + table: Table<PartnersBiddingListItem>, + { + filename = "협력업체입찰목록", + onlySelected = false, + }: { + filename?: string + onlySelected?: boolean + } = {} +): Promise<void> { + // 테이블에서 실제 사용 중인 leaf columns 가져오기 + const allColumns = table.getAllLeafColumns() + + // select, actions, attachments 컬럼 제외 + const columns = allColumns.filter( + (col) => !["select", "actions", "attachments"].includes(col.id) + ) + + // 헤더 매핑 (컬럼 id -> Excel 헤더명) + const headerMap: Record<string, string> = { + biddingNumber: "입찰 No.", + status: "입찰상태", + isUrgent: "긴급여부", + title: "입찰명", + isAttendingMeeting: "사양설명회", + isBiddingParticipated: "입찰 참여의사", + biddingSubmissionStatus: "입찰 제출여부", + contractType: "계약구분", + submissionStartDate: "입찰기간", + contractStartDate: "계약기간", + bidPicName: "입찰담당자", + supplyPicName: "조달담당자", + updatedAt: "최종수정일", + } + + // 헤더 행 생성 + const headerRow = columns.map((col) => { + return headerMap[col.id] || col.id + }) + + // 데이터 행 생성 + const rowModel = onlySelected + ? table.getFilteredSelectedRowModel() + : table.getRowModel() + + const dataRows = rowModel.rows.map((row) => { + const original = row.original + return columns.map((col) => { + const colId = col.id + let value: any + + // 특별 처리 필요한 컬럼들 + switch (colId) { + case "contractType": + // 계약구분: 라벨로 변환 + value = contractTypeLabels[original.contractType as keyof typeof contractTypeLabels] || original.contractType + break + + case "status": + // 입찰상태: 라벨로 변환 + value = biddingStatusLabels[original.status as keyof typeof biddingStatusLabels] || original.status + break + + case "isUrgent": + // 긴급여부: Yes/No + value = original.isUrgent ? "긴급" : "일반" + break + + case "isAttendingMeeting": + // 사양설명회: 참석/불참/미결정 + if (original.isAttendingMeeting === null) { + value = "해당없음" + } else { + value = original.isAttendingMeeting ? "참석" : "불참" + } + break + + case "isBiddingParticipated": + // 입찰 참여의사: 참여/불참/미결정 + if (original.isBiddingParticipated === null) { + value = "미결정" + } else { + value = original.isBiddingParticipated ? "참여" : "불참" + } + break + + case "biddingSubmissionStatus": + // 입찰 제출여부: 최종제출/제출/미제출 + const finalQuoteAmount = original.finalQuoteAmount + const isFinalSubmission = original.isFinalSubmission + + if (!finalQuoteAmount) { + value = "미제출" + } else if (isFinalSubmission) { + value = "최종제출" + } else { + value = "제출" + } + break + + case "submissionStartDate": + // 입찰기간: submissionStartDate, submissionEndDate 기준 + const startDate = original.submissionStartDate + const endDate = original.submissionEndDate + + if (!startDate || !endDate) { + value = "-" + } else { + const startObj = new Date(startDate) + const endObj = new Date(endDate) + + // 입력값 기반: 저장된 UTC 값을 그대로 표시 (타임존 가감 없음) + const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ') + + value = `${formatValue(startObj)} ~ ${formatValue(endObj)}` + } + break + + // case "preQuoteDeadline": + // // 사전견적 마감일: 날짜 형식 + // if (!original.preQuoteDeadline) { + // value = "-" + // } else { + // const deadline = new Date(original.preQuoteDeadline) + // value = deadline.toISOString().slice(0, 16).replace('T', ' ') + // } + // break + + case "contractStartDate": + // 계약기간: contractStartDate, contractEndDate 기준 + const contractStart = original.contractStartDate + const contractEnd = original.contractEndDate + + if (!contractStart || !contractEnd) { + value = "-" + } else { + const startObj = new Date(contractStart) + const endObj = new Date(contractEnd) + value = `${formatDate(startObj, "KR")} ~ ${formatDate(endObj, "KR")}` + } + break + + case "bidPicName": + // 입찰담당자: bidPicName + value = original.bidPicName || "-" + break + + case "supplyPicName": + // 조달담당자: supplyPicName + value = original.supplyPicName || "-" + break + + case "updatedAt": + // 최종수정일: 날짜 시간 형식 + if (original.updatedAt) { + const updated = new Date(original.updatedAt) + value = updated.toISOString().slice(0, 16).replace('T', ' ') + } else { + value = "-" + } + break + + case "biddingNumber": + // 입찰번호: 원입찰번호 포함 + const biddingNumber = original.biddingNumber + const originalBiddingNumber = original.originalBiddingNumber + if (originalBiddingNumber) { + value = `${biddingNumber} (원: ${originalBiddingNumber})` + } else { + value = biddingNumber + } + break + + default: + // 기본값: row.getValue 사용 + value = row.getValue(colId) + + // null/undefined 처리 + if (value == null) { + value = "" + } + + // 객체인 경우 JSON 문자열로 변환 + if (typeof value === "object") { + value = JSON.stringify(value) + } + break + } + + return value + }) + }) + + // 최종 sheetData + const sheetData = [headerRow, ...dataRows] + + // ExcelJS로 파일 생성 및 다운로드 + await createAndDownloadExcel(sheetData, columns.length, filename) +} + +/** + * Excel 파일 생성 및 다운로드 + */ +async function createAndDownloadExcel( + sheetData: any[][], + columnCount: number, + filename: string +): Promise<void> { + // ExcelJS 워크북/시트 생성 + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("Sheet1") + + // 칼럼별 최대 길이 추적 + const maxColumnLengths = Array(columnCount).fill(0) + sheetData.forEach((row) => { + row.forEach((cellValue, colIdx) => { + const cellText = cellValue?.toString() ?? "" + if (cellText.length > maxColumnLengths[colIdx]) { + maxColumnLengths[colIdx] = cellText.length + } + }) + }) + + // 시트에 데이터 추가 + 헤더 스타일 + sheetData.forEach((arr, idx) => { + const row = worksheet.addRow(arr) + + // 헤더 스타일 적용 (첫 번째 행) + if (idx === 0) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + } + }) + } + }) + + // 칼럼 너비 자동 조정 + maxColumnLengths.forEach((len, idx) => { + // 최소 너비 10, +2 여백 + worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10) + }) + + // 최종 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `${filename}.xlsx` + link.click() + URL.revokeObjectURL(url) +} + diff --git a/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx b/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx index d0ef97f1..8d6cb82d 100644 --- a/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx +++ b/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx @@ -37,7 +37,6 @@ interface PartnersSpecificationMeetingDialogProps { title: string preQuoteDate: string | null biddingRegistrationDate: string | null - evaluationDate: string | null hasSpecificationMeeting?: boolean // 사양설명회 여부 추가 } | null biddingCompanyId: number diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx index bf76de62..bf33cef5 100644 --- a/lib/bidding/vendor/partners-bidding-detail.tsx +++ b/lib/bidding/vendor/partners-bidding-detail.tsx @@ -75,7 +75,6 @@ interface BiddingDetail { biddingRegistrationDate: Date | string | null submissionStartDate: Date | string | null submissionEndDate: Date | string | null - evaluationDate: Date | string | null currency: string budget: number | null targetPrice: number | null @@ -869,7 +868,8 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD const timeLeft = deadline.getTime() - now.getTime() const daysLeft = Math.floor(timeLeft / (1000 * 60 * 60 * 24)) const hoursLeft = Math.floor((timeLeft % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) - const kstDeadline = new Date(deadline.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ') + // 입력값 기반: 저장된 UTC 값을 그대로 표시 + const displayDeadline = deadline.toISOString().slice(0, 16).replace('T', ' ') return ( <div className={`p-3 rounded-lg border-2 ${ @@ -884,7 +884,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD <Calendar className="w-5 h-5" /> <span className="font-medium">제출 마감일:</span> <span className="text-lg font-semibold"> - {kstDeadline} + {displayDeadline} </span> </div> {isExpired ? ( @@ -921,17 +921,13 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD <span className="font-medium">입찰서 제출기간:</span> {(() => { const start = new Date(biddingDetail.submissionStartDate!) const end = new Date(biddingDetail.submissionEndDate!) - const kstStart = new Date(start.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ') - const kstEnd = new Date(end.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ') - return `${kstStart} ~ ${kstEnd}` + const displayStart = start.toISOString().slice(0, 16).replace('T', ' ') + const displayEnd = end.toISOString().slice(0, 16).replace('T', ' ') + return `${displayStart} ~ ${displayEnd}` })()} </div> )} - {biddingDetail.evaluationDate && ( - <div> - <span className="font-medium">평가일:</span> {format(new Date(biddingDetail.evaluationDate), "yyyy-MM-dd HH:mm")} - </div> - )} + </div> </div> </CardContent> diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx index a122e87b..09c3caad 100644 --- a/lib/bidding/vendor/partners-bidding-list-columns.tsx +++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx @@ -285,7 +285,7 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL cell: ({ row }) => { const isAttending = row.original.isAttendingMeeting if (isAttending === null) { - return <div className="text-muted-foreground text-center">-</div> + return <div className="text-muted-foreground text-center">해당없음</div> } return isAttending ? ( <CheckCircle className="h-5 w-5 text-green-600 mx-auto" /> @@ -352,45 +352,45 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL const startObj = new Date(startDate) const endObj = new Date(endDate) - // UI 표시용 KST 변환 - const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ') + // 입력값 기반: 저장된 UTC 값 그대로 표시 (타임존 가감 없음) + const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ') return ( <div className="text-sm"> - <div>{formatKst(startObj)}</div> + <div>{formatValue(startObj)}</div> <div className="text-muted-foreground">~</div> - <div>{formatKst(endObj)}</div> + <div>{formatValue(endObj)}</div> </div> ) }, }), // 사전견적 마감일 - columnHelper.accessor('preQuoteDeadline', { - header: '사전견적 마감일', - cell: ({ row }) => { - const deadline = row.original.preQuoteDeadline - if (!deadline) { - return <div className="text-muted-foreground">-</div> - } + // columnHelper.accessor('preQuoteDeadline', { + // header: '사전견적 마감일', + // cell: ({ row }) => { + // const deadline = row.original.preQuoteDeadline + // if (!deadline) { + // return <div className="text-muted-foreground">-</div> + // } - const now = new Date() - const deadlineDate = new Date(deadline) - const isExpired = deadlineDate < now + // const now = new Date() + // const deadlineDate = new Date(deadline) + // const isExpired = deadlineDate < now - return ( - <div className={`text-sm flex items-center gap-1 ${isExpired ? 'text-red-600' : ''}`}> - <Calendar className="w-4 h-4" /> - <span>{format(new Date(deadline), "yyyy-MM-dd HH:mm")}</span> - {isExpired && ( - <Badge variant="destructive" className="text-xs"> - 마감 - </Badge> - )} - </div> - ) - }, - }), + // return ( + // <div className={`text-sm flex items-center gap-1 ${isExpired ? 'text-red-600' : ''}`}> + // <Calendar className="w-4 h-4" /> + // <span>{format(new Date(deadline), "yyyy-MM-dd HH:mm")}</span> + // {isExpired && ( + // <Badge variant="destructive" className="text-xs"> + // 마감 + // </Badge> + // )} + // </div> + // ) + // }, + // }), // 계약기간 columnHelper.accessor('contractStartDate', { diff --git a/lib/bidding/vendor/partners-bidding-list.tsx b/lib/bidding/vendor/partners-bidding-list.tsx index 0f68ed68..f1cb0bdc 100644 --- a/lib/bidding/vendor/partners-bidding-list.tsx +++ b/lib/bidding/vendor/partners-bidding-list.tsx @@ -181,7 +181,6 @@ export function PartnersBiddingList({ promises }: PartnersBiddingListProps) { title: rowAction.row.original.title, preQuoteDate: null, biddingRegistrationDate: rowAction.row.original.submissionStartDate?.toISOString() || null, - evaluationDate: null, hasSpecificationMeeting: rowAction.row.original.hasSpecificationMeeting || false, } : null} biddingCompanyId={rowAction?.row.original?.biddingCompanyId || 0} diff --git a/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx b/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx index 87b1367e..9a2f026c 100644 --- a/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx +++ b/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx @@ -2,10 +2,12 @@ import * as React from "react" import { type Table } from "@tanstack/react-table" -import { Users} from "lucide-react" +import { Users, FileSpreadsheet } from "lucide-react" +import { toast } from "sonner" import { Button } from "@/components/ui/button" import { PartnersBiddingListItem } from '../detail/service' +import { exportPartnersBiddingsToExcel } from './export-partners-biddings-to-excel' interface PartnersBiddingToolbarActionsProps { table: Table<PartnersBiddingListItem> @@ -20,6 +22,8 @@ export function PartnersBiddingToolbarActions({ const selectedRows = table.getFilteredSelectedRowModel().rows const selectedBidding = selectedRows.length === 1 ? selectedRows[0].original : null + const [isExporting, setIsExporting] = React.useState(false) + const handleSpecificationMeetingClick = () => { if (selectedBidding && setRowAction) { setRowAction({ @@ -29,8 +33,36 @@ export function PartnersBiddingToolbarActions({ } } + // Excel 내보내기 핸들러 + const handleExport = React.useCallback(async () => { + try { + setIsExporting(true) + await exportPartnersBiddingsToExcel(table, { + filename: "협력업체입찰목록", + onlySelected: false, + }) + toast.success("Excel 파일이 다운로드되었습니다.") + } catch (error) { + console.error("Excel export error:", error) + toast.error("Excel 내보내기 중 오류가 발생했습니다.") + } finally { + setIsExporting(false) + } + }, [table]) + return ( <div className="flex items-center gap-2"> + {/* Excel 내보내기 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleExport} + disabled={isExporting} + className="gap-2" + > + <FileSpreadsheet className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">{isExporting ? "내보내는 중..." : "Excel 내보내기"}</span> + </Button> <Button variant="outline" size="sm" |
