diff options
Diffstat (limited to 'components/bidding')
| -rw-r--r-- | components/bidding/ProjectSelectorBid.tsx | 183 | ||||
| -rw-r--r-- | components/bidding/bidding-info-header.tsx | 193 | ||||
| -rw-r--r-- | components/bidding/create/bidding-create-dialog.tsx | 118 | ||||
| -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 |
6 files changed, 455 insertions, 500 deletions
diff --git a/components/bidding/ProjectSelectorBid.tsx b/components/bidding/ProjectSelectorBid.tsx new file mode 100644 index 00000000..de9e435e --- /dev/null +++ b/components/bidding/ProjectSelectorBid.tsx @@ -0,0 +1,183 @@ +"use client" + +import * as React from "react" +import { Check, ChevronsUpDown, X } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" +import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command" +import { cn } from "@/lib/utils" +import { getProjects, type Project } from "@/lib/rfqs/service" + +interface ProjectSelectorProps { + selectedProjectId?: number | null; + onProjectSelect: (project: Project) => void; + placeholder?: string; + filterType?: string; // 옵션으로 필터 타입 지정 가능 +} + +export function ProjectSelector({ + selectedProjectId, + onProjectSelect, + placeholder = "프로젝트 선택...", + filterType +}: ProjectSelectorProps) { + const [open, setOpen] = React.useState(false) + const [searchTerm, setSearchTerm] = React.useState("") + const [projects, setProjects] = React.useState<Project[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + const [selectedProject, setSelectedProject] = React.useState<Project | null>(null) + + // 모든 프로젝트 데이터 로드 후 plant 타입만 필터링 + React.useEffect(() => { + async function loadAllProjects() { + setIsLoading(true); + try { + const allProjects = await getProjects(); + + // filterType이 지정된 경우 해당 타입만 필터링 + const filteredByType = filterType + ? allProjects.filter(p => p.type === filterType) + : allProjects; + + console.log(`Loaded ${filteredByType.length} ${filterType || 'all'} projects`); + setProjects(filteredByType); + + // 초기 선택된 프로젝트가 있으면 설정 + if (selectedProjectId) { + const selected = filteredByType.find(p => p.id === selectedProjectId); + if (selected) { + setSelectedProject(selected); + } + } + } catch (error) { + console.error("프로젝트 목록 로드 오류:", error); + } finally { + setIsLoading(false); + } + } + + loadAllProjects(); + }, [selectedProjectId, filterType]); + + // 클라이언트 측에서 검색어로 필터링 + const filteredProjects = React.useMemo(() => { + if (!searchTerm.trim()) return projects; + + const lowerSearch = searchTerm.toLowerCase(); + return projects.filter( + project => + project.projectCode.toLowerCase().includes(lowerSearch) || + project.projectName.toLowerCase().includes(lowerSearch) + ); + }, [projects, searchTerm]); + + // 프로젝트 선택 처리 + const handleSelectProject = (project: Project) => { + // 이미 선택된 프로젝트를 다시 선택하면 선택 해제 + if (selectedProject?.id === project.id) { + setSelectedProject(null); + onProjectSelect(null as any); // 선택 해제를 위해 null 전달 + setOpen(false); + return; + } + + setSelectedProject(project); + onProjectSelect(project); + setOpen(false); + }; + + // 프로젝트 선택 해제 + const handleClearSelection = (e: React.MouseEvent) => { + e.stopPropagation(); // Popover가 열리지 않도록 방지 + setSelectedProject(null); + onProjectSelect(null as any); // 선택 해제를 위해 null 전달 + }; + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={open} + className="w-full justify-between" + disabled={isLoading} + > + {isLoading ? ( + "프로젝트 로딩 중..." + ) : selectedProject ? ( + <div className="flex items-center justify-between w-full"> + <span>{selectedProject.projectCode}</span> + <div className="flex items-center gap-1"> + <Button + variant="ghost" + size="sm" + className="h-4 w-4 p-0 hover:bg-destructive hover:text-destructive-foreground" + onClick={handleClearSelection} + > + <X className="h-3 w-3" /> + </Button> + <ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" /> + </div> + </div> + ) : ( + <div className="flex items-center justify-between w-full"> + <span>{placeholder}</span> + <ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" /> + </div> + )} + </Button> + </PopoverTrigger> + <PopoverContent className="w-[400px] p-0"> + <Command> + <CommandInput + placeholder="프로젝트 코드/이름 검색..." + onValueChange={setSearchTerm} + /> + <CommandList + className="max-h-[300px]" + onWheel={(e) => { + e.stopPropagation(); + const target = e.currentTarget; + target.scrollTop += e.deltaY; + }} + > + {isLoading ? ( + <div className="py-6 text-center text-sm">로딩 중...</div> + ) : filteredProjects.length === 0 ? ( + <CommandEmpty> + {searchTerm + ? "검색 결과가 없습니다" + : `${filterType || '해당 타입의'} 프로젝트가 없습니다`} + </CommandEmpty> + ) : ( + <CommandGroup> + {filteredProjects.map((project) => ( + <CommandItem + key={project.id} + value={`${project.projectCode} ${project.projectName}`} + onSelect={() => handleSelectProject(project)} + > + <Check + className={cn( + "mr-2 h-4 w-4", + selectedProject?.id === project.id + ? "opacity-100" + : "opacity-0" + )} + /> + <span className="font-medium">{project.projectCode}</span> + <span className="ml-2 text-gray-500 truncate">- {project.projectName}</span> + {selectedProject?.id === project.id && ( + <span className="ml-auto text-xs text-muted-foreground">(선택됨)</span> + )} + </CommandItem> + ))} + </CommandGroup> + )} + </CommandList> + </Command> + </PopoverContent> + </Popover> + ); +}
\ No newline at end of file diff --git a/components/bidding/bidding-info-header.tsx b/components/bidding/bidding-info-header.tsx deleted file mode 100644 index 0b2d2b47..00000000 --- a/components/bidding/bidding-info-header.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import { Bidding } from '@/db/schema/bidding' -import { Building2, User, DollarSign, Calendar, FileText } from 'lucide-react' -import { contractTypeLabels, biddingTypeLabels, awardCountLabels } from '@/db/schema/bidding' -import { formatDate } from '@/lib/utils' - -interface BiddingInfoHeaderProps { - bidding: Bidding | null -} - -export function BiddingInfoHeader({ bidding }: BiddingInfoHeaderProps) { - if (!bidding) { - return ( - <div className="bg-white border rounded-lg p-6 mb-6 shadow-sm"> - <div className="text-center text-gray-500">입찰 정보를 불러오는 중...</div> - </div> - ) - } - - return ( - <div className="bg-white border rounded-lg p-6 mb-6 shadow-sm"> - {/* 4개 섹션을 Grid로 배치 */} - <div className="grid grid-cols-1 lg:grid-cols-4 gap-6"> - {/* 1. 프로젝트 및 품목 정보 */} - <div className="w-full space-y-4"> - <div className="flex items-center gap-2 text-sm font-semibold text-gray-700 mb-3"> - <Building2 className="w-4 h-4" /> - <span>기본 정보</span> - </div> - - {bidding.projectName && ( - <div> - <div className="text-xs text-gray-500 mb-1">프로젝트</div> - <div className="font-medium text-gray-900 text-sm">{bidding.projectName}</div> - </div> - )} - - {bidding.itemName && ( - <div> - <div className="text-xs text-gray-500 mb-1">품목</div> - <div className="font-medium text-gray-900 text-sm">{bidding.itemName}</div> - </div> - )} - - {bidding.prNumber && ( - <div> - <div className="text-xs text-gray-500 mb-1">PR No.</div> - <div className="font-mono text-sm font-medium text-gray-900">{bidding.prNumber}</div> - </div> - )} - - {bidding.purchasingOrganization && ( - <div> - <div className="text-xs text-gray-500 mb-1">구매조직</div> - <div className="font-medium text-gray-900 text-sm">{bidding.purchasingOrganization}</div> - </div> - )} - </div> - - {/* 2. 담당자 및 예산 정보 */} - <div className="w-full border-l border-gray-100 pl-6 space-y-4"> - <div className="flex items-center gap-2 text-sm font-semibold text-gray-700 mb-3"> - <User className="w-4 h-4" /> - <span>담당자 정보</span> - </div> - - {bidding.bidPicName && ( - <div> - <div className="text-xs text-gray-500 mb-1">입찰담당자</div> - <div className="font-medium text-gray-900 text-sm"> - {bidding.bidPicName} - {bidding.bidPicCode && ( - <span className="ml-2 text-xs text-gray-500">({bidding.bidPicCode})</span> - )} - </div> - </div> - )} - - {bidding.supplyPicName && ( - <div> - <div className="text-xs text-gray-500 mb-1">조달담당자</div> - <div className="font-medium text-gray-900 text-sm"> - {bidding.supplyPicName} - {bidding.supplyPicCode && ( - <span className="ml-2 text-xs text-gray-500">({bidding.supplyPicCode})</span> - )} - </div> - </div> - )} - - {bidding.budget && ( - <div> - <div className="flex items-center gap-1.5 text-xs text-gray-500 mb-1"> - <DollarSign className="w-3 h-3" /> - <span>예산</span> - </div> - <div className="font-semibold text-gray-900 text-sm"> - {new Intl.NumberFormat('ko-KR', { - style: 'currency', - currency: bidding.currency || 'KRW', - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(Number(bidding.budget))} - </div> - </div> - )} - </div> - - {/* 3. 계약 정보 */} - <div className="w-full border-l border-gray-100 pl-6 space-y-4"> - <div className="flex items-center gap-2 text-sm font-semibold text-gray-700 mb-3"> - <FileText className="w-4 h-4" /> - <span>계약 정보</span> - </div> - - <div className="grid grid-cols-2 gap-3"> - <div> - <div className="text-xs text-gray-500 mb-1">계약구분</div> - <div className="font-medium text-sm text-gray-900">{contractTypeLabels[bidding.contractType]}</div> - </div> - - <div> - <div className="text-xs text-gray-500 mb-1">입찰유형</div> - <div className="font-medium text-sm text-gray-900">{biddingTypeLabels[bidding.biddingType]}</div> - </div> - - <div> - <div className="text-xs text-gray-500 mb-1">낙찰수</div> - <div className="font-medium text-sm text-gray-900"> - {bidding.awardCount ? awardCountLabels[bidding.awardCount] : '-'} - </div> - </div> - - <div> - <div className="text-xs text-gray-500 mb-1">통화</div> - <div className="font-mono font-medium text-sm text-gray-900">{bidding.currency}</div> - </div> - </div> - - {(bidding.contractStartDate || bidding.contractEndDate) && ( - <div> - <div className="text-xs text-gray-500 mb-1">계약기간</div> - <div className="font-medium text-sm text-gray-900"> - {bidding.contractStartDate && formatDate(bidding.contractStartDate, 'KR')} - {bidding.contractStartDate && bidding.contractEndDate && ' ~ '} - {bidding.contractEndDate && formatDate(bidding.contractEndDate, 'KR')} - </div> - </div> - )} - </div> - - {/* 4. 일정 정보 */} - <div className="w-full border-l border-gray-100 pl-6 space-y-4"> - <div className="flex items-center gap-2 text-sm font-semibold text-gray-700 mb-3"> - <Calendar className="w-4 h-4" /> - <span>일정 정보</span> - </div> - - {bidding.biddingRegistrationDate && ( - <div> - <div className="text-xs text-gray-500 mb-1">입찰등록일</div> - <div className="font-medium text-sm text-gray-900">{formatDate(bidding.biddingRegistrationDate, 'KR')}</div> - </div> - )} - - {bidding.preQuoteDate && ( - <div> - <div className="text-xs text-gray-500 mb-1">사전견적일</div> - <div className="font-medium text-sm text-gray-900">{formatDate(bidding.preQuoteDate, 'KR')}</div> - </div> - )} - - {bidding.submissionStartDate && bidding.submissionEndDate && ( - <div> - <div className="text-xs text-gray-500 mb-1">제출기간</div> - <div className="font-medium text-sm text-gray-900"> - {formatDate(bidding.submissionStartDate, 'KR')} - <div className="text-xs text-gray-400">~</div> - {formatDate(bidding.submissionEndDate, 'KR')} - </div> - </div> - )} - - {bidding.evaluationDate && ( - <div> - <div className="text-xs text-gray-500 mb-1">평가일</div> - <div className="font-medium text-sm text-gray-900">{formatDate(bidding.evaluationDate, 'KR')}</div> - </div> - )} - </div> - </div> - </div> - ) -} diff --git a/components/bidding/create/bidding-create-dialog.tsx b/components/bidding/create/bidding-create-dialog.tsx index ad9555db..9b0a6f66 100644 --- a/components/bidding/create/bidding-create-dialog.tsx +++ b/components/bidding/create/bidding-create-dialog.tsx @@ -45,6 +45,7 @@ import { DropzoneDescription,
DropzoneInput,
DropzoneTitle,
+ DropzoneTrigger,
DropzoneUploadIcon,
DropzoneZone,
} from "@/components/ui/dropzone"
@@ -198,28 +199,28 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp React.useEffect(() => {
const loadNoticeTemplate = async () => {
- if (selectedNoticeType) {
- setIsLoadingTemplate(true)
- try {
- const template = await getBiddingNoticeTemplate(selectedNoticeType)
- if (template) {
- setNoticeTemplate(template.content)
- // 폼의 content 필드도 업데이트
- form.setValue('content', template.content)
- } else {
- // 템플릿이 없으면 표준 템플릿 사용
- const defaultTemplate = await getBiddingNoticeTemplate('standard')
- if (defaultTemplate) {
- setNoticeTemplate(defaultTemplate.content)
- form.setValue('content', defaultTemplate.content)
- }
+ setIsLoadingTemplate(true)
+ try {
+ // 처음 로드할 때는 무조건 standard 템플릿 사용
+ const templateType = selectedNoticeType || 'standard'
+ const template = await getBiddingNoticeTemplate(templateType)
+ if (template) {
+ setNoticeTemplate(template.content)
+ // 폼의 content 필드도 업데이트
+ form.setValue('content', template.content)
+ } else {
+ // 템플릿이 없으면 표준 템플릿 사용
+ const defaultTemplate = await getBiddingNoticeTemplate('standard')
+ if (defaultTemplate) {
+ setNoticeTemplate(defaultTemplate.content)
+ form.setValue('content', defaultTemplate.content)
}
- } catch (error) {
- console.error('Failed to load notice template:', error)
- toast.error('입찰공고 템플릿을 불러오는데 실패했습니다.')
- } finally {
- setIsLoadingTemplate(false)
}
+ } catch (error) {
+ console.error('Failed to load notice template:', error)
+ toast.error('입찰공고 템플릿을 불러오는데 실패했습니다.')
+ } finally {
+ setIsLoadingTemplate(false)
}
}
@@ -279,30 +280,13 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp return
}
- // 첨부파일 정보 설정 (실제로는 파일 업로드 후 저장해야 함)
- const attachments = shiAttachmentFiles.map((file, index) => ({
- id: `shi_${Date.now()}_${index}`,
- fileName: file.name,
- fileSize: file.size,
- filePath: '', // 실제 업로드 후 경로
- uploadedAt: new Date().toISOString(),
- type: 'shi' as const,
- }))
-
- const vendorAttachments = vendorAttachmentFiles.map((file, index) => ({
- id: `vendor_${Date.now()}_${index}`,
- fileName: file.name,
- fileSize: file.size,
- filePath: '', // 실제 업로드 후 경로
- uploadedAt: new Date().toISOString(),
- type: 'vendor' as const,
- }))
+ // 첨부파일 정보 설정
// sparePartOptions가 undefined인 경우 빈 문자열로 설정
const biddingData = {
...data,
- attachments,
- vendorAttachments,
+ attachments: shiAttachmentFiles, // 실제 파일 객체들 전달
+ vendorAttachments: vendorAttachmentFiles, // 실제 파일 객체들 전달
biddingConditions: {
...data.biddingConditions,
sparePartOptions: data.biddingConditions.sparePartOptions || '',
@@ -396,7 +380,7 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
- {/* 1행: 입찰명, 낙찰수, 입찰유형, 계약구분 */}
+ {/* 1행: 입찰명, 낙찰업체 수, 입찰유형, 계약구분 */}
<div className="grid grid-cols-4 gap-4">
<FormField
control={form.control}
@@ -417,11 +401,11 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp name="awardCount"
render={({ field }) => (
<FormItem>
- <FormLabel>낙찰수 <span className="text-red-500">*</span></FormLabel>
+ <FormLabel>낙찰업체 수<span className="text-red-500">*</span></FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
- <SelectValue placeholder="낙찰수 선택" />
+ <SelectValue placeholder="낙찰업체 수 선택" />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -982,9 +966,15 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp </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 />
@@ -1138,12 +1128,12 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp )}
/>
- {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>
- )}
+ )} */}
</CardContent>
</Card>
@@ -1174,14 +1164,16 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp }}
>
{() => (
- <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>
@@ -1246,14 +1238,16 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp }}
>
{() => (
- <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>
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> |
