summaryrefslogtreecommitdiff
path: root/lib/bidding/vendor
diff options
context:
space:
mode:
Diffstat (limited to 'lib/bidding/vendor')
-rw-r--r--lib/bidding/vendor/components/pr-items-pricing-table.tsx18
-rw-r--r--lib/bidding/vendor/export-partners-biddings-to-excel.ts278
-rw-r--r--lib/bidding/vendor/partners-bidding-list-columns.tsx48
-rw-r--r--lib/bidding/vendor/partners-bidding-toolbar-actions.tsx34
4 files changed, 342 insertions, 36 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..5afb2b67 100644
--- a/lib/bidding/vendor/components/pr-items-pricing-table.tsx
+++ b/lib/bidding/vendor/components/pr-items-pricing-table.tsx
@@ -382,18 +382,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',
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..9e99eeec
--- /dev/null
+++ b/lib/bidding/vendor/export-partners-biddings-to-excel.ts
@@ -0,0 +1,278 @@
+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)
+
+ // KST 변환 (UTC+9)
+ const formatKst = (d: Date) => {
+ const kstDate = new Date(d.getTime() + 9 * 60 * 60 * 1000)
+ return kstDate.toISOString().slice(0, 16).replace('T', ' ')
+ }
+
+ value = `${formatKst(startObj)} ~ ${formatKst(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-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx
index a122e87b..6276d1b7 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" />
@@ -366,31 +366,31 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
}),
// 사전견적 마감일
- 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-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"