diff options
Diffstat (limited to 'components/bidding/manage')
| -rw-r--r-- | components/bidding/manage/bidding-basic-info-editor.tsx | 250 | ||||
| -rw-r--r-- | components/bidding/manage/bidding-items-editor.tsx | 74 | ||||
| -rw-r--r-- | components/bidding/manage/bidding-schedule-editor.tsx | 137 |
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> |
