summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--components/bidding/manage/bidding-companies-editor.tsx262
-rw-r--r--components/bidding/manage/bidding-items-editor.tsx181
-rw-r--r--components/bidding/manage/create-pre-quote-rfq-dialog.tsx38
-rw-r--r--components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx4
-rw-r--r--db/schema/bidding.ts2
-rw-r--r--lib/bidding/actions.ts8
-rw-r--r--lib/bidding/approval-actions.ts2
-rw-r--r--lib/bidding/detail/service.ts151
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-table.tsx5
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx195
-rw-r--r--lib/bidding/handlers.ts12
-rw-r--r--lib/bidding/list/biddings-table-toolbar-actions.tsx54
-rw-r--r--lib/bidding/list/export-biddings-to-excel.ts212
-rw-r--r--lib/bidding/manage/export-bidding-items-to-excel.ts161
-rw-r--r--lib/bidding/manage/import-bidding-items-from-excel.ts271
-rw-r--r--lib/bidding/manage/project-utils.ts87
-rw-r--r--lib/bidding/selection/actions.ts69
-rw-r--r--lib/bidding/selection/bidding-info-card.tsx2
-rw-r--r--lib/bidding/selection/bidding-item-table.tsx192
-rw-r--r--lib/bidding/selection/bidding-selection-detail-content.tsx11
-rw-r--r--lib/bidding/selection/biddings-selection-table.tsx6
-rw-r--r--lib/bidding/selection/selection-result-form.tsx213
-rw-r--r--lib/bidding/selection/vendor-selection-table.tsx4
-rw-r--r--lib/bidding/service.ts133
-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
-rw-r--r--lib/general-contracts/detail/general-contract-basic-info.tsx478
-rw-r--r--lib/general-contracts/detail/general-contract-items-table.tsx43
-rw-r--r--lib/general-contracts/service.ts11
-rw-r--r--lib/procurement-items/service.ts15
-rw-r--r--lib/soap/ecc/mapper/bidding-and-pr-mapper.ts15
33 files changed, 2728 insertions, 487 deletions
diff --git a/components/bidding/manage/bidding-companies-editor.tsx b/components/bidding/manage/bidding-companies-editor.tsx
index 6634f528..4c3e6bbc 100644
--- a/components/bidding/manage/bidding-companies-editor.tsx
+++ b/components/bidding/manage/bidding-companies-editor.tsx
@@ -1,7 +1,7 @@
'use client'
import * as React from 'react'
-import { Building, User, Plus, Trash2 } from 'lucide-react'
+import { Building, User, Plus, Trash2, Users } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
@@ -11,7 +11,9 @@ import {
createBiddingCompanyContact,
deleteBiddingCompanyContact,
getVendorContactsByVendorId,
- updateBiddingCompanyPriceAdjustmentQuestion
+ updateBiddingCompanyPriceAdjustmentQuestion,
+ getBiddingCompaniesByBidPicId,
+ addBiddingCompanyFromOtherBidding
} from '@/lib/bidding/service'
import { deleteBiddingCompany } from '@/lib/bidding/pre-quote/service'
import { BiddingDetailVendorCreateDialog } from './bidding-detail-vendor-create-dialog'
@@ -36,6 +38,7 @@ import {
} from '@/components/ui/table'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
+import { PurchaseGroupCodeSelector, PurchaseGroupCodeWithUser } from '@/components/common/selectors/purchase-group-code/purchase-group-code-selector'
interface QuotationVendor {
id: number // biddingCompanies.id
@@ -102,6 +105,26 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC
const [isLoadingVendorContacts, setIsLoadingVendorContacts] = React.useState(false)
const [selectedContactFromVendor, setSelectedContactFromVendor] = React.useState<VendorContact | null>(null)
+ // 협력사 멀티 선택 다이얼로그
+ const [multiSelectDialogOpen, setMultiSelectDialogOpen] = React.useState(false)
+ const [selectedBidPic, setSelectedBidPic] = React.useState<PurchaseGroupCodeWithUser | undefined>(undefined)
+ const [biddingCompaniesList, setBiddingCompaniesList] = React.useState<Array<{
+ biddingId: number
+ biddingNumber: string
+ biddingTitle: string
+ companyId: number
+ vendorCode: string
+ vendorName: string
+ updatedAt: Date
+ }>>([])
+ const [isLoadingBiddingCompanies, setIsLoadingBiddingCompanies] = React.useState(false)
+ const [selectedBiddingCompany, setSelectedBiddingCompany] = React.useState<{
+ biddingId: number
+ companyId: number
+ } | null>(null)
+ const [selectedBiddingCompanyContacts, setSelectedBiddingCompanyContacts] = React.useState<BiddingCompanyContact[]>([])
+ const [isLoadingCompanyContacts, setIsLoadingCompanyContacts] = React.useState(false)
+
// 업체 목록 다시 로딩 함수
const reloadVendors = React.useCallback(async () => {
try {
@@ -494,10 +517,16 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC
</p>
</div>
{!readonly && (
- <Button onClick={() => setAddVendorDialogOpen(true)} className="flex items-center gap-2" disabled={readonly}>
- <Plus className="h-4 w-4" />
- 업체 추가
- </Button>
+ <div className="flex gap-2">
+ <Button onClick={() => setMultiSelectDialogOpen(true)} className="flex items-center gap-2" disabled={readonly} variant="outline">
+ <Users className="h-4 w-4" />
+ 협력사 멀티 선택
+ </Button>
+ <Button onClick={() => setAddVendorDialogOpen(true)} className="flex items-center gap-2" disabled={readonly}>
+ <Plus className="h-4 w-4" />
+ 업체 추가
+ </Button>
+ </div>
)}
</CardHeader>
<CardContent>
@@ -740,6 +769,227 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC
</DialogContent>
</Dialog>
+ {/* 협력사 멀티 선택 다이얼로그 */}
+ <Dialog open={multiSelectDialogOpen} onOpenChange={setMultiSelectDialogOpen}>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>참여협력사 선택</DialogTitle>
+ <DialogDescription>
+ 입찰담당자를 선택하여 해당 담당자의 입찰 업체를 조회하고 선택할 수 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4 py-4">
+ {/* 입찰담당자 선택 */}
+ <div className="space-y-2">
+ <Label>입찰담당자 선택</Label>
+ <PurchaseGroupCodeSelector
+ selectedCode={selectedBidPic}
+ onCodeSelect={async (code) => {
+ setSelectedBidPic(code)
+ if (code.user?.id) {
+ setIsLoadingBiddingCompanies(true)
+ try {
+ const result = await getBiddingCompaniesByBidPicId(code.user.id)
+ if (result.success && result.data) {
+ setBiddingCompaniesList(result.data)
+ } else {
+ toast.error(result.error || '입찰 업체 조회에 실패했습니다.')
+ setBiddingCompaniesList([])
+ }
+ } catch (error) {
+ console.error('Failed to load bidding companies:', error)
+ toast.error('입찰 업체 조회에 실패했습니다.')
+ setBiddingCompaniesList([])
+ } finally {
+ setIsLoadingBiddingCompanies(false)
+ }
+ }
+ }}
+ placeholder="입찰담당자 선택"
+ disabled={readonly}
+ />
+ </div>
+
+ {/* 입찰 업체 목록 */}
+ {isLoadingBiddingCompanies ? (
+ <div className="flex items-center justify-center py-8">
+ <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+ <span className="text-sm text-muted-foreground">입찰 업체를 불러오는 중...</span>
+ </div>
+ ) : biddingCompaniesList.length === 0 && selectedBidPic ? (
+ <div className="text-center py-8 text-muted-foreground">
+ 해당 입찰담당자의 입찰 업체가 없습니다.
+ </div>
+ ) : biddingCompaniesList.length > 0 ? (
+ <div className="space-y-2">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[50px]">선택</TableHead>
+ <TableHead>입찰번호</TableHead>
+ <TableHead>입찰명</TableHead>
+ <TableHead>협력사코드</TableHead>
+ <TableHead>협력사명</TableHead>
+ <TableHead>입찰 업데이트일</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {biddingCompaniesList.map((company) => {
+ const isSelected = selectedBiddingCompany?.biddingId === company.biddingId &&
+ selectedBiddingCompany?.companyId === company.companyId
+ return (
+ <TableRow
+ key={`${company.biddingId}-${company.companyId}`}
+ className={`cursor-pointer hover:bg-muted/50 ${
+ isSelected ? 'bg-muted/50' : ''
+ }`}
+ onClick={async () => {
+ if (isSelected) {
+ setSelectedBiddingCompany(null)
+ setSelectedBiddingCompanyContacts([])
+ return
+ }
+ setSelectedBiddingCompany({
+ biddingId: company.biddingId,
+ companyId: company.companyId
+ })
+ setIsLoadingCompanyContacts(true)
+ try {
+ const contactsResult = await getBiddingCompanyContacts(company.biddingId, company.companyId)
+ if (contactsResult.success && contactsResult.data) {
+ setSelectedBiddingCompanyContacts(contactsResult.data)
+ } else {
+ setSelectedBiddingCompanyContacts([])
+ }
+ } catch (error) {
+ console.error('Failed to load company contacts:', error)
+ setSelectedBiddingCompanyContacts([])
+ } finally {
+ setIsLoadingCompanyContacts(false)
+ }
+ }}
+ >
+ <TableCell onClick={(e) => e.stopPropagation()}>
+ <Checkbox
+ checked={isSelected}
+ onCheckedChange={() => {
+ // 클릭 이벤트는 TableRow의 onClick에서 처리
+ }}
+ disabled={readonly}
+ />
+ </TableCell>
+ <TableCell className="font-medium">{company.biddingNumber}</TableCell>
+ <TableCell>{company.biddingTitle}</TableCell>
+ <TableCell>{company.vendorCode}</TableCell>
+ <TableCell>{company.vendorName}</TableCell>
+ <TableCell>
+ {company.updatedAt ? new Date(company.updatedAt).toLocaleDateString('ko-KR') : '-'}
+ </TableCell>
+ </TableRow>
+ )
+ })}
+ </TableBody>
+ </Table>
+
+ {/* 선택한 입찰 업체의 담당자 정보 */}
+ {selectedBiddingCompany !== null && (
+ <div className="mt-4 p-4 border rounded-lg">
+ <h4 className="font-medium mb-2">담당자 정보</h4>
+ {isLoadingCompanyContacts ? (
+ <div className="flex items-center justify-center py-4">
+ <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+ <span className="text-sm text-muted-foreground">담당자 정보를 불러오는 중...</span>
+ </div>
+ ) : selectedBiddingCompanyContacts.length === 0 ? (
+ <div className="text-sm text-muted-foreground">등록된 담당자가 없습니다.</div>
+ ) : (
+ <div className="space-y-2">
+ {selectedBiddingCompanyContacts.map((contact) => (
+ <div key={contact.id} className="text-sm">
+ <span className="font-medium">{contact.contactName}</span>
+ <span className="text-muted-foreground ml-2">{contact.contactEmail}</span>
+ {contact.contactNumber && (
+ <span className="text-muted-foreground ml-2">{contact.contactNumber}</span>
+ )}
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ ) : null}
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => {
+ setMultiSelectDialogOpen(false)
+ setSelectedBidPic(undefined)
+ setBiddingCompaniesList([])
+ setSelectedBiddingCompany(null)
+ setSelectedBiddingCompanyContacts([])
+ }}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={async () => {
+ if (!selectedBiddingCompany) {
+ toast.error('입찰 업체를 선택해주세요.')
+ return
+ }
+
+ const selectedCompany = biddingCompaniesList.find(
+ c => c.biddingId === selectedBiddingCompany.biddingId &&
+ c.companyId === selectedBiddingCompany.companyId
+ )
+
+ if (!selectedCompany) {
+ toast.error('선택한 입찰 업체 정보를 찾을 수 없습니다.')
+ return
+ }
+
+ try {
+ const contacts = selectedBiddingCompanyContacts.map(c => ({
+ contactName: c.contactName,
+ contactEmail: c.contactEmail,
+ contactNumber: c.contactNumber || undefined,
+ }))
+
+ const result = await addBiddingCompanyFromOtherBidding(
+ biddingId,
+ selectedCompany.biddingId,
+ selectedCompany.companyId,
+ contacts.length > 0 ? contacts : undefined
+ )
+
+ if (result.success) {
+ toast.success('업체가 성공적으로 추가되었습니다.')
+ setMultiSelectDialogOpen(false)
+ setSelectedBidPic(undefined)
+ setBiddingCompaniesList([])
+ setSelectedBiddingCompany(null)
+ setSelectedBiddingCompanyContacts([])
+ await reloadVendors()
+ } else {
+ toast.error(result.error || '업체 추가에 실패했습니다.')
+ }
+ } catch (error) {
+ console.error('Failed to add bidding company:', error)
+ toast.error('업체 추가에 실패했습니다.')
+ }
+ }}
+ disabled={!selectedBiddingCompany || readonly}
+ >
+ 추가
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
{/* 벤더 담당자에서 추가 다이얼로그 */}
<Dialog open={addContactFromVendorDialogOpen} onOpenChange={setAddContactFromVendorDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
diff --git a/components/bidding/manage/bidding-items-editor.tsx b/components/bidding/manage/bidding-items-editor.tsx
index 90e512d2..452cdc3c 100644
--- a/components/bidding/manage/bidding-items-editor.tsx
+++ b/components/bidding/manage/bidding-items-editor.tsx
@@ -1,7 +1,7 @@
'use client'
import * as React from 'react'
-import { Package, Plus, Trash2, Save, RefreshCw, FileText } from 'lucide-react'
+import { Package, Plus, Trash2, Save, RefreshCw, FileText, FileSpreadsheet, Upload } from 'lucide-react'
import { getPRItemsForBidding } from '@/lib/bidding/detail/service'
import { updatePrItem } from '@/lib/bidding/detail/service'
import { toast } from 'sonner'
@@ -26,7 +26,7 @@ import { CostCenterSingleSelector } from '@/components/common/selectors/cost-cen
import { GlAccountSingleSelector } from '@/components/common/selectors/gl-account/gl-account-single-selector'
// PR 아이템 정보 타입 (create-bidding-dialog와 동일)
-interface PRItemInfo {
+export interface PRItemInfo {
id: number // 실제 DB ID
prNumber?: string | null
projectId?: number | null
@@ -84,6 +84,16 @@ import { CreatePreQuoteRfqDialog } from './create-pre-quote-rfq-dialog'
import { ProcurementItemSelectorDialogSingle } from '@/components/common/selectors/procurement-item/procurement-item-selector-dialog-single'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
+import { exportBiddingItemsToExcel } from '@/lib/bidding/manage/export-bidding-items-to-excel'
+import { importBiddingItemsFromExcel } from '@/lib/bidding/manage/import-bidding-items-from-excel'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItemsEditorProps) {
const { data: session } = useSession()
@@ -114,6 +124,11 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
isPriceAdjustmentApplicable?: boolean | null
sparePartOptions?: string | null
} | null>(null)
+ const [importDialogOpen, setImportDialogOpen] = React.useState(false)
+ const [importFile, setImportFile] = React.useState<File | null>(null)
+ const [importErrors, setImportErrors] = React.useState<string[]>([])
+ const [isImporting, setIsImporting] = React.useState(false)
+ const [isExporting, setIsExporting] = React.useState(false)
// 초기 데이터 로딩 - 기존 품목이 있으면 자동으로 로드
React.useEffect(() => {
@@ -492,7 +507,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
materialGroupInfo: null,
materialNumber: null,
materialInfo: null,
- priceUnit: 1,
+ priceUnit: '1',
purchaseUnit: 'EA',
materialWeight: null,
wbsCode: null,
@@ -644,6 +659,76 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
const totals = calculateTotals()
+ // Excel 내보내기 핸들러
+ const handleExport = React.useCallback(async () => {
+ if (items.length === 0) {
+ toast.error('내보낼 품목이 없습니다.')
+ return
+ }
+
+ try {
+ setIsExporting(true)
+ await exportBiddingItemsToExcel(items, {
+ filename: `입찰품목목록_${biddingId}`,
+ })
+ toast.success('Excel 파일이 다운로드되었습니다.')
+ } catch (error) {
+ console.error('Excel export error:', error)
+ toast.error('Excel 내보내기 중 오류가 발생했습니다.')
+ } finally {
+ setIsExporting(false)
+ }
+ }, [items, biddingId])
+
+ // Excel 가져오기 핸들러
+ const handleImportFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const file = event.target.files?.[0]
+ if (file) {
+ if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.xls')) {
+ toast.error('Excel 파일(.xlsx, .xls)만 업로드 가능합니다.')
+ return
+ }
+ setImportFile(file)
+ setImportErrors([])
+ }
+ }
+
+ const handleImport = async () => {
+ if (!importFile) return
+
+ setIsImporting(true)
+ setImportErrors([])
+
+ try {
+ const result = await importBiddingItemsFromExcel(importFile)
+
+ if (result.errors.length > 0) {
+ setImportErrors(result.errors)
+ toast.warning(
+ `${result.items.length}개의 품목을 파싱했지만 ${result.errors.length}개의 오류가 있습니다.`
+ )
+ return
+ }
+
+ if (result.items.length === 0) {
+ toast.error('가져올 품목이 없습니다.')
+ return
+ }
+
+ // 기존 아이템에 추가
+ setItems((prev) => [...prev, ...result.items])
+ setImportDialogOpen(false)
+ setImportFile(null)
+ setImportErrors([])
+ toast.success(`${result.items.length}개의 품목이 추가되었습니다.`)
+ } catch (error) {
+ console.error('Excel import error:', error)
+ toast.error('Excel 가져오기 중 오류가 발생했습니다.')
+ } finally {
+ setIsImporting(false)
+ }
+ }
+
if (isLoading) {
return (
<div className="flex items-center justify-center p-8">
@@ -1372,6 +1457,14 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
<FileText className="h-4 w-4" />
사전견적
</Button>
+ <Button onClick={handleExport} variant="outline" className="flex items-center gap-2" disabled={readonly || isExporting || items.length === 0}>
+ <FileSpreadsheet className="h-4 w-4" />
+ {isExporting ? "내보내는 중..." : "Excel 내보내기"}
+ </Button>
+ <Button onClick={() => setImportDialogOpen(true)} variant="outline" className="flex items-center gap-2" disabled={readonly}>
+ <Upload className="h-4 w-4" />
+ Excel 가져오기
+ </Button>
<Button onClick={handleAddItem} className="flex items-center gap-2" disabled={readonly}>
<Plus className="h-4 w-4" />
품목 추가
@@ -1492,6 +1585,88 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
toast.success('사전견적용 일반견적이 생성되었습니다')
}}
/>
+
+ {/* Excel 가져오기 다이얼로그 */}
+ <Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
+ <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>Excel 가져오기</DialogTitle>
+ <DialogDescription>
+ Excel 파일을 업로드하여 품목을 일괄 추가합니다.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="space-y-4">
+ <div>
+ <Label htmlFor="import-file">Excel 파일 선택</Label>
+ <Input
+ id="import-file"
+ type="file"
+ accept=".xlsx,.xls"
+ onChange={handleImportFileSelect}
+ className="mt-2"
+ disabled={isImporting}
+ />
+ {importFile && (
+ <p className="text-sm text-muted-foreground mt-2">
+ 선택된 파일: {importFile.name}
+ </p>
+ )}
+ </div>
+
+ {importErrors.length > 0 && (
+ <div className="space-y-2">
+ <Label className="text-destructive">오류 목록</Label>
+ <div className="max-h-60 overflow-y-auto border rounded-md p-3 bg-destructive/5">
+ <ul className="list-disc list-inside space-y-1">
+ {importErrors.map((error, index) => (
+ <li key={index} className="text-sm text-destructive">
+ {error}
+ </li>
+ ))}
+ </ul>
+ </div>
+ </div>
+ )}
+
+ <div className="text-sm text-muted-foreground space-y-1">
+ <p className="font-semibold">필수 컬럼:</p>
+ <ul className="list-disc list-inside ml-2">
+ <li>자재그룹코드, 자재그룹명</li>
+ <li>수량 또는 중량 (둘 중 하나 필수)</li>
+ <li>수량단위 또는 중량단위</li>
+ <li>납품요청일 (YYYY-MM-DD 형식)</li>
+ <li>내정단가</li>
+ </ul>
+ </div>
+ </div>
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => {
+ setImportDialogOpen(false)
+ setImportFile(null)
+ setImportErrors([])
+ }}
+ disabled={isImporting}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleImport}
+ disabled={!importFile || isImporting}
+ >
+ {isImporting ? (
+ <>
+ <RefreshCw className="w-4 h-4 mr-2 animate-spin" />
+ 가져오는 중...
+ </>
+ ) : (
+ "가져오기"
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
</div>
)
diff --git a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx
index de3c19ff..1ab7a40f 100644
--- a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx
+++ b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx
@@ -465,7 +465,7 @@ export function CreatePreQuoteRfqDialog({
)}
>
{field.value ? (
- format(field.value, "yyyy-MM-dd")
+ format(field.value, "yyyy-MM-dd HH:mm")
) : (
<span>제출마감일을 선택하세요 (선택)</span>
)}
@@ -477,12 +477,40 @@ export function CreatePreQuoteRfqDialog({
<Calendar
mode="single"
selected={field.value}
- onSelect={field.onChange}
- disabled={(date) =>
- date < new Date() || date < new Date("1900-01-01")
- }
+ onSelect={(date) => {
+ if (!date) {
+ field.onChange(undefined)
+ return
+ }
+ const newDate = new Date(date)
+ if (field.value) {
+ newDate.setHours(field.value.getHours(), field.value.getMinutes())
+ } else {
+ newDate.setHours(0, 0, 0, 0)
+ }
+ field.onChange(newDate)
+ }}
+ disabled={(date) => {
+ const today = new Date()
+ today.setHours(0, 0, 0, 0)
+ return date < today || date < new Date("1900-01-01")
+ }}
initialFocus
/>
+ <div className="p-3 border-t border-border">
+ <Input
+ type="time"
+ value={field.value ? format(field.value, "HH:mm") : ""}
+ onChange={(e) => {
+ if (field.value) {
+ const [hours, minutes] = e.target.value.split(':').map(Number)
+ const newDate = new Date(field.value)
+ newDate.setHours(hours, minutes)
+ field.onChange(newDate)
+ }
+ }}
+ />
+ </div>
</PopoverContent>
</Popover>
<FormMessage />
diff --git a/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx
index 84fd85ff..a1b98468 100644
--- a/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx
+++ b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx
@@ -23,6 +23,7 @@ export interface ProcurementItemSelectorDialogSingleProps {
title?: string;
description?: string;
showConfirmButtons?: boolean;
+ disabled?: boolean;
}
/**
@@ -78,6 +79,7 @@ export function ProcurementItemSelectorDialogSingle({
title = "1회성 품목 선택",
description = "1회성 품목을 검색하고 선택해주세요.",
showConfirmButtons = false,
+ disabled = false,
}: ProcurementItemSelectorDialogSingleProps) {
const [open, setOpen] = useState(false);
const [tempSelectedProcurementItem, setTempSelectedProcurementItem] =
@@ -128,7 +130,7 @@ export function ProcurementItemSelectorDialogSingle({
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
- <Button variant={triggerVariant} size={triggerSize}>
+ <Button variant={triggerVariant} size={triggerSize} disabled={disabled}>
{selectedProcurementItem ? (
<span className="truncate">
{`${selectedProcurementItem.itemCode}`}
diff --git a/db/schema/bidding.ts b/db/schema/bidding.ts
index c08ea921..fa3f1df5 100644
--- a/db/schema/bidding.ts
+++ b/db/schema/bidding.ts
@@ -297,7 +297,7 @@ export const prItemsForBidding = pgTable('pr_items_for_bidding', {
// 수량 및 중량
quantity: decimal('quantity', { precision: 10, scale: 3 }), // 수량
- quantityUnit: varchar('quantity_unit', { length: 50 }), // 수량단위 (구매단위)
+ quantityUnit: varchar('quantity_unit', { length: 50 }), // 수량단위
totalWeight: decimal('total_weight', { precision: 10, scale: 3 }), // 총 중량
weightUnit: varchar('weight_unit', { length: 50 }), // 중량단위 (자재순중량)
diff --git a/lib/bidding/actions.ts b/lib/bidding/actions.ts
index 4e7da36c..cc246ee7 100644
--- a/lib/bidding/actions.ts
+++ b/lib/bidding/actions.ts
@@ -125,6 +125,11 @@ export async function transmitToContract(biddingId: number, userId: number) {
const contractNumber = await generateContractNumber(safeUserId, biddingData.contractType)
console.log('Generated contractNumber:', contractNumber)
+ // 연동제 여부 변환 (boolean -> Y/N)
+ const interlockingSystem = biddingCondition?.isPriceAdjustmentApplicable
+ ? 'Y'
+ : (biddingCondition?.isPriceAdjustmentApplicable === false ? 'N' : null)
+
// general-contract 생성 (발주비율 계산된 최종 금액 사용)
const contractResult = await db.insert(generalContracts).values({
contractNumber,
@@ -141,10 +146,13 @@ export async function transmitToContract(biddingId: number, userId: number) {
currency: biddingData.currency || 'KRW',
// 계약 조건 정보 추가
paymentTerm: biddingCondition?.paymentTerms || null,
+ paymentDelivery: biddingCondition?.paymentTerms || null, // 지급조건 (납품 지급조건)
taxType: biddingCondition?.taxConditions || 'V0',
deliveryTerm: biddingCondition?.incoterms || 'FOB',
shippingLocation: biddingCondition?.shippingPort || null,
dischargeLocation: biddingCondition?.destinationPort || null,
+ contractDeliveryDate: biddingCondition?.contractDeliveryDate || null, // 계약납기일
+ interlockingSystem: interlockingSystem, // 연동제 여부
registeredById: userId,
lastUpdatedById: userId,
}).returning({ id: generalContracts.id })
diff --git a/lib/bidding/approval-actions.ts b/lib/bidding/approval-actions.ts
index 3d07d49c..49b9f847 100644
--- a/lib/bidding/approval-actions.ts
+++ b/lib/bidding/approval-actions.ts
@@ -81,6 +81,7 @@ export async function prepareBiddingApprovalData(data: {
projectName: biddings.projectName,
itemName: biddings.itemName,
biddingType: biddings.biddingType,
+ awardCount: biddings.awardCount,
bidPicName: biddings.bidPicName,
supplyPicName: biddings.supplyPicName,
submissionStartDate: biddings.submissionStartDate,
@@ -166,6 +167,7 @@ export async function prepareBiddingApprovalData(data: {
...bidding,
projectName: bidding.projectName || undefined,
itemName: bidding.itemName || undefined,
+ awardCount: bidding.awardCount || undefined,
bidPicName: bidding.bidPicName || undefined,
supplyPicName: bidding.supplyPicName || undefined,
targetPrice: bidding.targetPrice ? Number(bidding.targetPrice) : undefined,
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts
index f52ecb1e..a0aa3378 100644
--- a/lib/bidding/detail/service.ts
+++ b/lib/bidding/detail/service.ts
@@ -3,7 +3,7 @@
import db from '@/db/db'
import { biddings, prItemsForBidding, biddingDocuments, biddingCompanies, vendors, companyPrItemBids, companyConditionResponses, vendorSelectionResults, priceAdjustmentForms, users, vendorContacts } from '@/db/schema'
import { specificationMeetings, biddingCompaniesContacts } from '@/db/schema/bidding'
-import { eq, and, sql, desc, ne, asc } from 'drizzle-orm'
+import { eq, and, sql, desc, ne, asc, inArray } from 'drizzle-orm'
import { revalidatePath, revalidateTag } from 'next/cache'
import { unstable_cache } from "@/lib/unstable-cache";
import { sendEmail } from '@/lib/mail/sendEmail'
@@ -2596,101 +2596,72 @@ export async function getBiddingDocumentsForPartners(biddingId: number) {
// 입찰가 비교 분석 함수들
// =================================================
-// 벤더별 입찰가 정보 조회 (캐시 적용)
+// 벤더별 입찰가 정보 조회 (최적화 및 간소화됨)
export async function getVendorPricesForBidding(biddingId: number) {
- return unstable_cache(
- async () => {
- try {
- // 각 회사의 입찰가 정보를 조회 - 본입찰 참여 업체들
- const vendorPrices = await db
- .select({
- companyId: biddingCompanies.companyId,
- companyName: vendors.vendorName,
- biddingCompanyId: biddingCompanies.id,
- currency: sql<string>`'KRW'`, // 기본값 KRW
- finalQuoteAmount: biddingCompanies.finalQuoteAmount,
- isBiddingParticipated: biddingCompanies.isBiddingParticipated,
- })
- .from(biddingCompanies)
- .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
- .where(and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.isBiddingParticipated, true), // 본입찰 참여 업체만
- sql`${biddingCompanies.finalQuoteAmount} IS NOT NULL` // 입찰가를 제출한 업체만
- ))
+ try {
+ // 1. 본입찰 참여 업체들 조회
+ const participatingVendors = await db
+ .select({
+ companyId: biddingCompanies.companyId,
+ companyName: vendors.vendorName,
+ biddingCompanyId: biddingCompanies.id,
+ currency: sql<string>`'KRW'`, // 기본값 KRW
+ finalQuoteAmount: biddingCompanies.finalQuoteAmount,
+ isBiddingParticipated: biddingCompanies.isBiddingParticipated,
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.isBiddingParticipated, true) // 본입찰 참여 업체만
+ ))
- console.log(`Found ${vendorPrices.length} vendors for bidding ${biddingId}`)
+ if (participatingVendors.length === 0) {
+ return []
+ }
- const result: any[] = []
+ const biddingCompanyIds = participatingVendors.map(v => v.biddingCompanyId)
- for (const vendor of vendorPrices) {
- try {
- // 해당 회사의 품목별 입찰가 조회 (본입찰 데이터)
- const itemPrices = await db
- .select({
- prItemId: companyPrItemBids.prItemId,
- itemName: prItemsForBidding.itemInfo, // itemInfo 사용
- itemNumber: prItemsForBidding.itemNumber, // itemNumber도 포함
- quantity: prItemsForBidding.quantity,
- quantityUnit: prItemsForBidding.quantityUnit,
- weight: prItemsForBidding.totalWeight, // totalWeight 사용
- weightUnit: prItemsForBidding.weightUnit,
- unitPrice: companyPrItemBids.bidUnitPrice,
- amount: companyPrItemBids.bidAmount,
- proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate,
- })
- .from(companyPrItemBids)
- .leftJoin(prItemsForBidding, eq(companyPrItemBids.prItemId, prItemsForBidding.id))
- .where(and(
- eq(companyPrItemBids.biddingCompanyId, vendor.biddingCompanyId),
- eq(companyPrItemBids.isPreQuote, false) // 본입찰 데이터만
- ))
- .orderBy(prItemsForBidding.id)
-
- console.log(`Vendor ${vendor.companyName}: Found ${itemPrices.length} item prices`)
-
- // 총 금액은 biddingCompanies.finalQuoteAmount 사용
- const totalAmount = parseFloat(vendor.finalQuoteAmount || '0')
-
- result.push({
- companyId: vendor.companyId,
- companyName: vendor.companyName || `Vendor ${vendor.companyId}`,
- biddingCompanyId: vendor.biddingCompanyId,
- totalAmount,
- currency: vendor.currency,
- itemPrices: itemPrices.map(item => ({
- prItemId: item.prItemId,
- itemName: item.itemName || item.itemNumber || `Item ${item.prItemId}`,
- quantity: parseFloat(item.quantity || '0'),
- quantityUnit: item.quantityUnit || 'ea',
- weight: item.weight ? parseFloat(item.weight) : null,
- weightUnit: item.weightUnit,
- unitPrice: parseFloat(item.unitPrice || '0'),
- amount: parseFloat(item.amount || '0'),
- proposedDeliveryDate: item.proposedDeliveryDate ?
- (typeof item.proposedDeliveryDate === 'string'
- ? item.proposedDeliveryDate
- : item.proposedDeliveryDate.toISOString().split('T')[0])
- : null,
- }))
- })
- } catch (vendorError) {
- console.error(`Error processing vendor ${vendor.companyId}:`, vendorError)
- // 벤더 처리 중 에러가 발생해도 다른 벤더들은 계속 처리
- }
- }
+ // 2. 해당 업체들의 입찰 품목 조회 (한 번의 쿼리로 최적화)
+ // 필요한 필드만 조회: prItemId, bidUnitPrice, bidAmount
+ const allItemBids = await db
+ .select({
+ biddingCompanyId: companyPrItemBids.biddingCompanyId,
+ prItemId: companyPrItemBids.prItemId,
+ bidUnitPrice: companyPrItemBids.bidUnitPrice,
+ bidAmount: companyPrItemBids.bidAmount,
+ })
+ .from(companyPrItemBids)
+ .where(and(
+ inArray(companyPrItemBids.biddingCompanyId, biddingCompanyIds),
+ eq(companyPrItemBids.isPreQuote, false) // 본입찰 데이터만
+ ))
- return result
- } catch (error) {
- console.error('Failed to get vendor prices for bidding:', error)
- return []
+ // 3. 업체별로 데이터 매핑
+ const result = participatingVendors.map(vendor => {
+ const vendorItems = allItemBids.filter(item => item.biddingCompanyId === vendor.biddingCompanyId)
+
+ const totalAmount = parseFloat(vendor.finalQuoteAmount || '0')
+
+ return {
+ companyId: vendor.companyId,
+ companyName: vendor.companyName || `Vendor ${vendor.companyId}`,
+ biddingCompanyId: vendor.biddingCompanyId,
+ totalAmount,
+ currency: vendor.currency,
+ itemPrices: vendorItems.map(item => ({
+ prItemId: item.prItemId,
+ unitPrice: parseFloat(item.bidUnitPrice || '0'),
+ amount: parseFloat(item.bidAmount || '0'),
+ }))
}
- },
- [`bidding-vendor-prices-${biddingId}`],
- {
- tags: [`bidding-${biddingId}`, 'quotation-vendors', 'pr-items']
- }
- )()
+ })
+
+ return result
+ } catch (error) {
+ console.error('Failed to get vendor prices for bidding:', error)
+ return []
+ }
}
// 사양설명회 참여 여부 업데이트
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
index fffac0c1..a6f64964 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
@@ -27,6 +27,7 @@ interface BiddingDetailVendorTableContentProps {
onOpenSelectionReasonDialog: () => void
onViewItemDetails?: (vendor: QuotationVendor) => void
onViewQuotationHistory?: (vendor: QuotationVendor) => void
+ readOnly?: boolean
}
const filterFields: DataTableFilterField<QuotationVendor>[] = [
@@ -86,7 +87,8 @@ export function BiddingDetailVendorTableContent({
vendors,
onRefresh,
onViewItemDetails,
- onViewQuotationHistory
+ onViewQuotationHistory,
+ readOnly = false
}: BiddingDetailVendorTableContentProps) {
const { data: session } = useSession()
const { toast } = useToast()
@@ -269,6 +271,7 @@ export function BiddingDetailVendorTableContent({
onSuccess={onRefresh}
winnerVendor={vendors.find(v => v.awardRatio === 100)}
singleSelectedVendor={singleSelectedVendor}
+ readOnly={readOnly}
/>
</DataTableAdvancedToolbar>
</DataTable>
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
index 8df29289..7e571a23 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
@@ -25,6 +25,7 @@ interface BiddingDetailVendorToolbarActionsProps {
onSuccess: () => void
winnerVendor?: QuotationVendor | null // 100% 낙찰된 벤더
singleSelectedVendor?: QuotationVendor | null // single select된 벤더
+ readOnly?: boolean
}
export function BiddingDetailVendorToolbarActions({
@@ -35,7 +36,8 @@ export function BiddingDetailVendorToolbarActions({
onOpenAwardRatioDialog,
onSuccess,
winnerVendor,
- singleSelectedVendor
+ singleSelectedVendor,
+ readOnly = false
}: BiddingDetailVendorToolbarActionsProps) {
const router = useRouter()
const { toast } = useToast()
@@ -82,53 +84,6 @@ export function BiddingDetailVendorToolbarActions({
setIsBiddingInvitationDialogOpen(true)
}
- // const handleBiddingInvitationSend = async (data: any) => {
- // try {
- // // 1. 기본계약 발송
- // const contractResult = await sendBiddingBasicContracts(
- // biddingId,
- // data.vendors,
- // data.generatedPdfs,
- // data.message
- // )
-
- // if (!contractResult.success) {
- // toast({
- // title: '기본계약 발송 실패',
- // description: contractResult.error,
- // variant: 'destructive',
- // })
- // return
- // }
-
- // // 2. 입찰 등록 진행
- // const registerResult = await registerBidding(bidding.id, userId)
-
- // if (registerResult.success) {
- // toast({
- // title: '본입찰 초대 완료',
- // description: '기본계약 발송 및 본입찰 초대가 완료되었습니다.',
- // })
- // setIsBiddingInvitationDialogOpen(false)
- // router.refresh()
- // onSuccess()
- // } else {
- // toast({
- // title: '오류',
- // description: registerResult.error,
- // variant: 'destructive',
- // })
- // }
- // } catch (error) {
- // console.error('본입찰 초대 실패:', error)
- // toast({
- // title: '오류',
- // description: '본입찰 초대에 실패했습니다.',
- // variant: 'destructive',
- // })
- // }
- // }
-
// 선정된 업체들 조회 (서버 액션 함수 사용)
const getSelectedVendors = async () => {
try {
@@ -165,27 +120,6 @@ export function BiddingDetailVendorToolbarActions({
})
}
- const handleRoundIncrease = () => {
- startTransition(async () => {
- const result = await increaseRoundOrRebid(bidding.id, userId, 'round_increase')
-
- if (result.success) {
- toast({
- title: "성공",
- description: result.message,
- })
- router.push(`/evcp/bid`)
- onSuccess()
- } else {
- toast({
- title: "오류",
- description: result.error || "차수증가 중 오류가 발생했습니다.",
- variant: 'destructive',
- })
- }
- })
- }
-
const handleCancelAward = () => {
if (!winnerVendor) return
@@ -233,69 +167,74 @@ export function BiddingDetailVendorToolbarActions({
return (
<>
<div className="flex items-center gap-2">
- {/* 상태별 액션 버튼 */}
- {/* 차수증가: 입찰공고 또는 입찰평가중 상태에서만 */}
- {(bidding.status === 'evaluation_of_bidding' || bidding.status === 'bidding_opened') && (
- <Button
- variant="outline"
- size="sm"
- onClick={() => setIsRoundIncreaseDialogOpen(true)}
- disabled={isPending}
- >
- <RotateCw className="mr-2 h-4 w-4" />
- 차수증가
- </Button>
- )}
-
- {/* 발주비율 산정: single select 시에만 활성화 */}
- {(bidding.status === 'evaluation_of_bidding') && (
- <Button
- variant="outline"
- size="sm"
- onClick={onOpenAwardRatioDialog}
- disabled={!singleSelectedVendor || isPending || singleSelectedVendor.isBiddingParticipated !== true}
- >
- <DollarSign className="mr-2 h-4 w-4" />
- 발주비율 산정
- </Button>
- )}
-
- {/* 유찰/낙찰: 입찰공고 또는 입찰평가중 상태에서만 */}
- {(bidding.status === 'bidding_opened' || bidding.status === 'evaluation_of_bidding') && (
+ {/* 상태별 액션 버튼 - 읽기 전용이 아닐 때만 표시 */}
+ {!readOnly && (
<>
- <Button
- variant="destructive"
- size="sm"
- onClick={handleMarkAsDisposal}
- disabled={isPending}
- >
- <XCircle className="mr-2 h-4 w-4" />
- 유찰
- </Button>
- <Button
- variant="default"
- size="sm"
- onClick={onOpenAwardDialog}
- disabled={isPending}
- >
- <Trophy className="mr-2 h-4 w-4" />
- 낙찰
- </Button>
+ {/* 차수증가: 입찰공고 또는 입찰평가중 상태에서만 */}
+ {(bidding.status === 'evaluation_of_bidding' || bidding.status === 'bidding_opened') && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsRoundIncreaseDialogOpen(true)}
+ disabled={isPending}
+ >
+ <RotateCw className="mr-2 h-4 w-4" />
+ 차수증가
+ </Button>
+ )}
+
+ {/* 발주비율 산정: single select 시에만 활성화 */}
+ {(bidding.status === 'evaluation_of_bidding') && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onOpenAwardRatioDialog}
+ disabled={!singleSelectedVendor || isPending || singleSelectedVendor.isBiddingParticipated !== true}
+ >
+ <DollarSign className="mr-2 h-4 w-4" />
+ 발주비율 산정
+ </Button>
+ )}
+
+ {/* 유찰/낙찰: 입찰공고 또는 입찰평가중 상태에서만 */}
+ {(bidding.status === 'bidding_opened' || bidding.status === 'evaluation_of_bidding') && (
+ <>
+ <Button
+ variant="destructive"
+ size="sm"
+ onClick={handleMarkAsDisposal}
+ disabled={isPending}
+ >
+ <XCircle className="mr-2 h-4 w-4" />
+ 유찰
+ </Button>
+ <Button
+ variant="default"
+ size="sm"
+ onClick={onOpenAwardDialog}
+ disabled={isPending}
+ >
+ <Trophy className="mr-2 h-4 w-4" />
+ 낙찰
+ </Button>
+ </>
+ )}
+
+ {/* 발주비율 취소: 100% 낙찰된 벤더가 있는 경우 */}
+ {winnerVendor && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsCancelAwardDialogOpen(true)}
+ disabled={isPending}
+ >
+ <RotateCcw className="mr-2 h-4 w-4" />
+ 발주비율 취소
+ </Button>
+ )}
</>
)}
- {/* 발주비율 취소: 100% 낙찰된 벤더가 있는 경우 */}
- {winnerVendor && (
- <Button
- variant="outline"
- size="sm"
- onClick={() => setIsCancelAwardDialogOpen(true)}
- disabled={isPending}
- >
- <RotateCcw className="mr-2 h-4 w-4" />
- 발주비율 취소
- </Button>
- )}
{/* 구분선 */}
{(bidding.status === 'bidding_generated' ||
bidding.status === 'bidding_disposal') && (
diff --git a/lib/bidding/handlers.ts b/lib/bidding/handlers.ts
index 11955a39..c64d9527 100644
--- a/lib/bidding/handlers.ts
+++ b/lib/bidding/handlers.ts
@@ -127,6 +127,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: {
biddingNumber: string;
projectName?: string;
itemName?: string;
+ awardCount: string;
biddingType: string;
bidPicName?: string;
supplyPicName?: string;
@@ -181,7 +182,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: {
const { bidding, biddingItems, vendors, message, specificationMeeting, requestedAt } = payload;
// 제목
- const title = bidding.title || '입찰';
+ const title = bidding.title || '';
// 입찰명
const biddingTitle = bidding.title || '';
@@ -190,7 +191,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: {
const biddingNumber = bidding.biddingNumber || '';
// 낙찰업체수
- const winnerCount = '1'; // 기본값, 실제로는 bidding 설정에서 가져와야 함
+ const awardCount = bidding.awardCount || '';
// 계약구분
const contractType = bidding.biddingType || '';
@@ -199,7 +200,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: {
const prNumber = '';
// 예산
- const budget = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : '';
+ const budget = bidding.budget ? bidding.budget.toLocaleString() : '';
// 내정가
const targetPrice = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : '';
@@ -272,7 +273,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: {
제목: title,
입찰명: biddingTitle,
입찰번호: biddingNumber,
- 낙찰업체수: winnerCount,
+ 낙찰업체수: awardCount,
계약구분: contractType,
'P/R번호': prNumber,
예산: budget,
@@ -637,6 +638,7 @@ export async function mapBiddingAwardToTemplateVariables(payload: {
biddingType: biddings.biddingType,
bidPicName: biddings.bidPicName,
supplyPicName: biddings.supplyPicName,
+ budget: biddings.budget,
targetPrice: biddings.targetPrice,
awardCount: biddings.awardCount,
})
@@ -684,7 +686,7 @@ export async function mapBiddingAwardToTemplateVariables(payload: {
const biddingNumber = bidding.biddingNumber || '';
const winnerCount = (bidding.awardCount === 'single' ? 1 : bidding.awardCount === 'multiple' ? 2 : 1).toString();
const contractType = bidding.biddingType || '';
- const budget = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : '';
+ const budget = bidding.budget ? bidding.budget.toLocaleString() : '';
const targetPrice = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : '';
const biddingManager = bidding.bidPicName || bidding.supplyPicName || '';
const biddingOverview = bidding.itemName || '';
diff --git a/lib/bidding/list/biddings-table-toolbar-actions.tsx b/lib/bidding/list/biddings-table-toolbar-actions.tsx
index 33368218..b0007c8c 100644
--- a/lib/bidding/list/biddings-table-toolbar-actions.tsx
+++ b/lib/bidding/list/biddings-table-toolbar-actions.tsx
@@ -7,7 +7,7 @@ import {
} from "lucide-react"
import { toast } from "sonner"
import { useSession } from "next-auth/react"
-import { exportTableToExcel } from "@/lib/export"
+import { exportBiddingsToExcel } from "./export-biddings-to-excel"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
@@ -92,6 +92,23 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio
return selectedBiddings.length === 1 && selectedBiddings[0].status === 'bidding_generated'
}, [selectedBiddings])
+ // Excel 내보내기 핸들러
+ const handleExport = React.useCallback(async () => {
+ try {
+ setIsExporting(true)
+ await exportBiddingsToExcel(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">
@@ -100,6 +117,17 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio
// 성공 시 테이블 새로고침 등 추가 작업
// window.location.reload()
}} />
+ {/* 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="default"
@@ -112,20 +140,16 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio
<span className="hidden sm:inline">전송하기</span>
</Button>
{/* 삭제 버튼 */}
-
- <Button
- variant="destructive"
- size="sm"
- onClick={() => setIsDeleteDialogOpen(true)}
- disabled={!canDelete}
- className="gap-2"
- >
- <Trash className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">삭제</span>
- </Button>
-
-
-
+ <Button
+ variant="destructive"
+ size="sm"
+ onClick={() => setIsDeleteDialogOpen(true)}
+ disabled={!canDelete}
+ className="gap-2"
+ >
+ <Trash className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">삭제</span>
+ </Button>
</div>
{/* 전송 다이얼로그 */}
diff --git a/lib/bidding/list/export-biddings-to-excel.ts b/lib/bidding/list/export-biddings-to-excel.ts
new file mode 100644
index 00000000..8b13e38d
--- /dev/null
+++ b/lib/bidding/list/export-biddings-to-excel.ts
@@ -0,0 +1,212 @@
+import { type Table } from "@tanstack/react-table"
+import ExcelJS from "exceljs"
+import { BiddingListItem } from "@/db/schema"
+import {
+ biddingStatusLabels,
+ contractTypeLabels,
+ biddingTypeLabels,
+} from "@/db/schema"
+import { formatDate } from "@/lib/utils"
+
+// BiddingListItem 확장 타입 (manager 정보 포함)
+type BiddingListItemWithManagerCode = BiddingListItem & {
+ bidPicName?: string | null
+ supplyPicName?: string | null
+}
+
+/**
+ * 입찰 목록을 Excel로 내보내기
+ * - 계약구분, 진행상태, 입찰유형은 라벨(명칭)로 변환
+ * - 입찰서 제출기간은 submissionStartDate, submissionEndDate 기준
+ * - 등록일시는 년, 월, 일 형식
+ */
+export async function exportBiddingsToExcel(
+ table: Table<BiddingListItemWithManagerCode>,
+ {
+ filename = "입찰목록",
+ onlySelected = false,
+ }: {
+ filename?: string
+ onlySelected?: boolean
+ } = {}
+): Promise<void> {
+ // 테이블에서 실제 사용 중인 leaf columns 가져오기
+ const allColumns = table.getAllLeafColumns()
+
+ // select, actions 컬럼 제외
+ const columns = allColumns.filter(
+ (col) => !["select", "actions"].includes(col.id)
+ )
+
+ // 헤더 행 생성 (excelHeader 사용)
+ const headerRow = columns.map((col) => {
+ const excelHeader = (col.columnDef.meta as any)?.excelHeader
+ return typeof excelHeader === "string" ? excelHeader : 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 "biddingType":
+ // 입찰유형: 라벨로 변환
+ value = biddingTypeLabels[original.biddingType as keyof typeof biddingTypeLabels] || original.biddingType
+ break
+
+ case "submissionPeriod":
+ // 입찰서 제출기간: 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 "updatedAt":
+ // 등록일시: 년, 월, 일 형식만
+ if (original.updatedAt) {
+ value = formatDate(original.updatedAt, "KR")
+ } else {
+ value = "-"
+ }
+ break
+
+ case "biddingRegistrationDate":
+ // 입찰등록일: 년, 월, 일 형식만
+ if (original.biddingRegistrationDate) {
+ value = formatDate(original.biddingRegistrationDate, "KR")
+ } else {
+ value = "-"
+ }
+ break
+
+ case "projectName":
+ // 프로젝트: 코드와 이름 조합
+ const code = original.projectCode
+ const name = original.projectName
+ value = code && name ? `${code} (${name})` : (code || name || "-")
+ break
+
+ case "hasSpecificationMeeting":
+ // 사양설명회: Yes/No
+ value = original.hasSpecificationMeeting ? "Yes" : "No"
+ 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/manage/export-bidding-items-to-excel.ts b/lib/bidding/manage/export-bidding-items-to-excel.ts
new file mode 100644
index 00000000..814648a7
--- /dev/null
+++ b/lib/bidding/manage/export-bidding-items-to-excel.ts
@@ -0,0 +1,161 @@
+import ExcelJS from "exceljs"
+import { PRItemInfo } from "@/components/bidding/manage/bidding-items-editor"
+import { getProjectCodesByIds } from "./project-utils"
+
+/**
+ * 입찰품목 목록을 Excel로 내보내기
+ */
+export async function exportBiddingItemsToExcel(
+ items: PRItemInfo[],
+ {
+ filename = "입찰품목목록",
+ }: {
+ filename?: string
+ } = {}
+): Promise<void> {
+ // 프로젝트 ID 목록 수집
+ const projectIds = items
+ .map((item) => item.projectId)
+ .filter((id): id is number => id != null && id > 0)
+
+ // 프로젝트 코드 맵 조회
+ const projectCodeMap = await getProjectCodesByIds(projectIds)
+
+ // 헤더 정의
+ const headers = [
+ "프로젝트코드",
+ "프로젝트명",
+ "자재그룹코드",
+ "자재그룹명",
+ "자재코드",
+ "자재명",
+ "수량",
+ "수량단위",
+ "중량",
+ "중량단위",
+ "납품요청일",
+ "가격단위",
+ "구매단위",
+ "자재순중량",
+ "내정단가",
+ "내정금액",
+ "내정통화",
+ "예산금액",
+ "예산통화",
+ "실적금액",
+ "실적통화",
+ "WBS코드",
+ "WBS명",
+ "코스트센터코드",
+ "코스트센터명",
+ "GL계정코드",
+ "GL계정명",
+ "PR번호",
+ ]
+
+ // 데이터 행 생성
+ const dataRows = items.map((item) => {
+ // 프로젝트 코드 조회
+ const projectCode = item.projectId
+ ? projectCodeMap.get(item.projectId) || ""
+ : ""
+
+ return [
+ projectCode,
+ item.projectInfo || "",
+ item.materialGroupNumber || "",
+ item.materialGroupInfo || "",
+ item.materialNumber || "",
+ item.materialInfo || "",
+ item.quantity || "",
+ item.quantityUnit || "",
+ item.totalWeight || "",
+ item.weightUnit || "",
+ item.requestedDeliveryDate || "",
+ item.priceUnit || "",
+ item.purchaseUnit || "",
+ item.materialWeight || "",
+ item.targetUnitPrice || "",
+ item.targetAmount || "",
+ item.targetCurrency || "KRW",
+ item.budgetAmount || "",
+ item.budgetCurrency || "KRW",
+ item.actualAmount || "",
+ item.actualCurrency || "KRW",
+ item.wbsCode || "",
+ item.wbsName || "",
+ item.costCenterCode || "",
+ item.costCenterName || "",
+ item.glAccountCode || "",
+ item.glAccountName || "",
+ item.prNumber || "",
+ ]
+ })
+
+ // 최종 sheetData
+ const sheetData = [headers, ...dataRows]
+
+ // ExcelJS로 파일 생성 및 다운로드
+ await createAndDownloadExcel(sheetData, headers.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/manage/import-bidding-items-from-excel.ts b/lib/bidding/manage/import-bidding-items-from-excel.ts
new file mode 100644
index 00000000..2e0dfe33
--- /dev/null
+++ b/lib/bidding/manage/import-bidding-items-from-excel.ts
@@ -0,0 +1,271 @@
+import ExcelJS from "exceljs"
+import { PRItemInfo } from "@/components/bidding/manage/bidding-items-editor"
+import { getProjectIdByCodeAndName } from "./project-utils"
+
+export interface ImportBiddingItemsResult {
+ success: boolean
+ items: PRItemInfo[]
+ errors: string[]
+}
+
+/**
+ * Excel 파일에서 입찰품목 데이터 파싱
+ */
+export async function importBiddingItemsFromExcel(
+ file: File
+): Promise<ImportBiddingItemsResult> {
+ const errors: string[] = []
+ const items: PRItemInfo[] = []
+
+ try {
+ const workbook = new ExcelJS.Workbook()
+ const arrayBuffer = await file.arrayBuffer()
+ await workbook.xlsx.load(arrayBuffer)
+
+ const worksheet = workbook.worksheets[0]
+ if (!worksheet) {
+ return {
+ success: false,
+ items: [],
+ errors: ["Excel 파일에 시트가 없습니다."],
+ }
+ }
+
+ // 헤더 행 읽기 (첫 번째 행)
+ const headerRow = worksheet.getRow(1)
+ const headerValues = headerRow.values as ExcelJS.CellValue[]
+
+ // 헤더 매핑 생성
+ const headerMap: Record<string, number> = {}
+ const expectedHeaders = [
+ "프로젝트코드",
+ "프로젝트명",
+ "자재그룹코드",
+ "자재그룹명",
+ "자재코드",
+ "자재명",
+ "수량",
+ "수량단위",
+ "중량",
+ "중량단위",
+ "납품요청일",
+ "가격단위",
+ "구매단위",
+ "자재순중량",
+ "내정단가",
+ "내정금액",
+ "내정통화",
+ "예산금액",
+ "예산통화",
+ "실적금액",
+ "실적통화",
+ "WBS코드",
+ "WBS명",
+ "코스트센터코드",
+ "코스트센터명",
+ "GL계정코드",
+ "GL계정명",
+ "PR번호",
+ ]
+
+ // 헤더 인덱스 매핑
+ for (let i = 1; i < headerValues.length; i++) {
+ const headerValue = String(headerValues[i] || "").trim()
+ if (headerValue && expectedHeaders.includes(headerValue)) {
+ headerMap[headerValue] = i
+ }
+ }
+
+ // 필수 헤더 확인
+ const requiredHeaders = ["자재그룹코드", "자재그룹명"]
+ const missingHeaders = requiredHeaders.filter(
+ (h) => !headerMap[h]
+ )
+ if (missingHeaders.length > 0) {
+ errors.push(
+ `필수 컬럼이 없습니다: ${missingHeaders.join(", ")}`
+ )
+ }
+
+ // 데이터 행 읽기 (2번째 행부터)
+ for (let rowIndex = 2; rowIndex <= worksheet.rowCount; rowIndex++) {
+ const row = worksheet.getRow(rowIndex)
+ const rowValues = row.values as ExcelJS.CellValue[]
+
+ // 빈 행 건너뛰기
+ if (rowValues.every((val) => !val || String(val).trim() === "")) {
+ continue
+ }
+
+ // 셀 값 추출 헬퍼
+ const getCellValue = (headerName: string): string => {
+ const colIndex = headerMap[headerName]
+ if (!colIndex) return ""
+ const value = rowValues[colIndex]
+ if (value == null) return ""
+
+ // ExcelJS 객체 처리
+ if (typeof value === "object" && "text" in value) {
+ return String((value as any).text || "")
+ }
+
+ // 날짜 처리
+ if (value instanceof Date) {
+ return value.toISOString().split("T")[0]
+ }
+
+ return String(value).trim()
+ }
+
+ // 필수값 검증
+ const materialGroupNumber = getCellValue("자재그룹코드")
+ const materialGroupInfo = getCellValue("자재그룹명")
+
+ if (!materialGroupNumber || !materialGroupInfo) {
+ errors.push(
+ `${rowIndex}번 행: 자재그룹코드와 자재그룹명은 필수입니다.`
+ )
+ continue
+ }
+
+ // 수량 또는 중량 검증
+ const quantity = getCellValue("수량")
+ const totalWeight = getCellValue("중량")
+ const quantityUnit = getCellValue("수량단위")
+ const weightUnit = getCellValue("중량단위")
+
+ if (!quantity && !totalWeight) {
+ errors.push(
+ `${rowIndex}번 행: 수량 또는 중량 중 하나는 필수입니다.`
+ )
+ continue
+ }
+
+ if (quantity && !quantityUnit) {
+ errors.push(
+ `${rowIndex}번 행: 수량이 있으면 수량단위가 필수입니다.`
+ )
+ continue
+ }
+
+ if (totalWeight && !weightUnit) {
+ errors.push(
+ `${rowIndex}번 행: 중량이 있으면 중량단위가 필수입니다.`
+ )
+ continue
+ }
+
+ // 납품요청일 검증
+ const requestedDeliveryDate = getCellValue("납품요청일")
+ if (!requestedDeliveryDate) {
+ errors.push(
+ `${rowIndex}번 행: 납품요청일은 필수입니다.`
+ )
+ continue
+ }
+
+ // 날짜 형식 검증
+ const dateRegex = /^\d{4}-\d{2}-\d{2}$/
+ if (requestedDeliveryDate && !dateRegex.test(requestedDeliveryDate)) {
+ errors.push(
+ `${rowIndex}번 행: 납품요청일 형식이 올바르지 않습니다. (YYYY-MM-DD 형식)`
+ )
+ continue
+ }
+
+ // 내정단가 검증 (필수)
+ const targetUnitPrice = getCellValue("내정단가")
+ if (!targetUnitPrice || parseFloat(targetUnitPrice.replace(/,/g, "")) <= 0) {
+ errors.push(
+ `${rowIndex}번 행: 내정단가는 필수이며 0보다 커야 합니다.`
+ )
+ continue
+ }
+
+ // 숫자 값 정리 (콤마 제거)
+ const cleanNumber = (value: string): string => {
+ return value.replace(/,/g, "").trim()
+ }
+
+ // 프로젝트 ID 조회 (프로젝트코드와 프로젝트명으로)
+ const projectCode = getCellValue("프로젝트코드")
+ const projectName = getCellValue("프로젝트명")
+ let projectId: number | null = null
+
+ if (projectCode && projectName) {
+ projectId = await getProjectIdByCodeAndName(projectCode, projectName)
+ if (!projectId) {
+ errors.push(
+ `${rowIndex}번 행: 프로젝트코드 "${projectCode}"와 프로젝트명 "${projectName}"에 해당하는 프로젝트를 찾을 수 없습니다.`
+ )
+ // 프로젝트를 찾지 못해도 계속 진행 (경고만 표시)
+ }
+ }
+
+ // PRItemInfo 객체 생성
+ const item: PRItemInfo = {
+ id: -(rowIndex - 1), // 임시 ID (음수)
+ prNumber: getCellValue("PR번호") || null,
+ projectId: projectId,
+ projectInfo: projectName || null,
+ shi: null,
+ quantity: quantity ? cleanNumber(quantity) : null,
+ quantityUnit: quantityUnit || null,
+ totalWeight: totalWeight ? cleanNumber(totalWeight) : null,
+ weightUnit: weightUnit || null,
+ materialDescription: null,
+ hasSpecDocument: false,
+ requestedDeliveryDate: requestedDeliveryDate || null,
+ isRepresentative: false,
+ annualUnitPrice: null,
+ currency: "KRW",
+ materialGroupNumber: materialGroupNumber || null,
+ materialGroupInfo: materialGroupInfo || null,
+ materialNumber: getCellValue("자재코드") || null,
+ materialInfo: getCellValue("자재명") || null,
+ priceUnit: getCellValue("가격단위") || "1",
+ purchaseUnit: getCellValue("구매단위") || "EA",
+ materialWeight: getCellValue("자재순중량") || null,
+ wbsCode: getCellValue("WBS코드") || null,
+ wbsName: getCellValue("WBS명") || null,
+ costCenterCode: getCellValue("코스트센터코드") || null,
+ costCenterName: getCellValue("코스트센터명") || null,
+ glAccountCode: getCellValue("GL계정코드") || null,
+ glAccountName: getCellValue("GL계정명") || null,
+ targetUnitPrice: cleanNumber(targetUnitPrice) || null,
+ targetAmount: getCellValue("내정금액")
+ ? cleanNumber(getCellValue("내정금액"))
+ : null,
+ targetCurrency: getCellValue("내정통화") || "KRW",
+ budgetAmount: getCellValue("예산금액")
+ ? cleanNumber(getCellValue("예산금액"))
+ : null,
+ budgetCurrency: getCellValue("예산통화") || "KRW",
+ actualAmount: getCellValue("실적금액")
+ ? cleanNumber(getCellValue("실적금액"))
+ : null,
+ actualCurrency: getCellValue("실적통화") || "KRW",
+ }
+
+ items.push(item)
+ }
+
+ return {
+ success: errors.length === 0,
+ items,
+ errors,
+ }
+ } catch (error) {
+ console.error("Excel import error:", error)
+ return {
+ success: false,
+ items: [],
+ errors: [
+ error instanceof Error
+ ? error.message
+ : "Excel 파일 파싱 중 오류가 발생했습니다.",
+ ],
+ }
+ }
+}
+
diff --git a/lib/bidding/manage/project-utils.ts b/lib/bidding/manage/project-utils.ts
new file mode 100644
index 00000000..92744695
--- /dev/null
+++ b/lib/bidding/manage/project-utils.ts
@@ -0,0 +1,87 @@
+'use server'
+
+import db from '@/db/db'
+import { projects } from '@/db/schema'
+import { eq, and, inArray } from 'drizzle-orm'
+
+/**
+ * 프로젝트 ID로 프로젝트 코드 조회
+ */
+export async function getProjectCodeById(projectId: number): Promise<string | null> {
+ try {
+ const result = await db
+ .select({ code: projects.code })
+ .from(projects)
+ .where(eq(projects.id, projectId))
+ .limit(1)
+
+ return result[0]?.code || null
+ } catch (error) {
+ console.error('Failed to get project code by id:', error)
+ return null
+ }
+}
+
+/**
+ * 프로젝트 코드와 이름으로 프로젝트 ID 조회
+ */
+export async function getProjectIdByCodeAndName(
+ projectCode: string,
+ projectName: string
+): Promise<number | null> {
+ try {
+ if (!projectCode || !projectName) {
+ return null
+ }
+
+ const result = await db
+ .select({ id: projects.id })
+ .from(projects)
+ .where(
+ and(
+ eq(projects.code, projectCode.trim()),
+ eq(projects.name, projectName.trim())
+ )
+ )
+ .limit(1)
+
+ return result[0]?.id || null
+ } catch (error) {
+ console.error('Failed to get project id by code and name:', error)
+ return null
+ }
+}
+
+/**
+ * 여러 프로젝트 ID로 프로젝트 코드 맵 조회 (성능 최적화)
+ */
+export async function getProjectCodesByIds(
+ projectIds: number[]
+): Promise<Map<number, string>> {
+ try {
+ if (projectIds.length === 0) {
+ return new Map()
+ }
+
+ const uniqueIds = [...new Set(projectIds.filter(id => id != null))]
+ if (uniqueIds.length === 0) {
+ return new Map()
+ }
+
+ const result = await db
+ .select({ id: projects.id, code: projects.code })
+ .from(projects)
+ .where(inArray(projects.id, uniqueIds))
+
+ const map = new Map<number, string>()
+ result.forEach((project) => {
+ map.set(project.id, project.code)
+ })
+
+ return map
+ } catch (error) {
+ console.error('Failed to get project codes by ids:', error)
+ return new Map()
+ }
+}
+
diff --git a/lib/bidding/selection/actions.ts b/lib/bidding/selection/actions.ts
index f19fbe6d..91550960 100644
--- a/lib/bidding/selection/actions.ts
+++ b/lib/bidding/selection/actions.ts
@@ -131,6 +131,75 @@ export async function saveSelectionResult(data: SaveSelectionResultData) {
}
}
+// 선정결과 조회
+export async function getSelectionResult(biddingId: number) {
+ try {
+ // 선정결과 조회 (selectedCompanyId가 null인 레코드)
+ const allResults = await db
+ .select()
+ .from(vendorSelectionResults)
+ .where(eq(vendorSelectionResults.biddingId, biddingId))
+
+ // @ts-ignore
+ const existingResult = allResults.filter((result: any) => result.selectedCompanyId === null).slice(0, 1)
+
+ if (existingResult.length === 0) {
+ return {
+ success: true,
+ data: {
+ summary: '',
+ attachments: []
+ }
+ }
+ }
+
+ const result = existingResult[0]
+
+ // 첨부파일 조회
+ const documents = await db
+ .select({
+ id: biddingDocuments.id,
+ fileName: biddingDocuments.fileName,
+ originalFileName: biddingDocuments.originalFileName,
+ fileSize: biddingDocuments.fileSize,
+ mimeType: biddingDocuments.mimeType,
+ filePath: biddingDocuments.filePath,
+ uploadedAt: biddingDocuments.uploadedAt
+ })
+ .from(biddingDocuments)
+ .where(and(
+ eq(biddingDocuments.biddingId, biddingId),
+ eq(biddingDocuments.documentType, 'selection_result')
+ ))
+
+ return {
+ success: true,
+ data: {
+ summary: result.evaluationSummary || '',
+ attachments: documents.map(doc => ({
+ id: doc.id,
+ fileName: doc.fileName || doc.originalFileName || '',
+ originalFileName: doc.originalFileName || '',
+ fileSize: doc.fileSize || 0,
+ mimeType: doc.mimeType || '',
+ filePath: doc.filePath || '',
+ uploadedAt: doc.uploadedAt
+ }))
+ }
+ }
+ } catch (error) {
+ console.error('Failed to get selection result:', error)
+ return {
+ success: false,
+ error: '선정결과 조회 중 오류가 발생했습니다.',
+ data: {
+ summary: '',
+ attachments: []
+ }
+ }
+ }
+}
+
// 견적 히스토리 조회
export async function getQuotationHistory(biddingId: number, vendorId: number) {
try {
diff --git a/lib/bidding/selection/bidding-info-card.tsx b/lib/bidding/selection/bidding-info-card.tsx
index 8864e7db..b363f538 100644
--- a/lib/bidding/selection/bidding-info-card.tsx
+++ b/lib/bidding/selection/bidding-info-card.tsx
@@ -56,7 +56,7 @@ export function BiddingInfoCard({ bidding }: BiddingInfoCardProps) {
입찰유형
</label>
<div className="text-sm font-medium">
- {bidding.isPublic ? '공개입찰' : '비공개입찰'}
+ {bidding.biddingType}
</div>
</div>
diff --git a/lib/bidding/selection/bidding-item-table.tsx b/lib/bidding/selection/bidding-item-table.tsx
new file mode 100644
index 00000000..c101f7e7
--- /dev/null
+++ b/lib/bidding/selection/bidding-item-table.tsx
@@ -0,0 +1,192 @@
+'use client'
+
+import * as React from 'react'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import {
+ getPRItemsForBidding,
+ getVendorPricesForBidding
+} from '@/lib/bidding/detail/service'
+import { formatNumber } from '@/lib/utils'
+import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
+
+interface BiddingItemTableProps {
+ biddingId: number
+}
+
+export function BiddingItemTable({ biddingId }: BiddingItemTableProps) {
+ const [data, setData] = React.useState<{
+ prItems: any[]
+ vendorPrices: any[]
+ }>({ prItems: [], vendorPrices: [] })
+ const [loading, setLoading] = React.useState(true)
+
+ React.useEffect(() => {
+ const loadData = async () => {
+ try {
+ setLoading(true)
+ const [prItems, vendorPrices] = await Promise.all([
+ getPRItemsForBidding(biddingId),
+ getVendorPricesForBidding(biddingId)
+ ])
+ console.log('prItems', prItems)
+ console.log('vendorPrices', vendorPrices)
+ setData({ prItems, vendorPrices })
+ } catch (error) {
+ console.error('Failed to load bidding items:', error)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ loadData()
+ }, [biddingId])
+
+ if (loading) {
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>응찰품목</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="flex items-center justify-center py-8">
+ <div className="text-sm text-muted-foreground">로딩 중...</div>
+ </div>
+ </CardContent>
+ </Card>
+ )
+ }
+
+ const { prItems, vendorPrices } = data
+
+ // Calculate Totals
+ const totalQuantity = prItems.reduce((sum, item) => sum + Number(item.quantity || 0), 0)
+ const totalWeight = prItems.reduce((sum, item) => sum + Number(item.totalWeight || 0), 0)
+ const totalTargetAmount = prItems.reduce((sum, item) => sum + Number(item.targetAmount || 0), 0)
+
+ // Calculate Vendor Totals
+ const vendorTotals = vendorPrices.map(vendor => {
+ const total = vendor.itemPrices.reduce((sum: number, item: any) => sum + Number(item.amount || 0), 0)
+ return {
+ companyId: vendor.companyId,
+ totalAmount: total
+ }
+ })
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>응찰품목</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <ScrollArea className="w-full whitespace-nowrap rounded-md border">
+ <div className="w-max min-w-full">
+ <table className="w-full caption-bottom text-sm">
+ <thead className="[&_tr]:border-b">
+ {/* Header Row 1: Base Info + Vendor Groups */}
+ <tr className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>자재번호</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>자재내역</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>자재내역상세</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>구매단위</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>수량</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>단위</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>총중량</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>중량단위</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>내정단가</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>내정액</th>
+ <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>통화</th>
+
+ {vendorPrices.map((vendor) => (
+ <th key={vendor.companyId} colSpan={4} className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r bg-muted/20">
+ {vendor.companyName}
+ </th>
+ ))}
+ </tr>
+ {/* Header Row 2: Vendor Sub-columns */}
+ <tr className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
+ {vendorPrices.map((vendor) => (
+ <React.Fragment key={vendor.companyId}>
+ <th className="h-10 px-2 text-center align-middle font-medium text-muted-foreground border-r bg-muted/10">단가</th>
+ <th className="h-10 px-2 text-center align-middle font-medium text-muted-foreground border-r bg-muted/10">총액</th>
+ <th className="h-10 px-2 text-center align-middle font-medium text-muted-foreground border-r bg-muted/10">통화</th>
+ <th className="h-10 px-2 text-center align-middle font-medium text-muted-foreground border-r bg-muted/10">내정액(%)</th>
+ </React.Fragment>
+ ))}
+ </tr>
+ </thead>
+ <tbody className="[&_tr:last-child]:border-0">
+ {/* Summary Row */}
+ <tr className="border-b transition-colors hover:bg-muted/50 bg-muted/30 font-semibold">
+ <td className="p-4 align-middle text-center border-r" colSpan={4}>합계</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(totalQuantity)}</td>
+ <td className="p-4 align-middle text-center border-r">-</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(totalWeight)}</td>
+ <td className="p-4 align-middle text-center border-r">-</td>
+ <td className="p-4 align-middle text-center border-r">-</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(totalTargetAmount)}</td>
+ <td className="p-4 align-middle text-center border-r">KRW</td>
+
+ {vendorPrices.map((vendor) => {
+ const vTotal = vendorTotals.find(t => t.companyId === vendor.companyId)?.totalAmount || 0
+ const ratio = totalTargetAmount > 0 ? (vTotal / totalTargetAmount) * 100 : 0
+ return (
+ <React.Fragment key={vendor.companyId}>
+ <td className="p-4 align-middle text-center border-r">-</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(vTotal)}</td>
+ <td className="p-4 align-middle text-center border-r">{vendor.currency}</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(ratio, 0)}%</td>
+ </React.Fragment>
+ )
+ })}
+ </tr>
+
+ {/* Data Rows */}
+ {prItems.map((item) => (
+ <tr key={item.id} className="border-b transition-colors hover:bg-muted/50">
+ <td className="p-4 align-middle border-r">{item.materialNumber}</td>
+ <td className="p-4 align-middle border-r min-w-[150px]">{item.materialInfo}</td>
+ <td className="p-4 align-middle border-r min-w-[150px]">{item.specification}</td>
+ <td className="p-4 align-middle text-center border-r">{item.purchaseUnit}</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(item.quantity)}</td>
+ <td className="p-4 align-middle text-center border-r">{item.quantityUnit}</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(item.totalWeight)}</td>
+ <td className="p-4 align-middle text-center border-r">{item.weightUnit}</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(item.targetUnitPrice)}</td>
+ <td className="p-4 align-middle text-right border-r">{formatNumber(item.targetAmount)}</td>
+ <td className="p-4 align-middle text-center border-r">{item.currency}</td>
+
+ {vendorPrices.map((vendor) => {
+ const bidItem = vendor.itemPrices.find((p: any) => p.prItemId === item.id)
+ const bidAmount = bidItem ? bidItem.amount : 0
+ const targetAmt = Number(item.targetAmount || 0)
+ const ratio = targetAmt > 0 && bidAmount > 0 ? (bidAmount / targetAmt) * 100 : 0
+
+ return (
+ <React.Fragment key={vendor.companyId}>
+ <td className="p-4 align-middle text-right border-r bg-muted/5">
+ {bidItem ? formatNumber(bidItem.unitPrice) : '-'}
+ </td>
+ <td className="p-4 align-middle text-right border-r bg-muted/5">
+ {bidItem ? formatNumber(bidItem.amount) : '-'}
+ </td>
+ <td className="p-4 align-middle text-center border-r bg-muted/5">
+ {vendor.currency}
+ </td>
+ <td className="p-4 align-middle text-right border-r bg-muted/5">
+ {bidItem && ratio > 0 ? `${formatNumber(ratio, 0)}%` : '-'}
+ </td>
+ </React.Fragment>
+ )
+ })}
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ <ScrollBar orientation="horizontal" />
+ </ScrollArea>
+ </CardContent>
+ </Card>
+ )
+}
+
diff --git a/lib/bidding/selection/bidding-selection-detail-content.tsx b/lib/bidding/selection/bidding-selection-detail-content.tsx
index 45d5d402..887498dc 100644
--- a/lib/bidding/selection/bidding-selection-detail-content.tsx
+++ b/lib/bidding/selection/bidding-selection-detail-content.tsx
@@ -5,6 +5,7 @@ import { Bidding } from '@/db/schema'
import { BiddingInfoCard } from './bidding-info-card'
import { SelectionResultForm } from './selection-result-form'
import { VendorSelectionTable } from './vendor-selection-table'
+import { BiddingItemTable } from './bidding-item-table'
interface BiddingSelectionDetailContentProps {
biddingId: number
@@ -17,6 +18,9 @@ export function BiddingSelectionDetailContent({
}: BiddingSelectionDetailContentProps) {
const [refreshKey, setRefreshKey] = React.useState(0)
+ // 입찰평가중 상태가 아니면 읽기 전용
+ const isReadOnly = bidding.status !== 'evaluation_of_bidding'
+
const handleRefresh = React.useCallback(() => {
setRefreshKey(prev => prev + 1)
}, [])
@@ -27,7 +31,7 @@ export function BiddingSelectionDetailContent({
<BiddingInfoCard bidding={bidding} />
{/* 선정결과 폼 */}
- <SelectionResultForm biddingId={biddingId} onSuccess={handleRefresh} />
+ <SelectionResultForm biddingId={biddingId} onSuccess={handleRefresh} readOnly={isReadOnly} />
{/* 업체선정 테이블 */}
<VendorSelectionTable
@@ -35,7 +39,12 @@ export function BiddingSelectionDetailContent({
biddingId={biddingId}
bidding={bidding}
onRefresh={handleRefresh}
+ readOnly={isReadOnly}
/>
+
+ {/* 응찰품목 테이블 */}
+ <BiddingItemTable biddingId={biddingId} />
+
</div>
)
}
diff --git a/lib/bidding/selection/biddings-selection-table.tsx b/lib/bidding/selection/biddings-selection-table.tsx
index c3990e7b..41225531 100644
--- a/lib/bidding/selection/biddings-selection-table.tsx
+++ b/lib/bidding/selection/biddings-selection-table.tsx
@@ -84,13 +84,13 @@ export function BiddingsSelectionTable({ promises }: BiddingsSelectionTableProps
switch (rowAction.type) {
case "view":
// 상세 페이지로 이동
- // 입찰평가중일때만 상세보기 가능
- if (rowAction.row.original.status === 'evaluation_of_bidding') {
+ // 입찰평가중, 업체선정, 차수증가, 재입찰 상태일 때 상세보기 가능
+ if (['evaluation_of_bidding', 'vendor_selected', 'round_increase', 'rebidding'].includes(rowAction.row.original.status)) {
router.push(`/evcp/bid-selection/${rowAction.row.original.id}/detail`)
} else {
toast({
title: '접근 제한',
- description: '입찰평가중이 아닙니다.',
+ description: '상세보기가 불가능한 상태입니다.',
variant: 'destructive',
})
}
diff --git a/lib/bidding/selection/selection-result-form.tsx b/lib/bidding/selection/selection-result-form.tsx
index 54687cc9..af6b8d43 100644
--- a/lib/bidding/selection/selection-result-form.tsx
+++ b/lib/bidding/selection/selection-result-form.tsx
@@ -9,8 +9,8 @@ import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { useToast } from '@/hooks/use-toast'
-import { saveSelectionResult } from './actions'
-import { Loader2, Save, FileText } from 'lucide-react'
+import { saveSelectionResult, getSelectionResult } from './actions'
+import { Loader2, Save, FileText, Download, X } from 'lucide-react'
import { Dropzone, DropzoneZone, DropzoneUploadIcon, DropzoneTitle, DropzoneDescription, DropzoneInput } from '@/components/ui/dropzone'
const selectionResultSchema = z.object({
@@ -22,12 +22,25 @@ type SelectionResultFormData = z.infer<typeof selectionResultSchema>
interface SelectionResultFormProps {
biddingId: number
onSuccess: () => void
+ readOnly?: boolean
}
-export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFormProps) {
+interface AttachmentInfo {
+ id: number
+ fileName: string
+ originalFileName: string
+ fileSize: number
+ mimeType: string
+ filePath: string
+ uploadedAt: Date | null
+}
+
+export function SelectionResultForm({ biddingId, onSuccess, readOnly = false }: SelectionResultFormProps) {
const { toast } = useToast()
const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const [isLoading, setIsLoading] = React.useState(true)
const [attachmentFiles, setAttachmentFiles] = React.useState<File[]>([])
+ const [existingAttachments, setExistingAttachments] = React.useState<AttachmentInfo[]>([])
const form = useForm<SelectionResultFormData>({
resolver: zodResolver(selectionResultSchema),
@@ -36,10 +49,53 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor
},
})
+ // 기존 선정결과 로드
+ React.useEffect(() => {
+ const loadSelectionResult = async () => {
+ setIsLoading(true)
+ try {
+ const result = await getSelectionResult(biddingId)
+ if (result.success && result.data) {
+ form.reset({
+ summary: result.data.summary || '',
+ })
+ if (result.data.attachments) {
+ setExistingAttachments(result.data.attachments)
+ }
+ }
+ } catch (error) {
+ console.error('Failed to load selection result:', error)
+ toast({
+ title: '로드 실패',
+ description: '선정결과를 불러오는데 실패했습니다.',
+ variant: 'destructive',
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadSelectionResult()
+ }, [biddingId, form, toast])
+
const removeAttachmentFile = (index: number) => {
setAttachmentFiles(prev => prev.filter((_, i) => i !== index))
}
+ const removeExistingAttachment = (id: number) => {
+ setExistingAttachments(prev => prev.filter(att => att.id !== id))
+ }
+
+ const downloadAttachment = (filePath: string, fileName: string) => {
+ // 파일 다운로드 (filePath가 절대 경로인 경우)
+ if (filePath.startsWith('http') || filePath.startsWith('/')) {
+ window.open(filePath, '_blank')
+ } else {
+ // 상대 경로인 경우
+ window.open(`/api/files/${filePath}`, '_blank')
+ }
+ }
+
const onSubmit = async (data: SelectionResultFormData) => {
setIsSubmitting(true)
try {
@@ -74,6 +130,22 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor
}
}
+ if (isLoading) {
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>선정결과</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="flex items-center justify-center py-8">
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
+ <span className="ml-2 text-sm text-muted-foreground">로딩 중...</span>
+ </div>
+ </CardContent>
+ </Card>
+ )
+ }
+
return (
<Card>
<CardHeader>
@@ -94,6 +166,7 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor
placeholder="선정결과에 대한 요약을 입력해주세요..."
className="min-h-[120px]"
{...field}
+ disabled={readOnly}
/>
</FormControl>
<FormMessage />
@@ -104,35 +177,83 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor
{/* 첨부파일 */}
<div className="space-y-4">
<FormLabel>첨부파일</FormLabel>
- <Dropzone
- maxSize={10 * 1024 * 1024} // 10MB
- onDropAccepted={(files) => {
- const newFiles = Array.from(files)
- setAttachmentFiles(prev => [...prev, ...newFiles])
- }}
- onDropRejected={() => {
- toast({
- title: "파일 업로드 거부",
- description: "파일 크기 및 형식을 확인해주세요.",
- variant: "destructive",
- })
- }}
- >
- <DropzoneZone>
- <DropzoneUploadIcon className="mx-auto h-12 w-12 text-muted-foreground" />
- <DropzoneTitle className="text-lg font-medium">
- 파일을 드래그하거나 클릭하여 업로드
- </DropzoneTitle>
- <DropzoneDescription className="text-sm text-muted-foreground">
- PDF, Word, Excel, 이미지 파일 (최대 10MB)
- </DropzoneDescription>
- </DropzoneZone>
- <DropzoneInput />
- </Dropzone>
+
+ {/* 기존 첨부파일 */}
+ {existingAttachments.length > 0 && (
+ <div className="space-y-2">
+ <h4 className="text-sm font-medium">기존 첨부파일</h4>
+ <div className="space-y-2">
+ {existingAttachments.map((attachment) => (
+ <div
+ key={attachment.id}
+ className="flex items-center justify-between p-3 bg-muted rounded-lg"
+ >
+ <div className="flex items-center gap-3">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <div>
+ <p className="text-sm font-medium">{attachment.originalFileName || attachment.fileName}</p>
+ <p className="text-xs text-muted-foreground">
+ {(attachment.fileSize / 1024 / 1024).toFixed(2)} MB
+ </p>
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => downloadAttachment(attachment.filePath, attachment.originalFileName || attachment.fileName)}
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ {!readOnly && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeExistingAttachment(attachment.id)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {!readOnly && (
+ <Dropzone
+ maxSize={10 * 1024 * 1024} // 10MB
+ onDropAccepted={(files) => {
+ const newFiles = Array.from(files)
+ setAttachmentFiles(prev => [...prev, ...newFiles])
+ }}
+ onDropRejected={() => {
+ toast({
+ title: "파일 업로드 거부",
+ description: "파일 크기 및 형식을 확인해주세요.",
+ variant: "destructive",
+ })
+ }}
+ >
+ <DropzoneZone>
+ <DropzoneUploadIcon className="mx-auto h-12 w-12 text-muted-foreground" />
+ <DropzoneTitle className="text-lg font-medium">
+ 파일을 드래그하거나 클릭하여 업로드
+ </DropzoneTitle>
+ <DropzoneDescription className="text-sm text-muted-foreground">
+ PDF, Word, Excel, 이미지 파일 (최대 10MB)
+ </DropzoneDescription>
+ </DropzoneZone>
+ <DropzoneInput />
+ </Dropzone>
+ )}
{attachmentFiles.length > 0 && (
<div className="space-y-2">
- <h4 className="text-sm font-medium">업로드된 파일</h4>
+ <h4 className="text-sm font-medium">새로 추가할 파일</h4>
<div className="space-y-2">
{attachmentFiles.map((file, index) => (
<div
@@ -148,14 +269,16 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor
</p>
</div>
</div>
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => removeAttachmentFile(index)}
- >
- 제거
- </Button>
+ {!readOnly && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeAttachmentFile(index)}
+ >
+ 제거
+ </Button>
+ )}
</div>
))}
</div>
@@ -164,13 +287,15 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor
</div>
{/* 저장 버튼 */}
- <div className="flex justify-end">
- <Button type="submit" disabled={isSubmitting}>
- {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- <Save className="mr-2 h-4 w-4" />
- 저장
- </Button>
- </div>
+ {!readOnly && (
+ <div className="flex justify-end">
+ <Button type="submit" disabled={isSubmitting}>
+ {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ <Save className="mr-2 h-4 w-4" />
+ 저장
+ </Button>
+ </div>
+ )}
</form>
</Form>
</CardContent>
diff --git a/lib/bidding/selection/vendor-selection-table.tsx b/lib/bidding/selection/vendor-selection-table.tsx
index 8570b5b6..40f13ec1 100644
--- a/lib/bidding/selection/vendor-selection-table.tsx
+++ b/lib/bidding/selection/vendor-selection-table.tsx
@@ -10,9 +10,10 @@ interface VendorSelectionTableProps {
biddingId: number
bidding: Bidding
onRefresh: () => void
+ readOnly?: boolean
}
-export function VendorSelectionTable({ biddingId, bidding, onRefresh }: VendorSelectionTableProps) {
+export function VendorSelectionTable({ biddingId, bidding, onRefresh, readOnly = false }: VendorSelectionTableProps) {
const [vendors, setVendors] = React.useState<any[]>([])
const [loading, setLoading] = React.useState(true)
@@ -59,6 +60,7 @@ export function VendorSelectionTable({ biddingId, bidding, onRefresh }: VendorSe
vendors={vendors}
onRefresh={onRefresh}
onOpenSelectionReasonDialog={() => {}}
+ readOnly={readOnly}
/>
</CardContent>
</Card>
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index a658ee6a..27dae87d 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -18,6 +18,7 @@ import {
vendorContacts,
vendors
} from '@/db/schema'
+import { companyConditionResponses } from '@/db/schema/bidding'
import {
eq,
desc,
@@ -2196,7 +2197,7 @@ export async function updateBiddingProjectInfo(biddingId: number) {
}
// 입찰의 PR 아이템 금액 합산하여 bidding 업데이트
-async function updateBiddingAmounts(biddingId: number) {
+export async function updateBiddingAmounts(biddingId: number) {
try {
// 해당 bidding의 모든 PR 아이템들의 금액 합계 계산
const amounts = await db
@@ -2214,9 +2215,9 @@ async function updateBiddingAmounts(biddingId: number) {
await db
.update(biddings)
.set({
- targetPrice: totalTargetAmount,
- budget: totalBudgetAmount,
- finalBidPrice: totalActualAmount,
+ targetPrice: String(totalTargetAmount),
+ budget: String(totalBudgetAmount),
+ finalBidPrice: String(totalActualAmount),
updatedAt: new Date()
})
.where(eq(biddings.id, biddingId))
@@ -2511,6 +2512,119 @@ export async function deleteBiddingCompanyContact(contactId: number) {
}
}
+// 입찰담당자별 입찰 업체 조회
+export async function getBiddingCompaniesByBidPicId(bidPicId: number) {
+ try {
+ const companies = await db
+ .select({
+ biddingId: biddings.id,
+ biddingNumber: biddings.biddingNumber,
+ biddingTitle: biddings.title,
+ companyId: biddingCompanies.companyId,
+ vendorCode: vendors.vendorCode,
+ vendorName: vendors.vendorName,
+ updatedAt: biddings.updatedAt,
+ })
+ .from(biddings)
+ .innerJoin(biddingCompanies, eq(biddings.id, biddingCompanies.biddingId))
+ .innerJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(eq(biddings.bidPicId, bidPicId))
+ .orderBy(desc(biddings.updatedAt))
+
+ return {
+ success: true,
+ data: companies
+ }
+ } catch (error) {
+ console.error('Failed to get bidding companies by bidPicId:', error)
+ return {
+ success: false,
+ error: '입찰 업체 조회에 실패했습니다.',
+ data: []
+ }
+ }
+}
+
+// 입찰 업체를 현재 입찰에 추가 (담당자 정보 포함)
+export async function addBiddingCompanyFromOtherBidding(
+ targetBiddingId: number,
+ sourceBiddingId: number,
+ companyId: number,
+ contacts?: Array<{
+ contactName: string
+ contactEmail: string
+ contactNumber?: string
+ }>
+) {
+ try {
+ return await db.transaction(async (tx) => {
+ // 중복 체크
+ const existingCompany = await tx
+ .select()
+ .from(biddingCompanies)
+ .where(
+ and(
+ eq(biddingCompanies.biddingId, targetBiddingId),
+ eq(biddingCompanies.companyId, companyId)
+ )
+ )
+ .limit(1)
+
+ if (existingCompany.length > 0) {
+ return {
+ success: false,
+ error: '이미 등록된 업체입니다.'
+ }
+ }
+
+ // 1. biddingCompanies 레코드 생성
+ const [biddingCompanyResult] = await tx
+ .insert(biddingCompanies)
+ .values({
+ biddingId: targetBiddingId,
+ companyId: companyId,
+ invitationStatus: 'pending',
+ invitedAt: new Date(),
+ })
+ .returning({ id: biddingCompanies.id })
+
+ if (!biddingCompanyResult) {
+ throw new Error('업체 추가에 실패했습니다.')
+ }
+
+ // 2. 담당자 정보 추가
+ if (contacts && contacts.length > 0) {
+ await tx.insert(biddingCompaniesContacts).values(
+ contacts.map(contact => ({
+ biddingId: targetBiddingId,
+ vendorId: companyId,
+ contactName: contact.contactName,
+ contactEmail: contact.contactEmail,
+ contactNumber: contact.contactNumber || null,
+ }))
+ )
+ }
+
+ // 3. company_condition_responses 레코드 생성
+ await tx.insert(companyConditionResponses).values({
+ biddingCompanyId: biddingCompanyResult.id,
+ })
+
+ return {
+ success: true,
+ message: '업체가 성공적으로 추가되었습니다.',
+ data: { id: biddingCompanyResult.id }
+ }
+ })
+ } catch (error) {
+ console.error('Failed to add bidding company from other bidding:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '업체 추가에 실패했습니다.'
+ }
+ }
+}
+
export async function updateBiddingConditions(
biddingId: number,
updates: {
@@ -3145,9 +3259,9 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
}
}
- revalidatePath('/bidding')
- revalidatePath(`/bidding/${biddingId}`) // 기존 입찰 페이지도 갱신
- revalidatePath(`/bidding/${newBidding.id}`)
+ revalidatePath('/bid-receive')
+ revalidatePath(`/bid-receive/${biddingId}`) // 기존 입찰 페이지도 갱신
+ revalidatePath(`/bid-receive/${newBidding.id}`)
return {
success: true,
@@ -3436,9 +3550,10 @@ export async function getBiddingsForSelection(input: GetBiddingsSchema) {
// 'bidding_opened', 'bidding_closed', 'evaluation_of_bidding', 'vendor_selected' 상태만 조회
basicConditions.push(
or(
- eq(biddings.status, 'bidding_closed'),
eq(biddings.status, 'evaluation_of_bidding'),
- eq(biddings.status, 'vendor_selected')
+ eq(biddings.status, 'vendor_selected'),
+ eq(biddings.status, 'round_increase'),
+ eq(biddings.status, 'rebidding'),
)!
)
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"
diff --git a/lib/general-contracts/detail/general-contract-basic-info.tsx b/lib/general-contracts/detail/general-contract-basic-info.tsx
index b0378912..d7533d2e 100644
--- a/lib/general-contracts/detail/general-contract-basic-info.tsx
+++ b/lib/general-contracts/detail/general-contract-basic-info.tsx
@@ -8,7 +8,21 @@ import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
-import { Save, LoaderIcon } from 'lucide-react'
+import { Save, LoaderIcon, Check, ChevronsUpDown } from 'lucide-react'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover'
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@/components/ui/command'
+import { cn } from '@/lib/utils'
import { updateContractBasicInfo, getContractBasicInfo } from '../service'
import { toast } from 'sonner'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
@@ -140,19 +154,28 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
// paymentDelivery에서 퍼센트와 타입 분리
const paymentDeliveryValue = contractData?.paymentDelivery || ''
+ console.log(paymentDeliveryValue,"paymentDeliveryValue")
let paymentDeliveryType = ''
let paymentDeliveryPercentValue = ''
- if (paymentDeliveryValue.includes('%')) {
+ // "60일 이내" 또는 "추가조건"은 그대로 사용
+ if (paymentDeliveryValue === '납품완료일로부터 60일 이내 지급' || paymentDeliveryValue === '추가조건') {
+ paymentDeliveryType = paymentDeliveryValue
+ } else if (paymentDeliveryValue.includes('%')) {
+ // 퍼센트가 포함된 경우 (예: "10% L/C")
const match = paymentDeliveryValue.match(/(\d+)%\s*(.+)/)
if (match) {
paymentDeliveryPercentValue = match[1]
paymentDeliveryType = match[2]
+ } else {
+ paymentDeliveryType = paymentDeliveryValue
}
} else {
+ // 일반 지급조건 코드 (예: "P008")
paymentDeliveryType = paymentDeliveryValue
}
-
+ console.log(paymentDeliveryType,"paymentDeliveryType")
+ console.log(paymentDeliveryPercentValue,"paymentDeliveryPercentValue")
setPaymentDeliveryPercent(paymentDeliveryPercentValue)
// 합의계약(AD, AW)인 경우 인도조건 기본값 설정
@@ -309,6 +332,7 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
loadShippingPlaces();
loadDestinationPlaces();
}, [loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces]);
+
const handleSaveContractInfo = async () => {
if (!userId) {
toast.error('사용자 정보를 찾을 수 없습니다.')
@@ -342,12 +366,29 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
return
}
- // paymentDelivery와 paymentDeliveryPercent 합쳐서 저장
+ // paymentDelivery 저장 로직
+ // 1. "60일 이내" 또는 "추가조건"은 그대로 저장
+ // 2. L/C 또는 T/T이고 퍼센트가 있으면 "퍼센트% 코드" 형식으로 저장
+ // 3. 그 외의 경우는 그대로 저장
+ let paymentDeliveryToSave = formData.paymentDelivery
+
+ if (
+ formData.paymentDelivery !== '납품완료일로부터 60일 이내 지급' &&
+ formData.paymentDelivery !== '추가조건' &&
+ (formData.paymentDelivery === 'L/C' || formData.paymentDelivery === 'T/T') &&
+ paymentDeliveryPercent
+ ) {
+ paymentDeliveryToSave = `${paymentDeliveryPercent}% ${formData.paymentDelivery}`
+ }
+ console.log(paymentDeliveryToSave,"paymentDeliveryToSave")
+
const dataToSave = {
...formData,
- paymentDelivery: (formData.paymentDelivery === 'L/C' || formData.paymentDelivery === 'T/T') && paymentDeliveryPercent
- ? `${paymentDeliveryPercent}% ${formData.paymentDelivery}`
- : formData.paymentDelivery
+ paymentDelivery: paymentDeliveryToSave,
+ // 추가조건 선택 시에만 추가 텍스트 저장, 그 외에는 빈 문자열 또는 undefined
+ paymentDeliveryAdditionalText: formData.paymentDelivery === '추가조건'
+ ? (formData.paymentDeliveryAdditionalText || '')
+ : ''
}
await updateContractBasicInfo(contractId, dataToSave, userId as number)
@@ -1026,20 +1067,100 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="paymentDelivery" className="text-xs">지급조건 *</Label>
- <Select value={formData.paymentDelivery} onValueChange={(value) => setFormData(prev => ({ ...prev, paymentDelivery: value }))}>
- <SelectTrigger className={`h-8 text-xs ${errors.paymentDelivery ? 'border-red-500' : ''}`}>
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {paymentTermsOptions.map((term) => (
- <SelectItem key={term.code} value={term.code} className="text-xs">
- {term.code}
- </SelectItem>
- ))}
- <SelectItem value="납품완료일로부터 60일 이내 지급" className="text-xs">60일 이내</SelectItem>
- <SelectItem value="추가조건" className="text-xs">추가조건</SelectItem>
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.paymentDelivery && "text-muted-foreground",
+ errors.paymentDelivery && "border-red-500"
+ )}
+ >
+ {formData.paymentDelivery
+ ? (() => {
+ // 1. paymentTermsOptions에서 찾기
+ const foundOption = paymentTermsOptions.find((option) => option.code === formData.paymentDelivery)
+ if (foundOption) {
+ return `${foundOption.code} ${foundOption.description ? `(${foundOption.description})` : ''}`
+ }
+ // 2. 특수 케이스 처리
+ if (formData.paymentDelivery === '납품완료일로부터 60일 이내 지급') {
+ return '60일 이내'
+ }
+ if (formData.paymentDelivery === '추가조건') {
+ return '추가조건'
+ }
+ // 3. 그 외의 경우 원본 값 표시 (로드된 값이지만 옵션에 없는 경우)
+ return formData.paymentDelivery
+ })()
+ : "지급조건 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="지급조건 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {paymentTermsOptions.map((option) => (
+ <CommandItem
+ key={option.code}
+ value={`${option.code} ${option.description || ''}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, paymentDelivery: option.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ option.code === formData.paymentDelivery
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {option.code} {option.description && `(${option.description})`}
+ </CommandItem>
+ ))}
+ <CommandItem
+ value="납품완료일로부터 60일 이내 지급"
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, paymentDelivery: '납품완료일로부터 60일 이내 지급' }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ formData.paymentDelivery === '납품완료일로부터 60일 이내 지급'
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ 60일 이내
+ </CommandItem>
+ <CommandItem
+ value="추가조건"
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, paymentDelivery: '추가조건' }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ formData.paymentDelivery === '추가조건'
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ 추가조건
+ </CommandItem>
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
{formData.paymentDelivery === '추가조건' && (
<Input
type="text"
@@ -1152,53 +1273,59 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
</div>
</div>
- {/* 지불조건 -> 세금조건 (지불조건 삭제됨) */}
+ {/*세금조건*/}
<div className="space-y-2">
<Label className="text-sm font-medium">세금조건</Label>
<div className="space-y-2">
- {/* 지불조건 필드 삭제됨
- <div className="space-y-1">
- <Label htmlFor="paymentTerm" className="text-xs">지불조건 *</Label>
- <Select
- value={formData.paymentTerm}
- onValueChange={(value) => setFormData(prev => ({ ...prev, paymentTerm: value }))}
- >
- <SelectTrigger className={`h-8 text-xs ${errors.paymentTerm ? 'border-red-500' : ''}`}>
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {paymentTermsOptions.length > 0 ? (
- paymentTermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code} className="text-xs">
- {option.code}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled className="text-xs">
- 로딩중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
- */}
<div className="space-y-1">
<Label htmlFor="taxType" className="text-xs">세금조건 *</Label>
- <Select
- value={formData.taxType}
- onValueChange={(value) => setFormData(prev => ({ ...prev, taxType: value }))}
- >
- <SelectTrigger className={`h-8 text-xs ${errors.taxType ? 'border-red-500' : ''}`}>
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {TAX_CONDITIONS.map((condition) => (
- <SelectItem key={condition.code} value={condition.code} className="text-xs">
- {condition.name}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.taxType && "text-muted-foreground",
+ errors.taxType && "border-red-500"
+ )}
+ >
+ {formData.taxType
+ ? TAX_CONDITIONS.find((condition) => condition.code === formData.taxType)?.name
+ : "세금조건 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="세금조건 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {TAX_CONDITIONS.map((condition) => (
+ <CommandItem
+ key={condition.code}
+ value={`${condition.code} ${condition.name}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, taxType: condition.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ condition.code === formData.taxType
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {condition.name}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
</div>
</div>
@@ -1266,79 +1393,178 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
{/* 인도조건 */}
<div className="space-y-2">
<Label htmlFor="deliveryTerm" className="text-xs">인도조건</Label>
- <Select
- value={formData.deliveryTerm}
- onValueChange={(value) => setFormData(prev => ({ ...prev, deliveryTerm: value }))}
- >
- <SelectTrigger className="h-8 text-xs">
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {incotermsOptions.length > 0 ? (
- incotermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code} className="text-xs">
- {option.code}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled className="text-xs">
- 로딩중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.deliveryTerm && "text-muted-foreground"
+ )}
+ >
+ {formData.deliveryTerm
+ ? incotermsOptions.find((option) => option.code === formData.deliveryTerm)
+ ? `${incotermsOptions.find((option) => option.code === formData.deliveryTerm)?.code} ${incotermsOptions.find((option) => option.code === formData.deliveryTerm)?.description ? `(${incotermsOptions.find((option) => option.code === formData.deliveryTerm)?.description})` : ''}`
+ : formData.deliveryTerm
+ : "인코텀즈 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="인코텀즈 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {incotermsOptions.length > 0 ? (
+ incotermsOptions.map((option) => (
+ <CommandItem
+ key={option.code}
+ value={`${option.code} ${option.description || ''}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, deliveryTerm: option.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ option.code === formData.deliveryTerm
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {option.code} {option.description && `(${option.description})`}
+ </CommandItem>
+ ))
+ ) : (
+ <CommandItem value="loading" disabled>
+ 로딩중...
+ </CommandItem>
+ )}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
{/* 선적지 */}
<div className="space-y-2">
<Label htmlFor="shippingLocation" className="text-xs">선적지</Label>
- <Select
- value={formData.shippingLocation}
- onValueChange={(value) => setFormData(prev => ({ ...prev, shippingLocation: value }))}
- >
- <SelectTrigger className="h-8 text-xs">
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {shippingPlaces.length > 0 ? (
- shippingPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code} className="text-xs">
- {place.code}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled className="text-xs">
- 로딩중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.shippingLocation && "text-muted-foreground"
+ )}
+ >
+ {formData.shippingLocation
+ ? shippingPlaces.find((place) => place.code === formData.shippingLocation)
+ ? `${shippingPlaces.find((place) => place.code === formData.shippingLocation)?.code} ${shippingPlaces.find((place) => place.code === formData.shippingLocation)?.description ? `(${shippingPlaces.find((place) => place.code === formData.shippingLocation)?.description})` : ''}`
+ : formData.shippingLocation
+ : "선적지 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="선적지 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {shippingPlaces.length > 0 ? (
+ shippingPlaces.map((place) => (
+ <CommandItem
+ key={place.code}
+ value={`${place.code} ${place.description || ''}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, shippingLocation: place.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ place.code === formData.shippingLocation
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {place.code} {place.description && `(${place.description})`}
+ </CommandItem>
+ ))
+ ) : (
+ <CommandItem value="loading" disabled>
+ 로딩중...
+ </CommandItem>
+ )}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
{/* 하역지 */}
<div className="space-y-2">
<Label htmlFor="dischargeLocation" className="text-xs">하역지</Label>
- <Select
- value={formData.dischargeLocation}
- onValueChange={(value) => setFormData(prev => ({ ...prev, dischargeLocation: value }))}
- >
- <SelectTrigger className="h-8 text-xs">
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {destinationPlaces.length > 0 ? (
- destinationPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code} className="text-xs">
- {place.code}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled className="text-xs">
- 로딩중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.dischargeLocation && "text-muted-foreground"
+ )}
+ >
+ {formData.dischargeLocation
+ ? destinationPlaces.find((place) => place.code === formData.dischargeLocation)
+ ? `${destinationPlaces.find((place) => place.code === formData.dischargeLocation)?.code} ${destinationPlaces.find((place) => place.code === formData.dischargeLocation)?.description ? `(${destinationPlaces.find((place) => place.code === formData.dischargeLocation)?.description})` : ''}`
+ : formData.dischargeLocation
+ : "하역지 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="하역지 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {destinationPlaces.length > 0 ? (
+ destinationPlaces.map((place) => (
+ <CommandItem
+ key={place.code}
+ value={`${place.code} ${place.description || ''}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, dischargeLocation: place.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ place.code === formData.dischargeLocation
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {place.code} {place.description && `(${place.description})`}
+ </CommandItem>
+ ))
+ ) : (
+ <CommandItem value="loading" disabled>
+ 로딩중...
+ </CommandItem>
+ )}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
{/* 계약납기일 */}
diff --git a/lib/general-contracts/detail/general-contract-items-table.tsx b/lib/general-contracts/detail/general-contract-items-table.tsx
index 15e5c926..e5fc6cf2 100644
--- a/lib/general-contracts/detail/general-contract-items-table.tsx
+++ b/lib/general-contracts/detail/general-contract-items-table.tsx
@@ -30,6 +30,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
import { ProjectSelector } from '@/components/ProjectSelector'
import { MaterialGroupSelectorDialogSingle } from '@/components/common/material/material-group-selector-dialog-single'
import { MaterialSearchItem } from '@/lib/material/material-group-service'
+import { ProcurementItemSelectorDialogSingle } from '@/components/common/selectors/procurement-item/procurement-item-selector-dialog-single'
+import { ProcurementSearchItem } from '@/components/common/selectors/procurement-item/procurement-item-service'
interface ContractItem {
id?: number
@@ -174,7 +176,7 @@ export function ContractItemsTable({
const errors: string[] = []
for (let index = 0; index < localItems.length; index++) {
const item = localItems[index]
- if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`)
+ // if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`)
if (!item.itemInfo) errors.push(`${index + 1}번째 품목의 Item 정보`)
if (!item.quantity || item.quantity <= 0) errors.push(`${index + 1}번째 품목의 수량`)
if (!item.contractUnitPrice || item.contractUnitPrice <= 0) errors.push(`${index + 1}번째 품목의 단가`)
@@ -271,6 +273,34 @@ export function ContractItemsTable({
onItemsChange(updatedItems)
}
+ // 1회성 품목 선택 시 행 추가
+ const handleOneTimeItemSelect = (item: ProcurementSearchItem | null) => {
+ if (!item) return
+
+ const newItem: ContractItem = {
+ projectId: null,
+ itemCode: item.itemCode,
+ itemInfo: item.itemName,
+ materialGroupCode: '',
+ materialGroupDescription: '',
+ specification: item.specification || '',
+ quantity: 0,
+ quantityUnit: item.unit || 'EA',
+ totalWeight: 0,
+ weightUnit: 'KG',
+ contractDeliveryDate: '',
+ contractUnitPrice: 0,
+ contractAmount: 0,
+ contractCurrency: 'KRW',
+ isSelected: false
+ }
+
+ const updatedItems = [...localItems, newItem]
+ setLocalItems(updatedItems)
+ onItemsChange(updatedItems)
+ toast.success('1회성 품목이 추가되었습니다.')
+ }
+
// 일괄입력 적용
const applyBatchInput = () => {
if (localItems.length === 0) {
@@ -382,6 +412,17 @@ export function ContractItemsTable({
<Plus className="w-4 h-4" />
행 추가
</Button>
+ <ProcurementItemSelectorDialogSingle
+ triggerLabel="1회성 품목 추가"
+ triggerVariant="outline"
+ triggerSize="sm"
+ selectedProcurementItem={null}
+ onProcurementItemSelect={handleOneTimeItemSelect}
+ title="1회성 품목 선택"
+ description="추가할 1회성 품목을 선택해주세요."
+ showConfirmButtons={false}
+ disabled={!isEnabled || readOnly}
+ />
<Dialog open={showBatchInputDialog} onOpenChange={setShowBatchInputDialog}>
<DialogTrigger asChild>
<Button
diff --git a/lib/general-contracts/service.ts b/lib/general-contracts/service.ts
index 3f3dc8de..72b6449f 100644
--- a/lib/general-contracts/service.ts
+++ b/lib/general-contracts/service.ts
@@ -504,7 +504,7 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u
linkedBidNumber,
notes,
paymentBeforeDelivery, // JSON 필드
- paymentDelivery: convertToNumberOrNull(paymentDelivery),
+ paymentDelivery,
paymentAfterDelivery, // JSON 필드
paymentTerm,
taxType,
@@ -525,7 +525,7 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u
lastUpdatedAt: new Date(),
lastUpdatedById: userId,
}
-
+ console.log(updateData.paymentDelivery,"updateData.paymentDelivery")
// DB에 업데이트 실행
const [updatedContract] = await db
.update(generalContracts)
@@ -533,14 +533,9 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u
.where(eq(generalContracts.id, id))
.returning()
- // 계약명 I/F 로직 (39번 화면으로의 I/F)
- // TODO: 39번 화면의 정확한 API 엔드포인트나 함수명 확인 필요
- // if (data.name) {
- // await syncContractNameToScreen39(id, data.name as string)
- // }
revalidatePath('/general-contracts')
- revalidatePath(`/general-contracts/detail/${id}`)
+ revalidatePath(`/general-contracts/${id}`)
return updatedContract
} catch (error) {
console.error('Error updating contract basic info:', error)
diff --git a/lib/procurement-items/service.ts b/lib/procurement-items/service.ts
index b62eb8df..c91959a9 100644
--- a/lib/procurement-items/service.ts
+++ b/lib/procurement-items/service.ts
@@ -255,8 +255,19 @@ export async function searchProcurementItems(query: string): Promise<{ itemCode:
unstable_noStore()
try {
+ // 검색어가 없으면 상위 50개 반환
if (!query || query.trim().length < 1) {
- return []
+ const results = await db
+ .select({
+ itemCode: procurementItems.itemCode,
+ itemName: procurementItems.itemName,
+ })
+ .from(procurementItems)
+ .where(eq(procurementItems.isActive, 'Y'))
+ .limit(50)
+ .orderBy(asc(procurementItems.itemCode))
+
+ return results
}
const searchQuery = `%${query.trim()}%`
@@ -277,7 +288,7 @@ export async function searchProcurementItems(query: string): Promise<{ itemCode:
eq(procurementItems.isActive, 'Y')
)
)
- .limit(20)
+ .limit(50)
.orderBy(asc(procurementItems.itemCode))
return results
diff --git a/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts b/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts
index 9bdd238d..00873d83 100644
--- a/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts
+++ b/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts
@@ -32,6 +32,7 @@ import {
parseSAPDateToString,
findSpecificationByMATNR,
} from './common-mapper-utils';
+import { updateBiddingAmounts } from '@/lib/bidding/service';
// Note: POS 파일은 온디맨드 방식으로 다운로드됩니다.
// 자동 동기화 관련 import는 제거되었습니다.
@@ -497,6 +498,20 @@ export async function mapAndSaveECCBiddingData(
};
});
+ // 새로 생성된 Bidding들에 대해 금액 집계 업데이트 (PR 아이템의 금액 정보를 Bidding 헤더에 반영)
+ if (result.insertedBiddings && result.insertedBiddings.length > 0) {
+ debugLog('Bidding 금액 집계 업데이트 시작', { count: result.insertedBiddings.length });
+ await Promise.all(
+ result.insertedBiddings.map(async (bidding) => {
+ try {
+ await updateBiddingAmounts(bidding.id);
+ } catch (err) {
+ debugError(`Bidding ${bidding.biddingNumber} 금액 업데이트 실패`, err);
+ }
+ })
+ );
+ }
+
debugSuccess('ECC Bidding 데이터 일괄 처리 완료', {
processedCount: result.processedCount,
});