summaryrefslogtreecommitdiff
path: root/components/bidding/manage
diff options
context:
space:
mode:
Diffstat (limited to 'components/bidding/manage')
-rw-r--r--components/bidding/manage/bidding-basic-info-editor.tsx250
-rw-r--r--components/bidding/manage/bidding-items-editor.tsx74
-rw-r--r--components/bidding/manage/bidding-schedule-editor.tsx137
3 files changed, 216 insertions, 245 deletions
diff --git a/components/bidding/manage/bidding-basic-info-editor.tsx b/components/bidding/manage/bidding-basic-info-editor.tsx
index f0d56689..c2c668a4 100644
--- a/components/bidding/manage/bidding-basic-info-editor.tsx
+++ b/components/bidding/manage/bidding-basic-info-editor.tsx
@@ -51,6 +51,7 @@ import {
DropzoneDescription,
DropzoneInput,
DropzoneTitle,
+ DropzoneTrigger,
DropzoneUploadIcon,
DropzoneZone,
} from "@/components/ui/dropzone"
@@ -113,8 +114,6 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
const [noticeTemplate, setNoticeTemplate] = React.useState('')
// 첨부파일 관련 상태
- const [shiAttachmentFiles, setShiAttachmentFiles] = React.useState<File[]>([])
- const [vendorAttachmentFiles, setVendorAttachmentFiles] = React.useState<File[]>([])
const [existingDocuments, setExistingDocuments] = React.useState<UploadedDocument[]>([])
const [isLoadingDocuments, setIsLoadingDocuments] = React.useState(false)
@@ -371,7 +370,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
const result = await uploadBiddingDocument(
biddingId,
file,
- 'bid_attachment',
+ 'evaluation_doc', // SHI용 문서 타입
file.name,
'SHI용 첨부파일',
'1' // TODO: 실제 사용자 ID 가져오기
@@ -381,17 +380,12 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
}
}
await loadExistingDocuments()
- setShiAttachmentFiles([])
} catch (error) {
console.error('Failed to upload SHI files:', error)
toast.error('파일 업로드에 실패했습니다.')
}
}
- const removeShiFile = (index: number) => {
- setShiAttachmentFiles(prev => prev.filter((_, i) => i !== index))
- }
-
// 협력업체용 파일 첨부 핸들러
const handleVendorFileUpload = async (files: File[]) => {
try {
@@ -400,7 +394,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
const result = await uploadBiddingDocument(
biddingId,
file,
- 'bid_attachment',
+ 'company_proposal', // 협력업체용 문서 타입
file.name,
'협력업체용 첨부파일',
'1' // TODO: 실제 사용자 ID 가져오기
@@ -410,17 +404,12 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
}
}
await loadExistingDocuments()
- setVendorAttachmentFiles([])
} catch (error) {
console.error('Failed to upload vendor files:', error)
toast.error('파일 업로드에 실패했습니다.')
}
}
- const removeVendorFile = (index: number) => {
- setVendorAttachmentFiles(prev => prev.filter((_, i) => i !== index))
- }
-
// 파일 삭제
const handleDeleteDocument = async (documentId: number) => {
if (!confirm('이 파일을 삭제하시겠습니까?')) {
@@ -623,7 +612,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
</div>
)}
- {/* 2행: 예산, 실적가, 내정가, 낙찰수 */}
+ {/* 2행: 예산, 실적가, 내정가, 낙찰업체 수 */}
<div className="grid grid-cols-4 gap-4">
<FormField control={form.control} name="budget" render={({ field }) => (
<FormItem>
@@ -666,11 +655,11 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
<FormField control={form.control} name="awardCount" render={({ field }) => (
<FormItem>
- <FormLabel>낙찰수</FormLabel>
+ <FormLabel>낙찰업체 수</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
- <SelectValue placeholder="낙찰수 선택" />
+ <SelectValue placeholder="낙찰업체 수 선택" />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -741,9 +730,15 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
</SelectTrigger>
</FormControl>
<SelectContent>
- <SelectItem value="조선">조선</SelectItem>
- <SelectItem value="해양">해양</SelectItem>
- <SelectItem value="기타">기타</SelectItem>
+ <SelectItem value="Shipbuild & Offshore">Shipbuild & Offshore</SelectItem>
+ <SelectItem value="Wind Energy">Wind Energy</SelectItem>
+ <SelectItem value="Power & Control Sys.">Power & Control Sys.</SelectItem>
+ <SelectItem value="SHI NINGBO Co., LTD">SHI NINGBO Co., LTD</SelectItem>
+ <SelectItem value="RONGCHENG Co.LTD">RONGCHENG Co.LTD</SelectItem>
+ <SelectItem value="RONGCHENGGAYA Co.LTD">RONGCHENGGAYA Co.LTD</SelectItem>
+ <SelectItem value="S&Sys">S&Sys</SelectItem>
+ <SelectItem value="Energy & Infra Solut">Energy & Infra Solut</SelectItem>
+ <SelectItem value="test pur.org222">test pur.org222</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@@ -825,69 +820,8 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
<FormMessage />
</FormItem>
)} />
-
- {/* <FormField control={form.control} name="submissionStartDate" render={({ field }) => (
- <FormItem>
- <FormLabel>입찰서 제출 시작</FormLabel>
- <FormControl>
- <Input type="datetime-local" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )} />
-
- <FormField control={form.control} name="submissionEndDate" render={({ field }) => (
- <FormItem>
- <FormLabel>입찰서 제출 마감</FormLabel>
- <FormControl>
- <Input type="datetime-local" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )} /> */}
</div>
- {/* 5행: 개찰 일시, 사양설명회, PR문서 */}
- {/* <div className="grid grid-cols-3 gap-4">
- <FormField control={form.control} name="evaluationDate" render={({ field }) => (
- <FormItem>
- <FormLabel>개찰 일시</FormLabel>
- <FormControl>
- <Input type="datetime-local" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )} /> */}
-
- {/* <FormField control={form.control} name="hasSpecificationMeeting" render={({ field }) => (
- <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
- <div className="space-y-0.5">
- <FormLabel className="text-base">사양설명회</FormLabel>
- <div className="text-sm text-muted-foreground">
- 사양설명회가 필요한 경우 체크
- </div>
- </div>
- <FormControl>
- <Switch checked={field.value} onCheckedChange={field.onChange} />
- </FormControl>
- </FormItem>
- )} /> */}
-
- {/* <FormField control={form.control} name="hasPrDocument" render={({ field }) => (
- <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
- <div className="space-y-0.5">
- <FormLabel className="text-base">PR 문서</FormLabel>
- <div className="text-sm text-muted-foreground">
- PR 문서가 있는 경우 체크
- </div>
- </div>
- <FormControl>
- <Switch checked={field.value} onCheckedChange={field.onChange} />
- </FormControl>
- </FormItem>
- )} /> */}
- {/* </div> */}
-
{/* 입찰개요 */}
<div className="pt-2">
<FormField control={form.control} name="description" render={({ field }) => (
@@ -902,7 +836,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
</div>
{/* 비고 */}
- <div className="pt-2">
+ {/* <div className="pt-2">
<FormField control={form.control} name="remarks" render={({ field }) => (
<FormItem>
<FormLabel>비고</FormLabel>
@@ -912,7 +846,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
<FormMessage />
</FormItem>
)} />
- </div>
+ </div> */}
{/* 입찰 조건 */}
<div className="pt-4 border-t">
@@ -1100,24 +1034,6 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
}}
/>
</div>
-
- {/* <div className="flex flex-row items-center justify-between rounded-lg border p-3">
- <div className="space-y-0.5">
- <FormLabel className="text-base">연동제 적용 가능</FormLabel>
- <div className="text-sm text-muted-foreground">
- 연동제 적용 요건 여부
- </div>
- </div>
- <Switch
- checked={biddingConditions.isPriceAdjustmentApplicable}
- onCheckedChange={(checked) => {
- setBiddingConditions(prev => ({
- ...prev,
- isPriceAdjustmentApplicable: checked
- }))
- }}
- />
- </div> */}
</div>
{/* 5행: 스페어파트 옵션 */}
@@ -1159,12 +1075,12 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
</FormItem>
)} />
- {isLoadingTemplate && (
+ {/* {isLoadingTemplate && (
<div className="flex items-center justify-center p-4 text-sm text-muted-foreground">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900 mr-2"></div>
입찰공고 템플릿을 불러오는 중...
</div>
- )}
+ )} */}
</div>
{/* 액션 버튼 */}
@@ -1195,9 +1111,10 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
<CardContent className="space-y-4">
<Dropzone
maxSize={6e8} // 600MB
- onDropAccepted={(files) => {
+ onDropAccepted={async (files) => {
const newFiles = Array.from(files)
- setShiAttachmentFiles(prev => [...prev, ...newFiles])
+ // 파일을 즉시 업로드
+ await handleShiFileUpload(newFiles)
}}
onDropRejected={() => {
toast({
@@ -1208,60 +1125,19 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
}}
>
{() => (
- <DropzoneZone className="flex justify-center h-32">
- <div className="flex items-center gap-6">
- <DropzoneUploadIcon />
- <div className="grid gap-0.5">
- <DropzoneTitle>파일을 드래그하여 업로드</DropzoneTitle>
+ <DropzoneTrigger asChild>
+ <DropzoneZone className="flex justify-center h-32">
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>파일을 드래그하여 업로드</DropzoneTitle>
+ </div>
</div>
- </div>
- </DropzoneZone>
+ </DropzoneZone>
+ </DropzoneTrigger>
)}
</Dropzone>
- {shiAttachmentFiles.length > 0 && (
- <div className="space-y-2">
- <h4 className="text-sm font-medium">업로드 예정 파일</h4>
- <div className="space-y-2">
- {shiAttachmentFiles.map((file, index) => (
- <div
- key={index}
- 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">{file.name}</p>
- <p className="text-xs text-muted-foreground">
- {(file.size / 1024 / 1024).toFixed(2)} MB
- </p>
- </div>
- </div>
- <div className="flex gap-2">
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => {
- handleShiFileUpload([file])
- }}
- >
- 업로드
- </Button>
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => removeShiFile(index)}
- >
- 제거
- </Button>
- </div>
- </div>
- ))}
- </div>
- </div>
- )}
{/* 기존 문서 목록 */}
{isLoadingDocuments ? (
@@ -1329,9 +1205,10 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
<CardContent className="space-y-4">
<Dropzone
maxSize={6e8} // 600MB
- onDropAccepted={(files) => {
+ onDropAccepted={async (files) => {
const newFiles = Array.from(files)
- setVendorAttachmentFiles(prev => [...prev, ...newFiles])
+ // 파일을 즉시 업로드
+ await handleVendorFileUpload(newFiles)
}}
onDropRejected={() => {
toast({
@@ -1342,60 +1219,19 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
}}
>
{() => (
- <DropzoneZone className="flex justify-center h-32">
- <div className="flex items-center gap-6">
- <DropzoneUploadIcon />
- <div className="grid gap-0.5">
- <DropzoneTitle>파일을 드래그하여 업로드</DropzoneTitle>
+ <DropzoneTrigger asChild>
+ <DropzoneZone className="flex justify-center h-32">
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>파일을 드래그하여 업로드</DropzoneTitle>
+ </div>
</div>
- </div>
- </DropzoneZone>
+ </DropzoneZone>
+ </DropzoneTrigger>
)}
</Dropzone>
- {vendorAttachmentFiles.length > 0 && (
- <div className="space-y-2">
- <h4 className="text-sm font-medium">업로드 예정 파일</h4>
- <div className="space-y-2">
- {vendorAttachmentFiles.map((file, index) => (
- <div
- key={index}
- 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">{file.name}</p>
- <p className="text-xs text-muted-foreground">
- {(file.size / 1024 / 1024).toFixed(2)} MB
- </p>
- </div>
- </div>
- <div className="flex gap-2">
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => {
- handleVendorFileUpload([file])
- }}
- >
- 업로드
- </Button>
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => removeVendorFile(index)}
- >
- 제거
- </Button>
- </div>
- </div>
- ))}
- </div>
- </div>
- )}
{/* 기존 문서 목록 */}
{existingDocuments.length > 0 && (
diff --git a/components/bidding/manage/bidding-items-editor.tsx b/components/bidding/manage/bidding-items-editor.tsx
index 38113dfa..f0287ae4 100644
--- a/components/bidding/manage/bidding-items-editor.tsx
+++ b/components/bidding/manage/bidding-items-editor.tsx
@@ -18,7 +18,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
-import { ProjectSelector } from '@/components/ProjectSelector'
+import { ProjectSelector } from '@/components/bidding/ProjectSelectorBid'
import { MaterialGroupSelectorDialogSingle } from '@/components/common/material/material-group-selector-dialog-single'
import { MaterialSelectorDialogSingle } from '@/components/common/selectors/material/material-selector-dialog-single'
import { WbsCodeSingleSelector } from '@/components/common/selectors/wbs-code/wbs-code-single-selector'
@@ -255,12 +255,12 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
costCenterName: item.costCenterName || null,
glAccountCode: item.glAccountCode || null,
glAccountName: item.glAccountName || null,
- targetUnitPrice: item.targetUnitPrice ? parseFloat(item.targetUnitPrice) : null,
+ targetUnitPrice: item.targetUnitPrice ? parseFloat(item.targetUnitPrice.replace(/,/g, '')) : null,
targetAmount: targetAmount ? parseFloat(targetAmount) : null,
targetCurrency: item.targetCurrency || 'KRW',
- budgetAmount: item.budgetAmount ? parseFloat(item.budgetAmount) : null,
+ budgetAmount: item.budgetAmount ? parseFloat(item.budgetAmount.replace(/,/g, '')) : null,
budgetCurrency: item.budgetCurrency || 'KRW',
- actualAmount: item.actualAmount ? parseFloat(item.actualAmount) : null,
+ actualAmount: item.actualAmount ? parseFloat(item.actualAmount.replace(/,/g, '')) : null,
actualCurrency: item.actualCurrency || 'KRW',
requestedDeliveryDate: item.requestedDeliveryDate ? new Date(item.requestedDeliveryDate) : null,
currency: item.currency || 'KRW',
@@ -291,12 +291,12 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
costCenterName: item.costCenterName ?? null,
glAccountCode: item.glAccountCode ?? null,
glAccountName: item.glAccountName ?? null,
- targetUnitPrice: item.targetUnitPrice ?? null,
- targetAmount: targetAmount ?? null,
+ targetUnitPrice: item.targetUnitPrice ? item.targetUnitPrice.replace(/,/g, '') : null,
+ targetAmount: targetAmount,
targetCurrency: item.targetCurrency || 'KRW',
- budgetAmount: item.budgetAmount ?? null,
+ budgetAmount: item.budgetAmount ? item.budgetAmount.replace(/,/g, '') : null,
budgetCurrency: item.budgetCurrency || 'KRW',
- actualAmount: item.actualAmount ?? null,
+ actualAmount: item.actualAmount ? item.actualAmount.replace(/,/g, '') : null,
actualCurrency: item.actualCurrency || 'KRW',
requestedDeliveryDate: item.requestedDeliveryDate ?? null,
currency: item.currency || 'KRW',
@@ -519,8 +519,20 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
setQuantityWeightMode(mode)
}
- const calculateTargetAmount = (item: PRItemInfo) => {
- const unitPrice = parseFloat(item.targetUnitPrice || '0') || 0
+ // 천단위 콤마 포맷팅 헬퍼 함수들
+ const formatNumberWithCommas = (value: string | number | null | undefined): string => {
+ if (!value) return ''
+ const numValue = typeof value === 'number' ? value : parseFloat(value.toString().replace(/,/g, ''))
+ if (isNaN(numValue)) return ''
+ return numValue.toLocaleString()
+ }
+
+ const parseNumberFromCommas = (value: string): string => {
+ return value.replace(/,/g, '')
+ }
+
+ const calculateTargetAmount = (item: PRItemInfo): string => {
+ const unitPrice = parseFloat(item.targetUnitPrice?.replace(/,/g, '') || '0') || 0
const purchaseUnit = parseFloat(item.purchaseUnit || '1') || 1
let amount = 0
@@ -560,6 +572,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
</th>
<th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">프로젝트코드</th>
<th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">프로젝트명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">PR 번호</th>
<th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">자재그룹코드 <span className="text-red-500">*</span></th>
<th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">자재그룹명 <span className="text-red-500">*</span></th>
<th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">자재코드</th>
@@ -580,7 +593,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
<th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">코스트센터명</th>
<th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">GL계정코드</th>
<th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">GL계정명</th>
- <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">납품요청일</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">납품요청일 <span className="text-red-500">*</span></th>
<th className="sticky right-0 z-10 bg-muted/50 border-l px-3 py-3 text-center text-xs font-medium min-w-[100px]">
액션
</th>
@@ -621,6 +634,14 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
/>
</td>
<td className="border-r px-3 py-2">
+ <Input
+ placeholder="PR 번호"
+ value={item.prNumber || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
{biddingType !== 'equipment' ? (
<ProcurementItemSelectorDialogSingle
triggerLabel={item.materialGroupNumber || "품목 선택"}
@@ -784,23 +805,19 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
</td>
<td className="border-r px-3 py-2">
<Input
- type="number"
- min="0"
- step="1"
+ type="text"
placeholder="내정단가"
- value={item.targetUnitPrice || ''}
- onChange={(e) => updatePRItem(item.id, { targetUnitPrice: e.target.value })}
+ value={formatNumberWithCommas(item.targetUnitPrice)}
+ onChange={(e) => updatePRItem(item.id, { targetUnitPrice: parseNumberFromCommas(e.target.value) })}
className="h-8 text-xs"
/>
</td>
<td className="border-r px-3 py-2">
<Input
- type="number"
- min="0"
- step="1"
+ type="text"
placeholder="내정금액"
readOnly
- value={item.targetAmount || ''}
+ value={formatNumberWithCommas(item.targetAmount)}
className="h-8 text-xs bg-muted/50"
/>
</td>
@@ -822,12 +839,10 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
</td>
<td className="border-r px-3 py-2">
<Input
- type="number"
- min="0"
- step="1"
+ type="text"
placeholder="예산금액"
- value={item.budgetAmount || ''}
- onChange={(e) => updatePRItem(item.id, { budgetAmount: e.target.value })}
+ value={formatNumberWithCommas(item.budgetAmount)}
+ onChange={(e) => updatePRItem(item.id, { budgetAmount: parseNumberFromCommas(e.target.value) })}
className="h-8 text-xs"
/>
</td>
@@ -849,12 +864,10 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
</td>
<td className="border-r px-3 py-2">
<Input
- type="number"
- min="0"
- step="1"
+ type="text"
placeholder="실적금액"
- value={item.actualAmount || ''}
- onChange={(e) => updatePRItem(item.id, { actualAmount: e.target.value })}
+ value={formatNumberWithCommas(item.actualAmount)}
+ onChange={(e) => updatePRItem(item.id, { actualAmount: parseNumberFromCommas(e.target.value) })}
className="h-8 text-xs"
/>
</td>
@@ -1030,6 +1043,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
value={item.requestedDeliveryDate || ''}
onChange={(e) => updatePRItem(item.id, { requestedDeliveryDate: e.target.value })}
className="h-8 text-xs"
+ required
/>
</td>
<td className="sticky right-0 z-10 bg-background border-l px-3 py-2">
diff --git a/components/bidding/manage/bidding-schedule-editor.tsx b/components/bidding/manage/bidding-schedule-editor.tsx
index f3260f04..b5f4aaf0 100644
--- a/components/bidding/manage/bidding-schedule-editor.tsx
+++ b/components/bidding/manage/bidding-schedule-editor.tsx
@@ -16,7 +16,7 @@ import { ApprovalPreviewDialog } from '@/lib/approval/approval-preview-dialog'
import { requestBiddingInvitationWithApproval } from '@/lib/bidding/approval-actions'
import { prepareBiddingApprovalData } from '@/lib/bidding/approval-actions'
import { BiddingInvitationDialog } from '@/lib/bidding/detail/table/bidding-invitation-dialog'
-import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from '@/lib/bidding/pre-quote/service'
+import { sendBiddingBasicContracts, getSelectedVendorsForBidding, getPrItemsForBidding } from '@/lib/bidding/pre-quote/service'
import { registerBidding } from '@/lib/bidding/detail/service'
import { useToast } from '@/hooks/use-toast'
import { format } from 'date-fns'
@@ -61,6 +61,13 @@ interface VendorContractRequirement {
agreementYn?: boolean
biddingCompanyId: number
biddingId: number
+ isPreQuoteSelected?: boolean
+ contacts?: Array<{
+ id: number
+ contactName: string
+ contactEmail: string
+ contactNumber?: string | null
+ }>
}
interface VendorWithContactInfo extends VendorContractRequirement {
@@ -216,6 +223,8 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
agreementYn: vendor.agreementYn,
biddingCompanyId: vendor.biddingCompanyId,
biddingId: vendor.biddingId,
+ isPreQuoteSelected: vendor.isPreQuoteSelected,
+ contacts: vendor.contacts || [],
}))
} else {
console.error('선정된 업체 조회 실패:', 'error' in result ? result.error : '알 수 없는 오류')
@@ -237,8 +246,64 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
}, [isBiddingInvitationDialogOpen, getSelectedVendors])
// 입찰공고 버튼 클릭 핸들러 - 입찰 초대 다이얼로그 열기
- const handleBiddingInvitationClick = () => {
- setIsBiddingInvitationDialogOpen(true)
+ const handleBiddingInvitationClick = async () => {
+ try {
+ // 1. 입찰서 제출기간 검증
+ if (!schedule.submissionStartDate || !schedule.submissionEndDate) {
+ toast({
+ title: '입찰서 제출기간 미설정',
+ description: '입찰서 제출 시작일시와 마감일시를 모두 설정해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ // 2. 선정된 업체들 조회 및 검증
+ const vendors = await getSelectedVendors()
+ if (vendors.length === 0) {
+ toast({
+ title: '선정된 업체 없음',
+ description: '입찰에 참여할 업체가 없습니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ // 3. 업체 담당자 검증
+ const vendorsWithoutContacts = vendors.filter(vendor =>
+ !vendor.contacts || vendor.contacts.length === 0
+ )
+ if (vendorsWithoutContacts.length > 0) {
+ toast({
+ title: '업체 담당자 정보 부족',
+ description: `${vendorsWithoutContacts.length}개 업체의 담당자가 없습니다. 각 업체에 담당자를 추가해주세요.`,
+ variant: 'destructive',
+ })
+ return
+ }
+
+ // 4. 입찰 품목 검증
+ const prItems = await getPrItemsForBidding(biddingId)
+ if (!prItems || prItems.length === 0) {
+ toast({
+ title: '입찰 품목 없음',
+ description: '입찰에 포함할 품목이 없습니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ // 모든 검증 통과 시 다이얼로그 열기
+ setSelectedVendors(vendors)
+ setIsBiddingInvitationDialogOpen(true)
+ } catch (error) {
+ console.error('입찰공고 검증 중 오류 발생:', error)
+ toast({
+ title: '오류',
+ description: '입찰공고 검증 중 오류가 발생했습니다.',
+ variant: 'destructive',
+ })
+ }
}
// 결재 상신 핸들러 - 결재 완료 시 실제 입찰 등록 실행
@@ -331,7 +396,7 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
// 입찰 초대 발송 핸들러 - 결재 준비 및 결재 다이얼로그 열기
const handleBiddingInvitationSend = async (data: BiddingInvitationData) => {
try {
- if (!session?.user?.id || !session.user.epId) {
+ if (!session?.user?.id) {
toast({
title: '오류',
description: '사용자 정보가 없습니다.',
@@ -384,7 +449,18 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
setIsSubmitting(true)
try {
const userId = session?.user?.id?.toString() || '1'
-
+
+ // 입찰서 제출기간 필수 검증
+ if (!schedule.submissionStartDate || !schedule.submissionEndDate) {
+ toast({
+ title: '입찰서 제출기간 미설정',
+ description: '입찰서 제출 시작일시와 마감일시를 모두 설정해주세요.',
+ variant: 'destructive',
+ })
+ setIsSubmitting(false)
+ return
+ }
+
// 사양설명회 정보 유효성 검사
if (schedule.hasSpecificationMeeting) {
if (!specMeetingInfo.meetingDate || !specMeetingInfo.location || !specMeetingInfo.contactPerson) {
@@ -430,8 +506,45 @@ 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) {
+ toast({
+ title: '마감일시 오류',
+ description: '마감일시는 현재일 이전으로 설정할 수 없습니다.',
+ variant: 'destructive',
+ })
+ 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 (!isUrgent && selectedDate.getTime() === today.getTime()) {
+ toast({
+ title: '제출 시작일시 오류',
+ description: '긴급 입찰이 아닌 경우 당일 제출 시작은 불가능합니다.',
+ variant: 'destructive',
+ })
+ return // 변경을 적용하지 않음
+ }
+ }
+
setSchedule(prev => ({ ...prev, [field]: value }))
-
+
// 사양설명회 실시 여부가 false로 변경되면 상세 정보 초기화
if (field === 'hasSpecificationMeeting' && value === false) {
setSpecMeetingInfo({
@@ -480,22 +593,30 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
- <Label htmlFor="submission-start">제출 시작일시</Label>
+ <Label htmlFor="submission-start">제출 시작일시 <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' : ''}
/>
+ {!schedule.submissionStartDate && (
+ <p className="text-sm text-red-500">제출 시작일시는 필수입니다</p>
+ )}
</div>
<div className="space-y-2">
- <Label htmlFor="submission-end">제출 마감일시</Label>
+ <Label htmlFor="submission-end">제출 마감일시 <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' : ''}
/>
+ {!schedule.submissionEndDate && (
+ <p className="text-sm text-red-500">제출 마감일시는 필수입니다</p>
+ )}
</div>
</div>
</div>