summaryrefslogtreecommitdiff
path: root/components/bidding/manage
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-12-08 14:19:37 +0900
committerjoonhoekim <26rote@gmail.com>2025-12-08 14:19:37 +0900
commit2ac7deb8494cf4123f0cff3321860585a44f157c (patch)
tree789b6980c8f863a0f675fad38c4a17d91ba28bf3 /components/bidding/manage
parent71c0ba1f01b98770ec2c60cdb935ffb36c1830a9 (diff)
parente37cce51ccfa3dcb91904b2492df3a29970fadf7 (diff)
Merge remote-tracking branch 'origin/sec-patch' into table-v2
Diffstat (limited to 'components/bidding/manage')
-rw-r--r--components/bidding/manage/bidding-basic-info-editor.tsx2
-rw-r--r--components/bidding/manage/bidding-companies-editor.tsx279
-rw-r--r--components/bidding/manage/bidding-detail-vendor-create-dialog.tsx15
-rw-r--r--components/bidding/manage/bidding-items-editor.tsx181
-rw-r--r--components/bidding/manage/bidding-schedule-editor.tsx343
-rw-r--r--components/bidding/manage/create-pre-quote-rfq-dialog.tsx128
6 files changed, 774 insertions, 174 deletions
diff --git a/components/bidding/manage/bidding-basic-info-editor.tsx b/components/bidding/manage/bidding-basic-info-editor.tsx
index 27a2c097..13c58311 100644
--- a/components/bidding/manage/bidding-basic-info-editor.tsx
+++ b/components/bidding/manage/bidding-basic-info-editor.tsx
@@ -88,7 +88,6 @@ interface BiddingBasicInfo {
contractEndDate?: string
submissionStartDate?: string
submissionEndDate?: string
- evaluationDate?: string
hasSpecificationMeeting?: boolean
hasPrDocument?: boolean
currency?: string
@@ -252,7 +251,6 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
contractEndDate: formatDate(bidding.contractEndDate),
submissionStartDate: formatDateTime(bidding.submissionStartDate),
submissionEndDate: formatDateTime(bidding.submissionEndDate),
- evaluationDate: formatDateTime(bidding.evaluationDate),
hasSpecificationMeeting: bidding.hasSpecificationMeeting || false,
hasPrDocument: bidding.hasPrDocument || false,
currency: bidding.currency || 'KRW',
diff --git a/components/bidding/manage/bidding-companies-editor.tsx b/components/bidding/manage/bidding-companies-editor.tsx
index 6634f528..9bfea90e 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>
@@ -537,7 +566,22 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC
</TableCell>
<TableCell className="font-medium">{vendor.vendorName}</TableCell>
<TableCell>{vendor.vendorCode}</TableCell>
- <TableCell>{vendor.businessSize || '-'}</TableCell>
+ <TableCell>
+ {(() => {
+ switch (vendor.businessSize) {
+ case 'A':
+ return '대기업';
+ case 'B':
+ return '중견기업';
+ case 'C':
+ return '중소기업';
+ case 'D':
+ return '소기업';
+ default:
+ return '-';
+ }
+ })()}
+ </TableCell>
<TableCell>
{vendor.companyId && vendorFirstContacts.has(vendor.companyId)
? vendorFirstContacts.get(vendor.companyId)!.contactName
@@ -740,6 +784,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-detail-vendor-create-dialog.tsx b/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx
index 0dd9f0eb..489f104d 100644
--- a/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx
+++ b/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx
@@ -408,7 +408,20 @@ export function BiddingDetailVendorCreateDialog({
연동제 적용요건 문의
</Label>
<span className="text-xs text-muted-foreground">
- 기업규모: {businessSizeMap[item.vendor.id] || '미정'}
+ 기업규모: {(() => {
+ switch (businessSizeMap[item.vendor.id]) {
+ case 'A':
+ return '대기업';
+ case 'B':
+ return '중견기업';
+ case 'C':
+ return '중소기업';
+ case 'D':
+ return '소기업';
+ default:
+ return '-';
+ }
+ })()}
</span>
</div>
</div>
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/bidding-schedule-editor.tsx b/components/bidding/manage/bidding-schedule-editor.tsx
index 49659ae7..72961c3d 100644
--- a/components/bidding/manage/bidding-schedule-editor.tsx
+++ b/components/bidding/manage/bidding-schedule-editor.tsx
@@ -21,8 +21,10 @@ import { registerBidding } from '@/lib/bidding/detail/service'
import { useToast } from '@/hooks/use-toast'
import { format } from 'date-fns'
interface BiddingSchedule {
- submissionStartDate?: string
- submissionEndDate?: string
+ submissionStartOffset?: number // 시작일 오프셋 (결재 후 n일)
+ submissionStartTime?: string // 시작 시간 (HH:MM)
+ submissionDurationDays?: number // 기간 (시작일 + n일)
+ submissionEndTime?: string // 마감 시간 (HH:MM)
remarks?: string
isUrgent?: boolean
hasSpecificationMeeting?: boolean
@@ -149,6 +151,47 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
return new Date(kstTime).toISOString().slice(0, 16)
}
+ // timestamp에서 시간(HH:MM) 추출
+ // 수정: Backend에서 setUTCHours로 저장했으므로, 읽을 때도 getUTCHours로 읽어야
+ // 브라우저 타임존(KST)의 간섭 없이 원래 저장한 숫자를 가져올 수 있습니다.
+ const extractTimeFromTimestamp = (date: string | Date | undefined | null): string => {
+ if (!date) return ''
+ const d = new Date(date)
+
+ // 중요: Backend에서 setUTCHours로 저장했으므로, 읽을 때도 getUTCHours로 읽어야
+ // 브라우저 타임존(KST)의 간섭 없이 원래 저장한 숫자(09:00)를 가져올 수 있습니다.
+ const hours = d.getUTCHours().toString().padStart(2, '0')
+ const minutes = d.getUTCMinutes().toString().padStart(2, '0')
+
+ return `${hours}:${minutes}`
+ }
+
+ // 예상 일정 계산 (오늘 기준 미리보기)
+ const getPreviewDates = () => {
+ const today = new Date()
+ today.setHours(0, 0, 0, 0)
+
+ const startOffset = schedule.submissionStartOffset ?? 0
+ const durationDays = schedule.submissionDurationDays ?? 7
+ const startTime = schedule.submissionStartTime || '09:00'
+ const endTime = schedule.submissionEndTime || '18:00'
+
+ // 시작일 계산
+ const startDate = new Date(today)
+ startDate.setDate(startDate.getDate() + startOffset)
+ const [startHour, startMinute] = startTime.split(':').map(Number)
+ startDate.setHours(startHour, startMinute, 0, 0)
+
+ // 마감일 계산
+ const endDate = new Date(startDate)
+ endDate.setHours(0, 0, 0, 0) // 시작일의 날짜만
+ endDate.setDate(endDate.getDate() + durationDays)
+ const [endHour, endMinute] = endTime.split(':').map(Number)
+ endDate.setHours(endHour, endMinute, 0, 0)
+
+ return { startDate, endDate }
+ }
+
// 데이터 로딩
React.useEffect(() => {
const loadSchedule = async () => {
@@ -165,36 +208,36 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
})
setSchedule({
- submissionStartDate: toKstInputValue(bidding.submissionStartDate),
- submissionEndDate: toKstInputValue(bidding.submissionEndDate),
+ submissionStartOffset: bidding.submissionStartOffset ?? 1,
+ submissionStartTime: extractTimeFromTimestamp(bidding.submissionStartDate) || '09:00',
+ submissionDurationDays: bidding.submissionDurationDays ?? 7,
+ submissionEndTime: extractTimeFromTimestamp(bidding.submissionEndDate) || '18:00',
remarks: bidding.remarks || '',
isUrgent: bidding.isUrgent || false,
hasSpecificationMeeting: bidding.hasSpecificationMeeting || false,
})
- // 사양설명회 정보 로드
- if (bidding.hasSpecificationMeeting) {
- try {
- const meetingDetails = await getSpecificationMeetingDetailsAction(biddingId)
- if (meetingDetails.success && meetingDetails.data) {
- const meeting = meetingDetails.data
- setSpecMeetingInfo({
- meetingDate: toKstInputValue(meeting.meetingDate),
- meetingTime: meeting.meetingTime || '',
- location: meeting.location || '',
- address: meeting.address || '',
- contactPerson: meeting.contactPerson || '',
- contactPhone: meeting.contactPhone || '',
- contactEmail: meeting.contactEmail || '',
- agenda: meeting.agenda || '',
- materials: meeting.materials || '',
- notes: meeting.notes || '',
- isRequired: meeting.isRequired || false,
- })
- }
- } catch (error) {
- console.error('Failed to load specification meeting details:', error)
+ // 사양설명회 정보 로드 (T/F 무관하게 기존 데이터가 있으면 로드)
+ try {
+ const meetingDetails = await getSpecificationMeetingDetailsAction(biddingId)
+ if (meetingDetails.success && meetingDetails.data) {
+ const meeting = meetingDetails.data
+ setSpecMeetingInfo({
+ meetingDate: toKstInputValue(meeting.meetingDate),
+ meetingTime: meeting.meetingTime || '',
+ location: meeting.location || '',
+ address: meeting.address || '',
+ contactPerson: meeting.contactPerson || '',
+ contactPhone: meeting.contactPhone || '',
+ contactEmail: meeting.contactEmail || '',
+ agenda: meeting.agenda || '',
+ materials: meeting.materials || '',
+ notes: meeting.notes || '',
+ isRequired: meeting.isRequired || false,
+ })
}
+ } catch (error) {
+ console.error('Failed to load specification meeting details:', error)
}
}
} catch (error) {
@@ -258,10 +301,18 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
const handleBiddingInvitationClick = async () => {
try {
// 1. 입찰서 제출기간 검증
- if (!schedule.submissionStartDate || !schedule.submissionEndDate) {
+ if (schedule.submissionStartOffset === undefined || schedule.submissionDurationDays === undefined) {
toast({
title: '입찰서 제출기간 미설정',
- description: '입찰서 제출 시작일시와 마감일시를 모두 설정해주세요.',
+ description: '입찰서 제출 시작일 오프셋과 기간을 모두 설정해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+ if (!schedule.submissionStartTime || !schedule.submissionEndTime) {
+ toast({
+ title: '입찰서 제출시간 미설정',
+ description: '입찰 시작 시간과 마감 시간을 모두 설정해주세요.',
variant: 'destructive',
})
return
@@ -484,10 +535,48 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
const userId = session?.user?.id?.toString() || '1'
// 입찰서 제출기간 필수 검증
- if (!schedule.submissionStartDate || !schedule.submissionEndDate) {
+ if (schedule.submissionStartOffset === undefined || schedule.submissionDurationDays === undefined) {
toast({
title: '입찰서 제출기간 미설정',
- description: '입찰서 제출 시작일시와 마감일시를 모두 설정해주세요.',
+ description: '입찰서 제출 시작일 오프셋과 기간을 모두 설정해주세요.',
+ variant: 'destructive',
+ })
+ setIsSubmitting(false)
+ return
+ }
+ if (!schedule.submissionStartTime || !schedule.submissionEndTime) {
+ toast({
+ title: '입찰서 제출시간 미설정',
+ description: '입찰 시작 시간과 마감 시간을 모두 설정해주세요.',
+ variant: 'destructive',
+ })
+ setIsSubmitting(false)
+ return
+ }
+ // 오프셋/기간 검증
+ if (schedule.submissionStartOffset < 0) {
+ toast({
+ title: '시작일 오프셋 오류',
+ description: '시작일 오프셋은 0 이상이어야 합니다.',
+ variant: 'destructive',
+ })
+ setIsSubmitting(false)
+ return
+ }
+ if (schedule.submissionDurationDays < 1) {
+ toast({
+ title: '기간 오류',
+ description: '입찰 기간은 최소 1일 이상이어야 합니다.',
+ variant: 'destructive',
+ })
+ setIsSubmitting(false)
+ return
+ }
+ // 긴급 입찰이 아닌 경우 당일 시작 불가 (오프셋 0)
+ if (!schedule.isUrgent && schedule.submissionStartOffset === 0) {
+ toast({
+ title: '시작일 오류',
+ description: '긴급 입찰이 아닌 경우 당일 시작(오프셋 0)은 불가능합니다.',
variant: 'destructive',
})
setIsSubmitting(false)
@@ -538,62 +627,55 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
}
}
- const handleScheduleChange = (field: keyof BiddingSchedule, value: string | boolean) => {
- // 마감일시 검증 - 현재일 이전 설정 불가
- if (field === 'submissionEndDate' && typeof value === 'string' && value) {
- const selectedDate = new Date(value)
- const now = new Date()
- now.setHours(0, 0, 0, 0) // 시간을 00:00:00으로 설정하여 날짜만 비교
-
- if (selectedDate < now) {
+ const handleScheduleChange = (field: keyof BiddingSchedule, value: string | boolean | number) => {
+ // 시작일 오프셋 검증
+ if (field === 'submissionStartOffset' && typeof value === 'number') {
+ if (value < 0) {
+ toast({
+ title: '시작일 오프셋 오류',
+ description: '시작일 오프셋은 0 이상이어야 합니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+ // 긴급 입찰이 아닌 경우 당일 시작(오프셋 0) 불가
+ if (!schedule.isUrgent && value === 0) {
toast({
- title: '마감일시 오류',
- description: '마감일시는 현재일 이전으로 설정할 수 없습니다.',
+ title: '시작일 오프셋 오류',
+ description: '긴급 입찰이 아닌 경우 당일 시작(오프셋 0)은 불가능합니다.',
variant: 'destructive',
})
- return // 변경을 적용하지 않음
+ return
}
}
- // 긴급여부 미선택 시 당일 제출시작 불가
- if (field === 'submissionStartDate' && typeof value === 'string' && value) {
- const selectedDate = new Date(value)
- const today = new Date()
- today.setHours(0, 0, 0, 0) // 시간을 00:00:00으로 설정
- selectedDate.setHours(0, 0, 0, 0)
-
- // 현재 긴급 여부 확인 (field가 'isUrgent'인 경우 value 사용, 아니면 기존 schedule 값)
- const isUrgent = field === 'isUrgent' ? (value as boolean) : schedule.isUrgent || false
+ // 기간 검증
+ if (field === 'submissionDurationDays' && typeof value === 'number') {
+ if (value < 1) {
+ toast({
+ title: '기간 오류',
+ description: '입찰 기간은 최소 1일 이상이어야 합니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+ }
- // 긴급이 아닌 경우 당일 시작 불가
- if (!isUrgent && selectedDate.getTime() === today.getTime()) {
+ // 시간 형식 검증 (HH:MM)
+ if ((field === 'submissionStartTime' || field === 'submissionEndTime') && typeof value === 'string') {
+ const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/
+ if (value && !timeRegex.test(value)) {
toast({
- title: '제출 시작일시 오류',
- description: '긴급 입찰이 아닌 경우 당일 제출 시작은 불가능합니다.',
+ title: '시간 형식 오류',
+ description: '시간은 HH:MM 형식으로 입력해주세요.',
variant: 'destructive',
})
- return // 변경을 적용하지 않음
+ return
}
}
setSchedule(prev => ({ ...prev, [field]: value }))
-
- // 사양설명회 실시 여부가 false로 변경되면 상세 정보 초기화
- if (field === 'hasSpecificationMeeting' && value === false) {
- setSpecMeetingInfo({
- meetingDate: '',
- meetingTime: '',
- location: '',
- address: '',
- contactPerson: '',
- contactPhone: '',
- contactEmail: '',
- agenda: '',
- materials: '',
- notes: '',
- isRequired: false,
- })
- }
+ // 사양설명회 실시 여부가 false로 변경되어도 상세 정보 초기화하지 않음 (기존 데이터 유지)
}
if (isLoading) {
@@ -624,40 +706,98 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
<Clock className="h-4 w-4" />
입찰서 제출 기간
</h3>
+ <p className="text-sm text-muted-foreground">
+ 입찰공고(결재 완료) 시점을 기준으로 일정이 자동 계산됩니다.
+ </p>
+
+ {/* 시작일 설정 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
- <Label htmlFor="submission-start">제출 시작일시 <span className="text-red-500">*</span></Label>
+ <Label htmlFor="submission-start-offset">시작일 (결재 후) <span className="text-red-500">*</span></Label>
+ <div className="flex items-center gap-2">
+ <Input
+ id="submission-start-offset"
+ type="number"
+ min={schedule.isUrgent ? 0 : 1}
+ value={schedule.submissionStartOffset ?? ''}
+ onChange={(e) => handleScheduleChange('submissionStartOffset', parseInt(e.target.value) || 0)}
+ className={schedule.submissionStartOffset === undefined ? 'border-red-200' : ''}
+ disabled={readonly}
+ placeholder="0"
+ />
+ <span className="text-sm text-muted-foreground whitespace-nowrap">일 후</span>
+ </div>
+ {schedule.submissionStartOffset === undefined && (
+ <p className="text-sm text-red-500">시작일 오프셋은 필수입니다</p>
+ )}
+ {!schedule.isUrgent && schedule.submissionStartOffset === 0 && (
+ <p className="text-sm text-amber-600">긴급 입찰만 당일 시작(0일) 가능</p>
+ )}
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="submission-start-time">시작 시간 <span className="text-red-500">*</span></Label>
<Input
- id="submission-start"
- type="datetime-local"
- value={schedule.submissionStartDate}
- onChange={(e) => handleScheduleChange('submissionStartDate', e.target.value)}
- className={!schedule.submissionStartDate ? 'border-red-200' : ''}
+ id="submission-start-time"
+ type="time"
+ value={schedule.submissionStartTime || ''}
+ onChange={(e) => handleScheduleChange('submissionStartTime', e.target.value)}
+ className={!schedule.submissionStartTime ? 'border-red-200' : ''}
disabled={readonly}
- min="1900-01-01T00:00"
- max="2100-12-31T23:59"
/>
- {!schedule.submissionStartDate && (
- <p className="text-sm text-red-500">제출 시작일시는 필수입니다</p>
+ {!schedule.submissionStartTime && (
+ <p className="text-sm text-red-500">시작 시간은 필수입니다</p>
+ )}
+ </div>
+ </div>
+
+ {/* 마감일 설정 */}
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="submission-duration">입찰 기간 (시작일 +) <span className="text-red-500">*</span></Label>
+ <div className="flex items-center gap-2">
+ <Input
+ id="submission-duration"
+ type="number"
+ min={1}
+ value={schedule.submissionDurationDays ?? ''}
+ onChange={(e) => handleScheduleChange('submissionDurationDays', parseInt(e.target.value) || 1)}
+ className={schedule.submissionDurationDays === undefined ? 'border-red-200' : ''}
+ disabled={readonly}
+ placeholder="7"
+ />
+ <span className="text-sm text-muted-foreground whitespace-nowrap">일간</span>
+ </div>
+ {schedule.submissionDurationDays === undefined && (
+ <p className="text-sm text-red-500">입찰 기간은 필수입니다</p>
)}
</div>
<div className="space-y-2">
- <Label htmlFor="submission-end">제출 마감일시 <span className="text-red-500">*</span></Label>
+ <Label htmlFor="submission-end-time">마감 시간 <span className="text-red-500">*</span></Label>
<Input
- id="submission-end"
- type="datetime-local"
- value={schedule.submissionEndDate}
- onChange={(e) => handleScheduleChange('submissionEndDate', e.target.value)}
- className={!schedule.submissionEndDate ? 'border-red-200' : ''}
+ id="submission-end-time"
+ type="time"
+ value={schedule.submissionEndTime || ''}
+ onChange={(e) => handleScheduleChange('submissionEndTime', e.target.value)}
+ className={!schedule.submissionEndTime ? 'border-red-200' : ''}
disabled={readonly}
- min="1900-01-01T00:00"
- max="2100-12-31T23:59"
/>
- {!schedule.submissionEndDate && (
- <p className="text-sm text-red-500">제출 마감일시는 필수입니다</p>
+ {!schedule.submissionEndTime && (
+ <p className="text-sm text-red-500">마감 시간은 필수입니다</p>
)}
</div>
</div>
+
+ {/* 예상 일정 미리보기 */}
+ {schedule.submissionStartOffset !== undefined && schedule.submissionDurationDays !== undefined && (
+ <div className="p-3 bg-blue-50 rounded-lg border border-blue-200">
+ <p className="text-sm font-medium text-blue-800 mb-1">📅 예상 일정 (오늘 공고 기준)</p>
+ <div className="text-sm text-blue-700">
+ <span>시작: {format(getPreviewDates().startDate, "yyyy-MM-dd HH:mm")}</span>
+ <span className="mx-2">~</span>
+ <span>마감: {format(getPreviewDates().endDate, "yyyy-MM-dd HH:mm")}</span>
+ </div>
+ </div>
+ )}
</div>
{/* 긴급 여부 */}
@@ -690,8 +830,8 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
/>
</div>
- {/* 사양설명회 상세 정보 */}
- {schedule.hasSpecificationMeeting && (
+ {/* 사양설명회 상세 정보 - T/F와 무관하게 기존 데이터가 있거나 hasSpecificationMeeting이 true면 표시 */}
+ {(schedule.hasSpecificationMeeting) && (
<div className="space-y-6 p-4 border rounded-lg bg-muted/50">
<div className="grid grid-cols-2 gap-4">
<div>
@@ -834,10 +974,19 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
<CardContent>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
- <span className="font-medium">입찰서 제출 기간:</span>
+ <span className="font-medium">시작일:</span>
+ <span>
+ {schedule.submissionStartOffset !== undefined
+ ? `결재 후 ${schedule.submissionStartOffset}일, ${schedule.submissionStartTime || '미설정'}`
+ : '미설정'
+ }
+ </span>
+ </div>
+ <div className="flex justify-between">
+ <span className="font-medium">마감일:</span>
<span>
- {schedule.submissionStartDate && schedule.submissionEndDate
- ? `${format(new Date(schedule.submissionStartDate), "yyyy-MM-dd HH:mm")} ~ ${format(new Date(schedule.submissionEndDate), "yyyy-MM-dd HH:mm")}`
+ {schedule.submissionDurationDays !== undefined
+ ? `시작일 + ${schedule.submissionDurationDays}일, ${schedule.submissionEndTime || '미설정'}`
: '미설정'
}
</span>
diff --git a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx
index de3c19ff..b0cecc25 100644
--- a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx
+++ b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx
@@ -26,13 +26,6 @@ import {
FormMessage,
FormDescription,
} from "@/components/ui/form"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import {
@@ -41,20 +34,15 @@ import {
PopoverTrigger,
} from "@/components/ui/popover"
import { Calendar } from "@/components/ui/calendar"
-import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
import { createPreQuoteRfqAction } from "@/lib/bidding/pre-quote/service"
-import { previewGeneralRfqCode } from "@/lib/rfq-last/service"
-import { MaterialGroupSelectorDialogSingle } from "@/components/common/material/material-group-selector-dialog-single"
-import { MaterialSearchItem } from "@/lib/material/material-group-service"
-import { MaterialSelectorDialogSingle } from "@/components/common/selectors/material/material-selector-dialog-single"
-import { MaterialSearchItem as SAPMaterialSearchItem } from "@/components/common/selectors/material/material-service"
import { PurchaseGroupCodeSelector } from "@/components/common/selectors/purchase-group-code/purchase-group-code-selector"
import type { PurchaseGroupCodeWithUser } from "@/components/common/selectors/purchase-group-code"
import { getBiddingById } from "@/lib/bidding/service"
+import { getProjectIdByCodeAndName } from "@/lib/bidding/manage/project-utils"
// 아이템 스키마
const itemSchema = z.object({
@@ -64,6 +52,8 @@ const itemSchema = z.object({
materialName: z.string().optional(),
quantity: z.number().min(1, "수량은 1 이상이어야 합니다"),
uom: z.string().min(1, "단위를 입력해주세요"),
+ totalWeight: z.union([z.number(), z.string(), z.null()]).optional(), // 중량 추가
+ weightUnit: z.string().optional().nullable(), // 중량단위 추가
remark: z.string().optional(),
})
@@ -125,8 +115,6 @@ export function CreatePreQuoteRfqDialog({
onSuccess
}: CreatePreQuoteRfqDialogProps) {
const [isLoading, setIsLoading] = React.useState(false)
- const [previewCode, setPreviewCode] = React.useState("")
- const [isLoadingPreview, setIsLoadingPreview] = React.useState(false)
const [selectedBidPic, setSelectedBidPic] = React.useState<PurchaseGroupCodeWithUser | undefined>(undefined)
const { data: session } = useSession()
@@ -143,6 +131,8 @@ export function CreatePreQuoteRfqDialog({
materialName: item.materialInfo || "",
quantity: item.quantity ? parseFloat(item.quantity) : 1,
uom: item.quantityUnit || item.weightUnit || "EA",
+ totalWeight: item.totalWeight ? parseFloat(item.totalWeight) : null,
+ weightUnit: item.weightUnit || null,
remark: "",
}))
}, [biddingItems])
@@ -164,6 +154,8 @@ export function CreatePreQuoteRfqDialog({
materialName: "",
quantity: 1,
uom: "",
+ totalWeight: null,
+ weightUnit: null,
remark: "",
},
],
@@ -231,6 +223,14 @@ export function CreatePreQuoteRfqDialog({
const pName = bidding.projectName || "";
setProjectInfo(pCode && pName ? `${pCode} - ${pName}` : pCode || pName || "");
+ // 프로젝트 ID 조회
+ if (pCode && pName) {
+ const fetchedProjectId = await getProjectIdByCodeAndName(pCode, pName)
+ if (fetchedProjectId) {
+ form.setValue("projectId", fetchedProjectId)
+ }
+ }
+
// 폼 값 설정
form.setValue("rfqTitle", rfqTitle);
form.setValue("rfqType", "pre_bidding"); // 기본값 설정
@@ -264,36 +264,15 @@ export function CreatePreQuoteRfqDialog({
materialName: "",
quantity: 1,
uom: "",
+ totalWeight: null,
+ weightUnit: null,
remark: "",
},
],
})
- setPreviewCode("")
}
}, [open, initialItems, form, selectedBidPic, biddingId])
- // 견적담당자 선택 시 RFQ 코드 미리보기 생성
- React.useEffect(() => {
- if (!selectedBidPic?.user?.id) {
- setPreviewCode("")
- return
- }
-
- // 즉시 실행 함수 패턴 사용
- (async () => {
- setIsLoadingPreview(true)
- try {
- const code = await previewGeneralRfqCode(selectedBidPic.user!.id)
- setPreviewCode(code)
- } catch (error) {
- console.error("코드 미리보기 오류:", error)
- setPreviewCode("")
- } finally {
- setIsLoadingPreview(false)
- }
- })()
- }, [selectedBidPic])
-
// 견적 종류 변경
const handleRfqTypeChange = (value: string) => {
form.setValue("rfqType", value)
@@ -315,12 +294,13 @@ export function CreatePreQuoteRfqDialog({
materialName: "",
quantity: 1,
uom: "",
+ totalWeight: null,
+ weightUnit: null,
remark: "",
},
],
})
setSelectedBidPic(undefined)
- setPreviewCode("")
onOpenChange(false)
}
@@ -350,15 +330,17 @@ export function CreatePreQuoteRfqDialog({
biddingNumber: data.biddingNumber, // 추가
contractStartDate: data.contractStartDate, // 추가
contractEndDate: data.contractEndDate, // 추가
- items: data.items as Array<{
- itemCode: string;
- itemName: string;
- materialCode?: string;
- materialName?: string;
- quantity: number;
- uom: string;
- remark?: string;
- }>,
+ items: data.items.map(item => ({
+ itemCode: item.itemCode || "",
+ itemName: item.itemName || "",
+ materialCode: item.materialCode,
+ materialName: item.materialName,
+ quantity: item.quantity,
+ uom: item.uom,
+ totalWeight: item.totalWeight,
+ weightUnit: item.weightUnit,
+ remark: item.remark,
+ })),
biddingConditions: biddingConditions || undefined,
createdBy: userId,
updatedBy: userId,
@@ -465,7 +447,7 @@ export function CreatePreQuoteRfqDialog({
)}
>
{field.value ? (
- format(field.value, "yyyy-MM-dd")
+ format(field.value, "yyyy-MM-dd HH:mm")
) : (
<span>제출마감일을 선택하세요 (선택)</span>
)}
@@ -477,12 +459,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 />
@@ -562,17 +572,7 @@ export function CreatePreQuoteRfqDialog({
</FormItem>
)}
/>
- {/* RFQ 코드 미리보기 */}
- {previewCode && (
- <div className="flex items-center gap-2">
- <Badge variant="secondary" className="font-mono text-sm">
- 예상 RFQ 코드: {previewCode}
- </Badge>
- {isLoadingPreview && (
- <Loader2 className="h-3 w-3 animate-spin" />
- )}
- </div>
- )}
+
{/* 계약기간 */}
<div className="grid grid-cols-2 gap-4">