summaryrefslogtreecommitdiff
path: root/components/bidding
diff options
context:
space:
mode:
Diffstat (limited to 'components/bidding')
-rw-r--r--components/bidding/ProjectSelectorBid.tsx183
-rw-r--r--components/bidding/bidding-info-header.tsx193
-rw-r--r--components/bidding/create/bidding-create-dialog.tsx118
-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
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>