diff options
Diffstat (limited to 'components/bidding/manage')
6 files changed, 774 insertions, 174 deletions
diff --git a/components/bidding/manage/bidding-basic-info-editor.tsx b/components/bidding/manage/bidding-basic-info-editor.tsx index 27a2c097..13c58311 100644 --- a/components/bidding/manage/bidding-basic-info-editor.tsx +++ b/components/bidding/manage/bidding-basic-info-editor.tsx @@ -88,7 +88,6 @@ interface BiddingBasicInfo { contractEndDate?: string submissionStartDate?: string submissionEndDate?: string - evaluationDate?: string hasSpecificationMeeting?: boolean hasPrDocument?: boolean currency?: string @@ -252,7 +251,6 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB contractEndDate: formatDate(bidding.contractEndDate), submissionStartDate: formatDateTime(bidding.submissionStartDate), submissionEndDate: formatDateTime(bidding.submissionEndDate), - evaluationDate: formatDateTime(bidding.evaluationDate), hasSpecificationMeeting: bidding.hasSpecificationMeeting || false, hasPrDocument: bidding.hasPrDocument || false, currency: bidding.currency || 'KRW', diff --git a/components/bidding/manage/bidding-companies-editor.tsx b/components/bidding/manage/bidding-companies-editor.tsx index 6634f528..9bfea90e 100644 --- a/components/bidding/manage/bidding-companies-editor.tsx +++ b/components/bidding/manage/bidding-companies-editor.tsx @@ -1,7 +1,7 @@ 'use client' import * as React from 'react' -import { Building, User, Plus, Trash2 } from 'lucide-react' +import { Building, User, Plus, Trash2, Users } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' @@ -11,7 +11,9 @@ import { createBiddingCompanyContact, deleteBiddingCompanyContact, getVendorContactsByVendorId, - updateBiddingCompanyPriceAdjustmentQuestion + updateBiddingCompanyPriceAdjustmentQuestion, + getBiddingCompaniesByBidPicId, + addBiddingCompanyFromOtherBidding } from '@/lib/bidding/service' import { deleteBiddingCompany } from '@/lib/bidding/pre-quote/service' import { BiddingDetailVendorCreateDialog } from './bidding-detail-vendor-create-dialog' @@ -36,6 +38,7 @@ import { } from '@/components/ui/table' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { PurchaseGroupCodeSelector, PurchaseGroupCodeWithUser } from '@/components/common/selectors/purchase-group-code/purchase-group-code-selector' interface QuotationVendor { id: number // biddingCompanies.id @@ -102,6 +105,26 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC const [isLoadingVendorContacts, setIsLoadingVendorContacts] = React.useState(false) const [selectedContactFromVendor, setSelectedContactFromVendor] = React.useState<VendorContact | null>(null) + // 협력사 멀티 선택 다이얼로그 + const [multiSelectDialogOpen, setMultiSelectDialogOpen] = React.useState(false) + const [selectedBidPic, setSelectedBidPic] = React.useState<PurchaseGroupCodeWithUser | undefined>(undefined) + const [biddingCompaniesList, setBiddingCompaniesList] = React.useState<Array<{ + biddingId: number + biddingNumber: string + biddingTitle: string + companyId: number + vendorCode: string + vendorName: string + updatedAt: Date + }>>([]) + const [isLoadingBiddingCompanies, setIsLoadingBiddingCompanies] = React.useState(false) + const [selectedBiddingCompany, setSelectedBiddingCompany] = React.useState<{ + biddingId: number + companyId: number + } | null>(null) + const [selectedBiddingCompanyContacts, setSelectedBiddingCompanyContacts] = React.useState<BiddingCompanyContact[]>([]) + const [isLoadingCompanyContacts, setIsLoadingCompanyContacts] = React.useState(false) + // 업체 목록 다시 로딩 함수 const reloadVendors = React.useCallback(async () => { try { @@ -494,10 +517,16 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC </p> </div> {!readonly && ( - <Button onClick={() => setAddVendorDialogOpen(true)} className="flex items-center gap-2" disabled={readonly}> - <Plus className="h-4 w-4" /> - 업체 추가 - </Button> + <div className="flex gap-2"> + <Button onClick={() => setMultiSelectDialogOpen(true)} className="flex items-center gap-2" disabled={readonly} variant="outline"> + <Users className="h-4 w-4" /> + 협력사 멀티 선택 + </Button> + <Button onClick={() => setAddVendorDialogOpen(true)} className="flex items-center gap-2" disabled={readonly}> + <Plus className="h-4 w-4" /> + 업체 추가 + </Button> + </div> )} </CardHeader> <CardContent> @@ -537,7 +566,22 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC </TableCell> <TableCell className="font-medium">{vendor.vendorName}</TableCell> <TableCell>{vendor.vendorCode}</TableCell> - <TableCell>{vendor.businessSize || '-'}</TableCell> + <TableCell> + {(() => { + switch (vendor.businessSize) { + case 'A': + return '대기업'; + case 'B': + return '중견기업'; + case 'C': + return '중소기업'; + case 'D': + return '소기업'; + default: + return '-'; + } + })()} + </TableCell> <TableCell> {vendor.companyId && vendorFirstContacts.has(vendor.companyId) ? vendorFirstContacts.get(vendor.companyId)!.contactName @@ -740,6 +784,227 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC </DialogContent> </Dialog> + {/* 협력사 멀티 선택 다이얼로그 */} + <Dialog open={multiSelectDialogOpen} onOpenChange={setMultiSelectDialogOpen}> + <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>참여협력사 선택</DialogTitle> + <DialogDescription> + 입찰담당자를 선택하여 해당 담당자의 입찰 업체를 조회하고 선택할 수 있습니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4 py-4"> + {/* 입찰담당자 선택 */} + <div className="space-y-2"> + <Label>입찰담당자 선택</Label> + <PurchaseGroupCodeSelector + selectedCode={selectedBidPic} + onCodeSelect={async (code) => { + setSelectedBidPic(code) + if (code.user?.id) { + setIsLoadingBiddingCompanies(true) + try { + const result = await getBiddingCompaniesByBidPicId(code.user.id) + if (result.success && result.data) { + setBiddingCompaniesList(result.data) + } else { + toast.error(result.error || '입찰 업체 조회에 실패했습니다.') + setBiddingCompaniesList([]) + } + } catch (error) { + console.error('Failed to load bidding companies:', error) + toast.error('입찰 업체 조회에 실패했습니다.') + setBiddingCompaniesList([]) + } finally { + setIsLoadingBiddingCompanies(false) + } + } + }} + placeholder="입찰담당자 선택" + disabled={readonly} + /> + </div> + + {/* 입찰 업체 목록 */} + {isLoadingBiddingCompanies ? ( + <div className="flex items-center justify-center py-8"> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + <span className="text-sm text-muted-foreground">입찰 업체를 불러오는 중...</span> + </div> + ) : biddingCompaniesList.length === 0 && selectedBidPic ? ( + <div className="text-center py-8 text-muted-foreground"> + 해당 입찰담당자의 입찰 업체가 없습니다. + </div> + ) : biddingCompaniesList.length > 0 ? ( + <div className="space-y-2"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[50px]">선택</TableHead> + <TableHead>입찰번호</TableHead> + <TableHead>입찰명</TableHead> + <TableHead>협력사코드</TableHead> + <TableHead>협력사명</TableHead> + <TableHead>입찰 업데이트일</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {biddingCompaniesList.map((company) => { + const isSelected = selectedBiddingCompany?.biddingId === company.biddingId && + selectedBiddingCompany?.companyId === company.companyId + return ( + <TableRow + key={`${company.biddingId}-${company.companyId}`} + className={`cursor-pointer hover:bg-muted/50 ${ + isSelected ? 'bg-muted/50' : '' + }`} + onClick={async () => { + if (isSelected) { + setSelectedBiddingCompany(null) + setSelectedBiddingCompanyContacts([]) + return + } + setSelectedBiddingCompany({ + biddingId: company.biddingId, + companyId: company.companyId + }) + setIsLoadingCompanyContacts(true) + try { + const contactsResult = await getBiddingCompanyContacts(company.biddingId, company.companyId) + if (contactsResult.success && contactsResult.data) { + setSelectedBiddingCompanyContacts(contactsResult.data) + } else { + setSelectedBiddingCompanyContacts([]) + } + } catch (error) { + console.error('Failed to load company contacts:', error) + setSelectedBiddingCompanyContacts([]) + } finally { + setIsLoadingCompanyContacts(false) + } + }} + > + <TableCell onClick={(e) => e.stopPropagation()}> + <Checkbox + checked={isSelected} + onCheckedChange={() => { + // 클릭 이벤트는 TableRow의 onClick에서 처리 + }} + disabled={readonly} + /> + </TableCell> + <TableCell className="font-medium">{company.biddingNumber}</TableCell> + <TableCell>{company.biddingTitle}</TableCell> + <TableCell>{company.vendorCode}</TableCell> + <TableCell>{company.vendorName}</TableCell> + <TableCell> + {company.updatedAt ? new Date(company.updatedAt).toLocaleDateString('ko-KR') : '-'} + </TableCell> + </TableRow> + ) + })} + </TableBody> + </Table> + + {/* 선택한 입찰 업체의 담당자 정보 */} + {selectedBiddingCompany !== null && ( + <div className="mt-4 p-4 border rounded-lg"> + <h4 className="font-medium mb-2">담당자 정보</h4> + {isLoadingCompanyContacts ? ( + <div className="flex items-center justify-center py-4"> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + <span className="text-sm text-muted-foreground">담당자 정보를 불러오는 중...</span> + </div> + ) : selectedBiddingCompanyContacts.length === 0 ? ( + <div className="text-sm text-muted-foreground">등록된 담당자가 없습니다.</div> + ) : ( + <div className="space-y-2"> + {selectedBiddingCompanyContacts.map((contact) => ( + <div key={contact.id} className="text-sm"> + <span className="font-medium">{contact.contactName}</span> + <span className="text-muted-foreground ml-2">{contact.contactEmail}</span> + {contact.contactNumber && ( + <span className="text-muted-foreground ml-2">{contact.contactNumber}</span> + )} + </div> + ))} + </div> + )} + </div> + )} + </div> + ) : null} + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => { + setMultiSelectDialogOpen(false) + setSelectedBidPic(undefined) + setBiddingCompaniesList([]) + setSelectedBiddingCompany(null) + setSelectedBiddingCompanyContacts([]) + }} + > + 취소 + </Button> + <Button + onClick={async () => { + if (!selectedBiddingCompany) { + toast.error('입찰 업체를 선택해주세요.') + return + } + + const selectedCompany = biddingCompaniesList.find( + c => c.biddingId === selectedBiddingCompany.biddingId && + c.companyId === selectedBiddingCompany.companyId + ) + + if (!selectedCompany) { + toast.error('선택한 입찰 업체 정보를 찾을 수 없습니다.') + return + } + + try { + const contacts = selectedBiddingCompanyContacts.map(c => ({ + contactName: c.contactName, + contactEmail: c.contactEmail, + contactNumber: c.contactNumber || undefined, + })) + + const result = await addBiddingCompanyFromOtherBidding( + biddingId, + selectedCompany.biddingId, + selectedCompany.companyId, + contacts.length > 0 ? contacts : undefined + ) + + if (result.success) { + toast.success('업체가 성공적으로 추가되었습니다.') + setMultiSelectDialogOpen(false) + setSelectedBidPic(undefined) + setBiddingCompaniesList([]) + setSelectedBiddingCompany(null) + setSelectedBiddingCompanyContacts([]) + await reloadVendors() + } else { + toast.error(result.error || '업체 추가에 실패했습니다.') + } + } catch (error) { + console.error('Failed to add bidding company:', error) + toast.error('업체 추가에 실패했습니다.') + } + }} + disabled={!selectedBiddingCompany || readonly} + > + 추가 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + {/* 벤더 담당자에서 추가 다이얼로그 */} <Dialog open={addContactFromVendorDialogOpen} onOpenChange={setAddContactFromVendorDialogOpen}> <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> diff --git a/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx b/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx index 0dd9f0eb..489f104d 100644 --- a/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx +++ b/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx @@ -408,7 +408,20 @@ export function BiddingDetailVendorCreateDialog({ 연동제 적용요건 문의 </Label> <span className="text-xs text-muted-foreground"> - 기업규모: {businessSizeMap[item.vendor.id] || '미정'} + 기업규모: {(() => { + switch (businessSizeMap[item.vendor.id]) { + case 'A': + return '대기업'; + case 'B': + return '중견기업'; + case 'C': + return '중소기업'; + case 'D': + return '소기업'; + default: + return '-'; + } + })()} </span> </div> </div> diff --git a/components/bidding/manage/bidding-items-editor.tsx b/components/bidding/manage/bidding-items-editor.tsx index 90e512d2..452cdc3c 100644 --- a/components/bidding/manage/bidding-items-editor.tsx +++ b/components/bidding/manage/bidding-items-editor.tsx @@ -1,7 +1,7 @@ 'use client' import * as React from 'react' -import { Package, Plus, Trash2, Save, RefreshCw, FileText } from 'lucide-react' +import { Package, Plus, Trash2, Save, RefreshCw, FileText, FileSpreadsheet, Upload } from 'lucide-react' import { getPRItemsForBidding } from '@/lib/bidding/detail/service' import { updatePrItem } from '@/lib/bidding/detail/service' import { toast } from 'sonner' @@ -26,7 +26,7 @@ import { CostCenterSingleSelector } from '@/components/common/selectors/cost-cen import { GlAccountSingleSelector } from '@/components/common/selectors/gl-account/gl-account-single-selector' // PR 아이템 정보 타입 (create-bidding-dialog와 동일) -interface PRItemInfo { +export interface PRItemInfo { id: number // 실제 DB ID prNumber?: string | null projectId?: number | null @@ -84,6 +84,16 @@ import { CreatePreQuoteRfqDialog } from './create-pre-quote-rfq-dialog' import { ProcurementItemSelectorDialogSingle } from '@/components/common/selectors/procurement-item/procurement-item-selector-dialog-single' import { Textarea } from '@/components/ui/textarea' import { Label } from '@/components/ui/label' +import { exportBiddingItemsToExcel } from '@/lib/bidding/manage/export-bidding-items-to-excel' +import { importBiddingItemsFromExcel } from '@/lib/bidding/manage/import-bidding-items-from-excel' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItemsEditorProps) { const { data: session } = useSession() @@ -114,6 +124,11 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems isPriceAdjustmentApplicable?: boolean | null sparePartOptions?: string | null } | null>(null) + const [importDialogOpen, setImportDialogOpen] = React.useState(false) + const [importFile, setImportFile] = React.useState<File | null>(null) + const [importErrors, setImportErrors] = React.useState<string[]>([]) + const [isImporting, setIsImporting] = React.useState(false) + const [isExporting, setIsExporting] = React.useState(false) // 초기 데이터 로딩 - 기존 품목이 있으면 자동으로 로드 React.useEffect(() => { @@ -492,7 +507,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems materialGroupInfo: null, materialNumber: null, materialInfo: null, - priceUnit: 1, + priceUnit: '1', purchaseUnit: 'EA', materialWeight: null, wbsCode: null, @@ -644,6 +659,76 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems const totals = calculateTotals() + // Excel 내보내기 핸들러 + const handleExport = React.useCallback(async () => { + if (items.length === 0) { + toast.error('내보낼 품목이 없습니다.') + return + } + + try { + setIsExporting(true) + await exportBiddingItemsToExcel(items, { + filename: `입찰품목목록_${biddingId}`, + }) + toast.success('Excel 파일이 다운로드되었습니다.') + } catch (error) { + console.error('Excel export error:', error) + toast.error('Excel 내보내기 중 오류가 발생했습니다.') + } finally { + setIsExporting(false) + } + }, [items, biddingId]) + + // Excel 가져오기 핸들러 + const handleImportFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0] + if (file) { + if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.xls')) { + toast.error('Excel 파일(.xlsx, .xls)만 업로드 가능합니다.') + return + } + setImportFile(file) + setImportErrors([]) + } + } + + const handleImport = async () => { + if (!importFile) return + + setIsImporting(true) + setImportErrors([]) + + try { + const result = await importBiddingItemsFromExcel(importFile) + + if (result.errors.length > 0) { + setImportErrors(result.errors) + toast.warning( + `${result.items.length}개의 품목을 파싱했지만 ${result.errors.length}개의 오류가 있습니다.` + ) + return + } + + if (result.items.length === 0) { + toast.error('가져올 품목이 없습니다.') + return + } + + // 기존 아이템에 추가 + setItems((prev) => [...prev, ...result.items]) + setImportDialogOpen(false) + setImportFile(null) + setImportErrors([]) + toast.success(`${result.items.length}개의 품목이 추가되었습니다.`) + } catch (error) { + console.error('Excel import error:', error) + toast.error('Excel 가져오기 중 오류가 발생했습니다.') + } finally { + setIsImporting(false) + } + } + if (isLoading) { return ( <div className="flex items-center justify-center p-8"> @@ -1372,6 +1457,14 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems <FileText className="h-4 w-4" /> 사전견적 </Button> + <Button onClick={handleExport} variant="outline" className="flex items-center gap-2" disabled={readonly || isExporting || items.length === 0}> + <FileSpreadsheet className="h-4 w-4" /> + {isExporting ? "내보내는 중..." : "Excel 내보내기"} + </Button> + <Button onClick={() => setImportDialogOpen(true)} variant="outline" className="flex items-center gap-2" disabled={readonly}> + <Upload className="h-4 w-4" /> + Excel 가져오기 + </Button> <Button onClick={handleAddItem} className="flex items-center gap-2" disabled={readonly}> <Plus className="h-4 w-4" /> 품목 추가 @@ -1492,6 +1585,88 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems toast.success('사전견적용 일반견적이 생성되었습니다') }} /> + + {/* Excel 가져오기 다이얼로그 */} + <Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}> + <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>Excel 가져오기</DialogTitle> + <DialogDescription> + Excel 파일을 업로드하여 품목을 일괄 추가합니다. + </DialogDescription> + </DialogHeader> + <div className="space-y-4"> + <div> + <Label htmlFor="import-file">Excel 파일 선택</Label> + <Input + id="import-file" + type="file" + accept=".xlsx,.xls" + onChange={handleImportFileSelect} + className="mt-2" + disabled={isImporting} + /> + {importFile && ( + <p className="text-sm text-muted-foreground mt-2"> + 선택된 파일: {importFile.name} + </p> + )} + </div> + + {importErrors.length > 0 && ( + <div className="space-y-2"> + <Label className="text-destructive">오류 목록</Label> + <div className="max-h-60 overflow-y-auto border rounded-md p-3 bg-destructive/5"> + <ul className="list-disc list-inside space-y-1"> + {importErrors.map((error, index) => ( + <li key={index} className="text-sm text-destructive"> + {error} + </li> + ))} + </ul> + </div> + </div> + )} + + <div className="text-sm text-muted-foreground space-y-1"> + <p className="font-semibold">필수 컬럼:</p> + <ul className="list-disc list-inside ml-2"> + <li>자재그룹코드, 자재그룹명</li> + <li>수량 또는 중량 (둘 중 하나 필수)</li> + <li>수량단위 또는 중량단위</li> + <li>납품요청일 (YYYY-MM-DD 형식)</li> + <li>내정단가</li> + </ul> + </div> + </div> + <DialogFooter> + <Button + variant="outline" + onClick={() => { + setImportDialogOpen(false) + setImportFile(null) + setImportErrors([]) + }} + disabled={isImporting} + > + 취소 + </Button> + <Button + onClick={handleImport} + disabled={!importFile || isImporting} + > + {isImporting ? ( + <> + <RefreshCw className="w-4 h-4 mr-2 animate-spin" /> + 가져오는 중... + </> + ) : ( + "가져오기" + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> </div> ) diff --git a/components/bidding/manage/bidding-schedule-editor.tsx b/components/bidding/manage/bidding-schedule-editor.tsx index 49659ae7..72961c3d 100644 --- a/components/bidding/manage/bidding-schedule-editor.tsx +++ b/components/bidding/manage/bidding-schedule-editor.tsx @@ -21,8 +21,10 @@ import { registerBidding } from '@/lib/bidding/detail/service' import { useToast } from '@/hooks/use-toast' import { format } from 'date-fns' interface BiddingSchedule { - submissionStartDate?: string - submissionEndDate?: string + submissionStartOffset?: number // 시작일 오프셋 (결재 후 n일) + submissionStartTime?: string // 시작 시간 (HH:MM) + submissionDurationDays?: number // 기간 (시작일 + n일) + submissionEndTime?: string // 마감 시간 (HH:MM) remarks?: string isUrgent?: boolean hasSpecificationMeeting?: boolean @@ -149,6 +151,47 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc return new Date(kstTime).toISOString().slice(0, 16) } + // timestamp에서 시간(HH:MM) 추출 + // 수정: Backend에서 setUTCHours로 저장했으므로, 읽을 때도 getUTCHours로 읽어야 + // 브라우저 타임존(KST)의 간섭 없이 원래 저장한 숫자를 가져올 수 있습니다. + const extractTimeFromTimestamp = (date: string | Date | undefined | null): string => { + if (!date) return '' + const d = new Date(date) + + // 중요: Backend에서 setUTCHours로 저장했으므로, 읽을 때도 getUTCHours로 읽어야 + // 브라우저 타임존(KST)의 간섭 없이 원래 저장한 숫자(09:00)를 가져올 수 있습니다. + const hours = d.getUTCHours().toString().padStart(2, '0') + const minutes = d.getUTCMinutes().toString().padStart(2, '0') + + return `${hours}:${minutes}` + } + + // 예상 일정 계산 (오늘 기준 미리보기) + const getPreviewDates = () => { + const today = new Date() + today.setHours(0, 0, 0, 0) + + const startOffset = schedule.submissionStartOffset ?? 0 + const durationDays = schedule.submissionDurationDays ?? 7 + const startTime = schedule.submissionStartTime || '09:00' + const endTime = schedule.submissionEndTime || '18:00' + + // 시작일 계산 + const startDate = new Date(today) + startDate.setDate(startDate.getDate() + startOffset) + const [startHour, startMinute] = startTime.split(':').map(Number) + startDate.setHours(startHour, startMinute, 0, 0) + + // 마감일 계산 + const endDate = new Date(startDate) + endDate.setHours(0, 0, 0, 0) // 시작일의 날짜만 + endDate.setDate(endDate.getDate() + durationDays) + const [endHour, endMinute] = endTime.split(':').map(Number) + endDate.setHours(endHour, endMinute, 0, 0) + + return { startDate, endDate } + } + // 데이터 로딩 React.useEffect(() => { const loadSchedule = async () => { @@ -165,36 +208,36 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc }) setSchedule({ - submissionStartDate: toKstInputValue(bidding.submissionStartDate), - submissionEndDate: toKstInputValue(bidding.submissionEndDate), + submissionStartOffset: bidding.submissionStartOffset ?? 1, + submissionStartTime: extractTimeFromTimestamp(bidding.submissionStartDate) || '09:00', + submissionDurationDays: bidding.submissionDurationDays ?? 7, + submissionEndTime: extractTimeFromTimestamp(bidding.submissionEndDate) || '18:00', remarks: bidding.remarks || '', isUrgent: bidding.isUrgent || false, hasSpecificationMeeting: bidding.hasSpecificationMeeting || false, }) - // 사양설명회 정보 로드 - if (bidding.hasSpecificationMeeting) { - try { - const meetingDetails = await getSpecificationMeetingDetailsAction(biddingId) - if (meetingDetails.success && meetingDetails.data) { - const meeting = meetingDetails.data - setSpecMeetingInfo({ - meetingDate: toKstInputValue(meeting.meetingDate), - meetingTime: meeting.meetingTime || '', - location: meeting.location || '', - address: meeting.address || '', - contactPerson: meeting.contactPerson || '', - contactPhone: meeting.contactPhone || '', - contactEmail: meeting.contactEmail || '', - agenda: meeting.agenda || '', - materials: meeting.materials || '', - notes: meeting.notes || '', - isRequired: meeting.isRequired || false, - }) - } - } catch (error) { - console.error('Failed to load specification meeting details:', error) + // 사양설명회 정보 로드 (T/F 무관하게 기존 데이터가 있으면 로드) + try { + const meetingDetails = await getSpecificationMeetingDetailsAction(biddingId) + if (meetingDetails.success && meetingDetails.data) { + const meeting = meetingDetails.data + setSpecMeetingInfo({ + meetingDate: toKstInputValue(meeting.meetingDate), + meetingTime: meeting.meetingTime || '', + location: meeting.location || '', + address: meeting.address || '', + contactPerson: meeting.contactPerson || '', + contactPhone: meeting.contactPhone || '', + contactEmail: meeting.contactEmail || '', + agenda: meeting.agenda || '', + materials: meeting.materials || '', + notes: meeting.notes || '', + isRequired: meeting.isRequired || false, + }) } + } catch (error) { + console.error('Failed to load specification meeting details:', error) } } } catch (error) { @@ -258,10 +301,18 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc const handleBiddingInvitationClick = async () => { try { // 1. 입찰서 제출기간 검증 - if (!schedule.submissionStartDate || !schedule.submissionEndDate) { + if (schedule.submissionStartOffset === undefined || schedule.submissionDurationDays === undefined) { toast({ title: '입찰서 제출기간 미설정', - description: '입찰서 제출 시작일시와 마감일시를 모두 설정해주세요.', + description: '입찰서 제출 시작일 오프셋과 기간을 모두 설정해주세요.', + variant: 'destructive', + }) + return + } + if (!schedule.submissionStartTime || !schedule.submissionEndTime) { + toast({ + title: '입찰서 제출시간 미설정', + description: '입찰 시작 시간과 마감 시간을 모두 설정해주세요.', variant: 'destructive', }) return @@ -484,10 +535,48 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc const userId = session?.user?.id?.toString() || '1' // 입찰서 제출기간 필수 검증 - if (!schedule.submissionStartDate || !schedule.submissionEndDate) { + if (schedule.submissionStartOffset === undefined || schedule.submissionDurationDays === undefined) { toast({ title: '입찰서 제출기간 미설정', - description: '입찰서 제출 시작일시와 마감일시를 모두 설정해주세요.', + description: '입찰서 제출 시작일 오프셋과 기간을 모두 설정해주세요.', + variant: 'destructive', + }) + setIsSubmitting(false) + return + } + if (!schedule.submissionStartTime || !schedule.submissionEndTime) { + toast({ + title: '입찰서 제출시간 미설정', + description: '입찰 시작 시간과 마감 시간을 모두 설정해주세요.', + variant: 'destructive', + }) + setIsSubmitting(false) + return + } + // 오프셋/기간 검증 + if (schedule.submissionStartOffset < 0) { + toast({ + title: '시작일 오프셋 오류', + description: '시작일 오프셋은 0 이상이어야 합니다.', + variant: 'destructive', + }) + setIsSubmitting(false) + return + } + if (schedule.submissionDurationDays < 1) { + toast({ + title: '기간 오류', + description: '입찰 기간은 최소 1일 이상이어야 합니다.', + variant: 'destructive', + }) + setIsSubmitting(false) + return + } + // 긴급 입찰이 아닌 경우 당일 시작 불가 (오프셋 0) + if (!schedule.isUrgent && schedule.submissionStartOffset === 0) { + toast({ + title: '시작일 오류', + description: '긴급 입찰이 아닌 경우 당일 시작(오프셋 0)은 불가능합니다.', variant: 'destructive', }) setIsSubmitting(false) @@ -538,62 +627,55 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc } } - const handleScheduleChange = (field: keyof BiddingSchedule, value: string | boolean) => { - // 마감일시 검증 - 현재일 이전 설정 불가 - if (field === 'submissionEndDate' && typeof value === 'string' && value) { - const selectedDate = new Date(value) - const now = new Date() - now.setHours(0, 0, 0, 0) // 시간을 00:00:00으로 설정하여 날짜만 비교 - - if (selectedDate < now) { + const handleScheduleChange = (field: keyof BiddingSchedule, value: string | boolean | number) => { + // 시작일 오프셋 검증 + if (field === 'submissionStartOffset' && typeof value === 'number') { + if (value < 0) { + toast({ + title: '시작일 오프셋 오류', + description: '시작일 오프셋은 0 이상이어야 합니다.', + variant: 'destructive', + }) + return + } + // 긴급 입찰이 아닌 경우 당일 시작(오프셋 0) 불가 + if (!schedule.isUrgent && value === 0) { toast({ - title: '마감일시 오류', - description: '마감일시는 현재일 이전으로 설정할 수 없습니다.', + title: '시작일 오프셋 오류', + description: '긴급 입찰이 아닌 경우 당일 시작(오프셋 0)은 불가능합니다.', variant: 'destructive', }) - return // 변경을 적용하지 않음 + return } } - // 긴급여부 미선택 시 당일 제출시작 불가 - if (field === 'submissionStartDate' && typeof value === 'string' && value) { - const selectedDate = new Date(value) - const today = new Date() - today.setHours(0, 0, 0, 0) // 시간을 00:00:00으로 설정 - selectedDate.setHours(0, 0, 0, 0) - - // 현재 긴급 여부 확인 (field가 'isUrgent'인 경우 value 사용, 아니면 기존 schedule 값) - const isUrgent = field === 'isUrgent' ? (value as boolean) : schedule.isUrgent || false + // 기간 검증 + if (field === 'submissionDurationDays' && typeof value === 'number') { + if (value < 1) { + toast({ + title: '기간 오류', + description: '입찰 기간은 최소 1일 이상이어야 합니다.', + variant: 'destructive', + }) + return + } + } - // 긴급이 아닌 경우 당일 시작 불가 - if (!isUrgent && selectedDate.getTime() === today.getTime()) { + // 시간 형식 검증 (HH:MM) + if ((field === 'submissionStartTime' || field === 'submissionEndTime') && typeof value === 'string') { + const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/ + if (value && !timeRegex.test(value)) { toast({ - title: '제출 시작일시 오류', - description: '긴급 입찰이 아닌 경우 당일 제출 시작은 불가능합니다.', + title: '시간 형식 오류', + description: '시간은 HH:MM 형식으로 입력해주세요.', variant: 'destructive', }) - return // 변경을 적용하지 않음 + return } } setSchedule(prev => ({ ...prev, [field]: value })) - - // 사양설명회 실시 여부가 false로 변경되면 상세 정보 초기화 - if (field === 'hasSpecificationMeeting' && value === false) { - setSpecMeetingInfo({ - meetingDate: '', - meetingTime: '', - location: '', - address: '', - contactPerson: '', - contactPhone: '', - contactEmail: '', - agenda: '', - materials: '', - notes: '', - isRequired: false, - }) - } + // 사양설명회 실시 여부가 false로 변경되어도 상세 정보 초기화하지 않음 (기존 데이터 유지) } if (isLoading) { @@ -624,40 +706,98 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc <Clock className="h-4 w-4" /> 입찰서 제출 기간 </h3> + <p className="text-sm text-muted-foreground"> + 입찰공고(결재 완료) 시점을 기준으로 일정이 자동 계산됩니다. + </p> + + {/* 시작일 설정 */} <div className="grid grid-cols-2 gap-4"> <div className="space-y-2"> - <Label htmlFor="submission-start">제출 시작일시 <span className="text-red-500">*</span></Label> + <Label htmlFor="submission-start-offset">시작일 (결재 후) <span className="text-red-500">*</span></Label> + <div className="flex items-center gap-2"> + <Input + id="submission-start-offset" + type="number" + min={schedule.isUrgent ? 0 : 1} + value={schedule.submissionStartOffset ?? ''} + onChange={(e) => handleScheduleChange('submissionStartOffset', parseInt(e.target.value) || 0)} + className={schedule.submissionStartOffset === undefined ? 'border-red-200' : ''} + disabled={readonly} + placeholder="0" + /> + <span className="text-sm text-muted-foreground whitespace-nowrap">일 후</span> + </div> + {schedule.submissionStartOffset === undefined && ( + <p className="text-sm text-red-500">시작일 오프셋은 필수입니다</p> + )} + {!schedule.isUrgent && schedule.submissionStartOffset === 0 && ( + <p className="text-sm text-amber-600">긴급 입찰만 당일 시작(0일) 가능</p> + )} + </div> + <div className="space-y-2"> + <Label htmlFor="submission-start-time">시작 시간 <span className="text-red-500">*</span></Label> <Input - id="submission-start" - type="datetime-local" - value={schedule.submissionStartDate} - onChange={(e) => handleScheduleChange('submissionStartDate', e.target.value)} - className={!schedule.submissionStartDate ? 'border-red-200' : ''} + id="submission-start-time" + type="time" + value={schedule.submissionStartTime || ''} + onChange={(e) => handleScheduleChange('submissionStartTime', e.target.value)} + className={!schedule.submissionStartTime ? 'border-red-200' : ''} disabled={readonly} - min="1900-01-01T00:00" - max="2100-12-31T23:59" /> - {!schedule.submissionStartDate && ( - <p className="text-sm text-red-500">제출 시작일시는 필수입니다</p> + {!schedule.submissionStartTime && ( + <p className="text-sm text-red-500">시작 시간은 필수입니다</p> + )} + </div> + </div> + + {/* 마감일 설정 */} + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="submission-duration">입찰 기간 (시작일 +) <span className="text-red-500">*</span></Label> + <div className="flex items-center gap-2"> + <Input + id="submission-duration" + type="number" + min={1} + value={schedule.submissionDurationDays ?? ''} + onChange={(e) => handleScheduleChange('submissionDurationDays', parseInt(e.target.value) || 1)} + className={schedule.submissionDurationDays === undefined ? 'border-red-200' : ''} + disabled={readonly} + placeholder="7" + /> + <span className="text-sm text-muted-foreground whitespace-nowrap">일간</span> + </div> + {schedule.submissionDurationDays === undefined && ( + <p className="text-sm text-red-500">입찰 기간은 필수입니다</p> )} </div> <div className="space-y-2"> - <Label htmlFor="submission-end">제출 마감일시 <span className="text-red-500">*</span></Label> + <Label htmlFor="submission-end-time">마감 시간 <span className="text-red-500">*</span></Label> <Input - id="submission-end" - type="datetime-local" - value={schedule.submissionEndDate} - onChange={(e) => handleScheduleChange('submissionEndDate', e.target.value)} - className={!schedule.submissionEndDate ? 'border-red-200' : ''} + id="submission-end-time" + type="time" + value={schedule.submissionEndTime || ''} + onChange={(e) => handleScheduleChange('submissionEndTime', e.target.value)} + className={!schedule.submissionEndTime ? 'border-red-200' : ''} disabled={readonly} - min="1900-01-01T00:00" - max="2100-12-31T23:59" /> - {!schedule.submissionEndDate && ( - <p className="text-sm text-red-500">제출 마감일시는 필수입니다</p> + {!schedule.submissionEndTime && ( + <p className="text-sm text-red-500">마감 시간은 필수입니다</p> )} </div> </div> + + {/* 예상 일정 미리보기 */} + {schedule.submissionStartOffset !== undefined && schedule.submissionDurationDays !== undefined && ( + <div className="p-3 bg-blue-50 rounded-lg border border-blue-200"> + <p className="text-sm font-medium text-blue-800 mb-1">📅 예상 일정 (오늘 공고 기준)</p> + <div className="text-sm text-blue-700"> + <span>시작: {format(getPreviewDates().startDate, "yyyy-MM-dd HH:mm")}</span> + <span className="mx-2">~</span> + <span>마감: {format(getPreviewDates().endDate, "yyyy-MM-dd HH:mm")}</span> + </div> + </div> + )} </div> {/* 긴급 여부 */} @@ -690,8 +830,8 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc /> </div> - {/* 사양설명회 상세 정보 */} - {schedule.hasSpecificationMeeting && ( + {/* 사양설명회 상세 정보 - T/F와 무관하게 기존 데이터가 있거나 hasSpecificationMeeting이 true면 표시 */} + {(schedule.hasSpecificationMeeting) && ( <div className="space-y-6 p-4 border rounded-lg bg-muted/50"> <div className="grid grid-cols-2 gap-4"> <div> @@ -834,10 +974,19 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc <CardContent> <div className="space-y-2 text-sm"> <div className="flex justify-between"> - <span className="font-medium">입찰서 제출 기간:</span> + <span className="font-medium">시작일:</span> + <span> + {schedule.submissionStartOffset !== undefined + ? `결재 후 ${schedule.submissionStartOffset}일, ${schedule.submissionStartTime || '미설정'}` + : '미설정' + } + </span> + </div> + <div className="flex justify-between"> + <span className="font-medium">마감일:</span> <span> - {schedule.submissionStartDate && schedule.submissionEndDate - ? `${format(new Date(schedule.submissionStartDate), "yyyy-MM-dd HH:mm")} ~ ${format(new Date(schedule.submissionEndDate), "yyyy-MM-dd HH:mm")}` + {schedule.submissionDurationDays !== undefined + ? `시작일 + ${schedule.submissionDurationDays}일, ${schedule.submissionEndTime || '미설정'}` : '미설정' } </span> diff --git a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx index de3c19ff..b0cecc25 100644 --- a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx +++ b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx @@ -26,13 +26,6 @@ import { FormMessage,
FormDescription,
} from "@/components/ui/form"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import {
@@ -41,20 +34,15 @@ import { PopoverTrigger,
} from "@/components/ui/popover"
import { Calendar } from "@/components/ui/calendar"
-import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
import { createPreQuoteRfqAction } from "@/lib/bidding/pre-quote/service"
-import { previewGeneralRfqCode } from "@/lib/rfq-last/service"
-import { MaterialGroupSelectorDialogSingle } from "@/components/common/material/material-group-selector-dialog-single"
-import { MaterialSearchItem } from "@/lib/material/material-group-service"
-import { MaterialSelectorDialogSingle } from "@/components/common/selectors/material/material-selector-dialog-single"
-import { MaterialSearchItem as SAPMaterialSearchItem } from "@/components/common/selectors/material/material-service"
import { PurchaseGroupCodeSelector } from "@/components/common/selectors/purchase-group-code/purchase-group-code-selector"
import type { PurchaseGroupCodeWithUser } from "@/components/common/selectors/purchase-group-code"
import { getBiddingById } from "@/lib/bidding/service"
+import { getProjectIdByCodeAndName } from "@/lib/bidding/manage/project-utils"
// 아이템 스키마
const itemSchema = z.object({
@@ -64,6 +52,8 @@ const itemSchema = z.object({ materialName: z.string().optional(),
quantity: z.number().min(1, "수량은 1 이상이어야 합니다"),
uom: z.string().min(1, "단위를 입력해주세요"),
+ totalWeight: z.union([z.number(), z.string(), z.null()]).optional(), // 중량 추가
+ weightUnit: z.string().optional().nullable(), // 중량단위 추가
remark: z.string().optional(),
})
@@ -125,8 +115,6 @@ export function CreatePreQuoteRfqDialog({ onSuccess
}: CreatePreQuoteRfqDialogProps) {
const [isLoading, setIsLoading] = React.useState(false)
- const [previewCode, setPreviewCode] = React.useState("")
- const [isLoadingPreview, setIsLoadingPreview] = React.useState(false)
const [selectedBidPic, setSelectedBidPic] = React.useState<PurchaseGroupCodeWithUser | undefined>(undefined)
const { data: session } = useSession()
@@ -143,6 +131,8 @@ export function CreatePreQuoteRfqDialog({ materialName: item.materialInfo || "",
quantity: item.quantity ? parseFloat(item.quantity) : 1,
uom: item.quantityUnit || item.weightUnit || "EA",
+ totalWeight: item.totalWeight ? parseFloat(item.totalWeight) : null,
+ weightUnit: item.weightUnit || null,
remark: "",
}))
}, [biddingItems])
@@ -164,6 +154,8 @@ export function CreatePreQuoteRfqDialog({ materialName: "",
quantity: 1,
uom: "",
+ totalWeight: null,
+ weightUnit: null,
remark: "",
},
],
@@ -231,6 +223,14 @@ export function CreatePreQuoteRfqDialog({ const pName = bidding.projectName || "";
setProjectInfo(pCode && pName ? `${pCode} - ${pName}` : pCode || pName || "");
+ // 프로젝트 ID 조회
+ if (pCode && pName) {
+ const fetchedProjectId = await getProjectIdByCodeAndName(pCode, pName)
+ if (fetchedProjectId) {
+ form.setValue("projectId", fetchedProjectId)
+ }
+ }
+
// 폼 값 설정
form.setValue("rfqTitle", rfqTitle);
form.setValue("rfqType", "pre_bidding"); // 기본값 설정
@@ -264,36 +264,15 @@ export function CreatePreQuoteRfqDialog({ materialName: "",
quantity: 1,
uom: "",
+ totalWeight: null,
+ weightUnit: null,
remark: "",
},
],
})
- setPreviewCode("")
}
}, [open, initialItems, form, selectedBidPic, biddingId])
- // 견적담당자 선택 시 RFQ 코드 미리보기 생성
- React.useEffect(() => {
- if (!selectedBidPic?.user?.id) {
- setPreviewCode("")
- return
- }
-
- // 즉시 실행 함수 패턴 사용
- (async () => {
- setIsLoadingPreview(true)
- try {
- const code = await previewGeneralRfqCode(selectedBidPic.user!.id)
- setPreviewCode(code)
- } catch (error) {
- console.error("코드 미리보기 오류:", error)
- setPreviewCode("")
- } finally {
- setIsLoadingPreview(false)
- }
- })()
- }, [selectedBidPic])
-
// 견적 종류 변경
const handleRfqTypeChange = (value: string) => {
form.setValue("rfqType", value)
@@ -315,12 +294,13 @@ export function CreatePreQuoteRfqDialog({ materialName: "",
quantity: 1,
uom: "",
+ totalWeight: null,
+ weightUnit: null,
remark: "",
},
],
})
setSelectedBidPic(undefined)
- setPreviewCode("")
onOpenChange(false)
}
@@ -350,15 +330,17 @@ export function CreatePreQuoteRfqDialog({ biddingNumber: data.biddingNumber, // 추가
contractStartDate: data.contractStartDate, // 추가
contractEndDate: data.contractEndDate, // 추가
- items: data.items as Array<{
- itemCode: string;
- itemName: string;
- materialCode?: string;
- materialName?: string;
- quantity: number;
- uom: string;
- remark?: string;
- }>,
+ items: data.items.map(item => ({
+ itemCode: item.itemCode || "",
+ itemName: item.itemName || "",
+ materialCode: item.materialCode,
+ materialName: item.materialName,
+ quantity: item.quantity,
+ uom: item.uom,
+ totalWeight: item.totalWeight,
+ weightUnit: item.weightUnit,
+ remark: item.remark,
+ })),
biddingConditions: biddingConditions || undefined,
createdBy: userId,
updatedBy: userId,
@@ -465,7 +447,7 @@ export function CreatePreQuoteRfqDialog({ )}
>
{field.value ? (
- format(field.value, "yyyy-MM-dd")
+ format(field.value, "yyyy-MM-dd HH:mm")
) : (
<span>제출마감일을 선택하세요 (선택)</span>
)}
@@ -477,12 +459,40 @@ export function CreatePreQuoteRfqDialog({ <Calendar
mode="single"
selected={field.value}
- onSelect={field.onChange}
- disabled={(date) =>
- date < new Date() || date < new Date("1900-01-01")
- }
+ onSelect={(date) => {
+ if (!date) {
+ field.onChange(undefined)
+ return
+ }
+ const newDate = new Date(date)
+ if (field.value) {
+ newDate.setHours(field.value.getHours(), field.value.getMinutes())
+ } else {
+ newDate.setHours(0, 0, 0, 0)
+ }
+ field.onChange(newDate)
+ }}
+ disabled={(date) => {
+ const today = new Date()
+ today.setHours(0, 0, 0, 0)
+ return date < today || date < new Date("1900-01-01")
+ }}
initialFocus
/>
+ <div className="p-3 border-t border-border">
+ <Input
+ type="time"
+ value={field.value ? format(field.value, "HH:mm") : ""}
+ onChange={(e) => {
+ if (field.value) {
+ const [hours, minutes] = e.target.value.split(':').map(Number)
+ const newDate = new Date(field.value)
+ newDate.setHours(hours, minutes)
+ field.onChange(newDate)
+ }
+ }}
+ />
+ </div>
</PopoverContent>
</Popover>
<FormMessage />
@@ -562,17 +572,7 @@ export function CreatePreQuoteRfqDialog({ </FormItem>
)}
/>
- {/* RFQ 코드 미리보기 */}
- {previewCode && (
- <div className="flex items-center gap-2">
- <Badge variant="secondary" className="font-mono text-sm">
- 예상 RFQ 코드: {previewCode}
- </Badge>
- {isLoadingPreview && (
- <Loader2 className="h-3 w-3 animate-spin" />
- )}
- </div>
- )}
+
{/* 계약기간 */}
<div className="grid grid-cols-2 gap-4">
|
