From d47334639bd717aa860563ec1020a29827524fd4 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 5 Dec 2025 06:29:23 +0000 Subject: (최겸)구매 결재일 기준 공고 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bidding/manage/bidding-schedule-editor.tsx | 340 ++++++--- lib/bidding/detail/service.ts | 97 ++- .../detail/table/bidding-detail-vendor-columns.tsx | 78 ++ .../detail/table/bidding-detail-vendor-table.tsx | 41 +- .../bidding-detail-vendor-toolbar-actions.tsx | 25 +- .../detail/table/price-adjustment-dialog.tsx | 195 +++++ .../table/vendor-price-adjustment-view-dialog.tsx | 324 +++++++++ lib/bidding/handlers.ts | 94 ++- lib/bidding/receive/biddings-receive-columns.tsx | 808 ++++++++++----------- lib/bidding/receive/biddings-receive-table.tsx | 593 +++++++-------- lib/bidding/service.ts | 61 +- .../vendor/components/pr-items-pricing-table.tsx | 125 +++- 12 files changed, 1922 insertions(+), 859 deletions(-) create mode 100644 lib/bidding/detail/table/price-adjustment-dialog.tsx create mode 100644 lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx diff --git a/components/bidding/manage/bidding-schedule-editor.tsx b/components/bidding/manage/bidding-schedule-editor.tsx index 49659ae7..32ce6940 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,44 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc return new Date(kstTime).toISOString().slice(0, 16) } + // timestamp에서 시간(HH:MM) 추출 (KST 기준) + const extractTimeFromTimestamp = (date: string | Date | undefined | null): string => { + if (!date) return '' + const d = new Date(date) + // UTC 시간에 9시간을 더함 + const kstTime = d.getTime() + (9 * 60 * 60 * 1000) + const kstDate = new Date(kstTime) + const hours = kstDate.getUTCHours().toString().padStart(2, '0') + const minutes = kstDate.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 +205,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 +298,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 +532,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 +624,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 +703,98 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc 입찰서 제출 기간 +

+ 입찰공고(결재 완료) 시점을 기준으로 일정이 자동 계산됩니다. +

+ + {/* 시작일 설정 */}
- + +
+ handleScheduleChange('submissionStartOffset', parseInt(e.target.value) || 0)} + className={schedule.submissionStartOffset === undefined ? 'border-red-200' : ''} + disabled={readonly} + placeholder="0" + /> + 일 후 +
+ {schedule.submissionStartOffset === undefined && ( +

시작일 오프셋은 필수입니다

+ )} + {!schedule.isUrgent && schedule.submissionStartOffset === 0 && ( +

긴급 입찰만 당일 시작(0일) 가능

+ )} +
+
+ 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 && ( -

제출 시작일시는 필수입니다

+ {!schedule.submissionStartTime && ( +

시작 시간은 필수입니다

+ )} +
+
+ + {/* 마감일 설정 */} +
+
+ +
+ handleScheduleChange('submissionDurationDays', parseInt(e.target.value) || 1)} + className={schedule.submissionDurationDays === undefined ? 'border-red-200' : ''} + disabled={readonly} + placeholder="7" + /> + 일간 +
+ {schedule.submissionDurationDays === undefined && ( +

입찰 기간은 필수입니다

)}
- + 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 && ( -

제출 마감일시는 필수입니다

+ {!schedule.submissionEndTime && ( +

마감 시간은 필수입니다

)}
+ + {/* 예상 일정 미리보기 */} + {schedule.submissionStartOffset !== undefined && schedule.submissionDurationDays !== undefined && ( +
+

📅 예상 일정 (오늘 공고 기준)

+
+ 시작: {format(getPreviewDates().startDate, "yyyy-MM-dd HH:mm")} + ~ + 마감: {format(getPreviewDates().endDate, "yyyy-MM-dd HH:mm")} +
+
+ )} {/* 긴급 여부 */} @@ -690,8 +827,8 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc /> - {/* 사양설명회 상세 정보 */} - {schedule.hasSpecificationMeeting && ( + {/* 사양설명회 상세 정보 - T/F와 무관하게 기존 데이터가 있거나 hasSpecificationMeeting이 true면 표시 */} + {(schedule.hasSpecificationMeeting) && (
@@ -834,10 +971,19 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
- 입찰서 제출 기간: + 시작일: + + {schedule.submissionStartOffset !== undefined + ? `결재 후 ${schedule.submissionStartOffset}일, ${schedule.submissionStartTime || '미설정'}` + : '미설정' + } + +
+
+ 마감일: - {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 || '미설정'}` : '미설정' } diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index 17ea8f28..4ef48d33 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -64,6 +64,12 @@ export async function getBiddingDetailData(biddingId: number): Promise { debugLog('registerBidding: Transaction started') - // 1. 입찰 상태를 오픈으로 변경 + + // 0. 입찰서 제출기간 계산 (오프셋 기반) + const { submissionStartOffset, submissionDurationDays, submissionStartDate, submissionEndDate } = bidding + + let calculatedStartDate = bidding.submissionStartDate + let calculatedEndDate = bidding.submissionEndDate + + // 오프셋 값이 있으면 날짜 계산 + if (submissionStartOffset !== null && submissionDurationDays !== null) { + // 시간 추출 (기본값: 시작 09:00, 마감 18:00) + const startTime = submissionStartDate + ? { hours: submissionStartDate.getUTCHours(), minutes: submissionStartDate.getUTCMinutes() } + : { hours: 9, minutes: 0 } + const endTime = submissionEndDate + ? { hours: submissionEndDate.getUTCHours(), minutes: submissionEndDate.getUTCMinutes() } + : { hours: 18, minutes: 0 } + + // baseDate = 현재일 날짜만 (00:00:00) + const baseDate = new Date() + baseDate.setHours(0, 0, 0, 0) + + // 시작일 = baseDate + offset일 + 시작시간 + calculatedStartDate = new Date(baseDate) + calculatedStartDate.setDate(calculatedStartDate.getDate() + submissionStartOffset) + calculatedStartDate.setHours(startTime.hours, startTime.minutes, 0, 0) + + // 마감일 = 시작일(날짜만) + duration일 + 마감시간 + calculatedEndDate = new Date(calculatedStartDate) + calculatedEndDate.setHours(0, 0, 0, 0) + calculatedEndDate.setDate(calculatedEndDate.getDate() + submissionDurationDays) + calculatedEndDate.setHours(endTime.hours, endTime.minutes, 0, 0) + + debugLog('registerBidding: Submission dates calculated', { + baseDate: baseDate.toISOString(), + calculatedStartDate: calculatedStartDate.toISOString(), + calculatedEndDate: calculatedEndDate.toISOString(), + }) + } + + // 1. 입찰 상태를 오픈으로 변경 + 제출기간 업데이트 await tx .update(biddings) .set({ status: 'bidding_opened', + submissionStartDate: calculatedStartDate, + submissionEndDate: calculatedEndDate, updatedBy: userName, updatedAt: new Date() }) @@ -2617,3 +2680,35 @@ export async function setSpecificationMeetingParticipation(biddingCompanyId: num return { success: false, error: '사양설명회 참여상태 업데이트에 실패했습니다.' } } } + +// 연동제 정보 업데이트 +export async function updatePriceAdjustmentInfo(params: { + biddingCompanyId: number + shiPriceAdjustmentApplied: boolean | null + priceAdjustmentNote: string | null + hasChemicalSubstance: boolean | null +}): Promise<{ success: boolean; error?: string }> { + try { + const result = await db.update(biddingCompanies) + .set({ + shiPriceAdjustmentApplied: params.shiPriceAdjustmentApplied, + priceAdjustmentNote: params.priceAdjustmentNote, + hasChemicalSubstance: params.hasChemicalSubstance, + updatedAt: new Date(), + }) + .where(eq(biddingCompanies.id, params.biddingCompanyId)) + .returning({ biddingId: biddingCompanies.biddingId }) + + if (result.length > 0) { + const biddingId = result[0].biddingId + revalidateTag(`bidding-${biddingId}`) + revalidateTag('quotation-vendors') + revalidatePath(`/evcp/bid/${biddingId}`) + } + + return { success: true } + } catch (error) { + console.error('Failed to update price adjustment info:', error) + return { success: false, error: '연동제 정보 업데이트에 실패했습니다.' } + } +} diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx index 5368b287..05c1a93d 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx @@ -31,6 +31,7 @@ interface GetVendorColumnsProps { } export function getBiddingDetailVendorColumns({ + onViewPriceAdjustment, onViewItemDetails, onSendBidding, onUpdateParticipation, @@ -238,6 +239,83 @@ export function getBiddingDetailVendorColumns({
), }, + { + accessorKey: 'priceAdjustmentResponse', + header: '연동제 응답', + cell: ({ row }) => { + const vendor = row.original + const response = vendor.priceAdjustmentResponse + + // 버튼 형태로 표시, 클릭 시 상세 다이얼로그 열기 + const getBadgeVariant = () => { + if (response === null || response === undefined) return 'outline' + return response ? 'default' : 'secondary' + } + + const getBadgeClass = () => { + if (response === true) return 'bg-green-600 hover:bg-green-700 cursor-pointer' + if (response === false) return 'hover:bg-gray-300 cursor-pointer' + return '' + } + + const getLabel = () => { + if (response === null || response === undefined) return '해당없음' + return response ? '예' : '아니오' + } + + return ( + onViewPriceAdjustment?.(vendor)} + > + {getLabel()} + + ) + }, + }, + { + accessorKey: 'shiPriceAdjustmentApplied', + header: 'SHI연동제적용', + cell: ({ row }) => { + const applied = row.original.shiPriceAdjustmentApplied + if (applied === null || applied === undefined) { + return 미정 + } + return ( + + {applied ? '적용' : '미적용'} + + ) + }, + }, + { + accessorKey: 'priceAdjustmentNote', + header: '연동제 Note', + cell: ({ row }) => { + const note = row.original.priceAdjustmentNote + return ( +
+ {note || '-'} +
+ ) + }, + }, + { + accessorKey: 'hasChemicalSubstance', + header: '화학물질', + cell: ({ row }) => { + const hasChemical = row.original.hasChemicalSubstance + if (hasChemical === null || hasChemical === undefined) { + return 미정 + } + return ( + + {hasChemical ? '해당' : '해당없음'} + + ) + }, + }, { id: 'actions', header: '작업', diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx index a6f64964..407cc51c 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx @@ -10,9 +10,9 @@ import { BiddingDetailVendorToolbarActions } from './bidding-detail-vendor-toolb import { BiddingDetailVendorEditDialog } from './bidding-detail-vendor-edit-dialog' import { BiddingAwardDialog } from './bidding-award-dialog' import { getBiddingDetailVendorColumns } from './bidding-detail-vendor-columns' -import { QuotationVendor, getPriceAdjustmentFormByBiddingCompanyId } from '@/lib/bidding/detail/service' +import { QuotationVendor } from '@/lib/bidding/detail/service' import { Bidding } from '@/db/schema' -import { PriceAdjustmentDialog } from '@/components/bidding/price-adjustment-dialog' +import { VendorPriceAdjustmentViewDialog } from './vendor-price-adjustment-view-dialog' import { QuotationHistoryDialog } from './quotation-history-dialog' import { ApprovalPreviewDialog } from '@/lib/approval/approval-preview-dialog' import { ApplicationReasonDialog } from '@/lib/rfq-last/vendor/application-reason-dialog' @@ -98,8 +98,7 @@ export function BiddingDetailVendorTableContent({ const [selectedVendor, setSelectedVendor] = React.useState(null) const [isAwardDialogOpen, setIsAwardDialogOpen] = React.useState(false) const [isAwardRatioDialogOpen, setIsAwardRatioDialogOpen] = React.useState(false) - const [priceAdjustmentData, setPriceAdjustmentData] = React.useState(null) - const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false) + const [isVendorPriceAdjustmentDialogOpen, setIsVendorPriceAdjustmentDialogOpen] = React.useState(false) const [quotationHistoryData, setQuotationHistoryData] = React.useState(null) const [isQuotationHistoryDialogOpen, setIsQuotationHistoryDialogOpen] = React.useState(false) const [approvalPreviewData, setApprovalPreviewData] = React.useState<{ @@ -116,28 +115,9 @@ export function BiddingDetailVendorTableContent({ } | null>(null) const [isApprovalPreviewDialogOpen, setIsApprovalPreviewDialogOpen] = React.useState(false) - const handleViewPriceAdjustment = async (vendor: QuotationVendor) => { - try { - const priceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(vendor.id) - if (priceAdjustmentForm) { - setPriceAdjustmentData(priceAdjustmentForm) - setSelectedVendor(vendor) - setIsPriceAdjustmentDialogOpen(true) - } else { - toast({ - title: '연동제 정보 없음', - description: '해당 업체의 연동제 정보가 없습니다.', - variant: 'default', - }) - } - } catch (error) { - console.error('Failed to load price adjustment form:', error) - toast({ - title: '오류', - description: '연동제 정보를 불러오는데 실패했습니다.', - variant: 'destructive', - }) - } + const handleViewPriceAdjustment = (vendor: QuotationVendor) => { + setSelectedVendor(vendor) + setIsVendorPriceAdjustmentDialogOpen(true) } const handleViewQuotationHistory = async (vendor: QuotationVendor) => { @@ -299,11 +279,12 @@ export function BiddingDetailVendorTableContent({ }} /> - ([]) const [isRoundIncreaseDialogOpen, setIsRoundIncreaseDialogOpen] = React.useState(false) const [isCancelAwardDialogOpen, setIsCancelAwardDialogOpen] = React.useState(false) + const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false) // 본입찰 초대 다이얼로그가 열릴 때 선정된 업체들 조회 React.useEffect(() => { @@ -196,6 +198,19 @@ export function BiddingDetailVendorToolbarActions({ )} + {/* 연동제 적용여부: single select 시에만 활성화 */} + {(bidding.status === 'evaluation_of_bidding') && ( + + )} + {/* 유찰/낙찰: 입찰공고 또는 입찰평가중 상태에서만 */} {(bidding.status === 'bidding_opened' || bidding.status === 'evaluation_of_bidding') && ( <> @@ -331,6 +346,14 @@ export function BiddingDetailVendorToolbarActions({ + {/* 연동제 적용여부 다이얼로그 */} + + ) } diff --git a/lib/bidding/detail/table/price-adjustment-dialog.tsx b/lib/bidding/detail/table/price-adjustment-dialog.tsx new file mode 100644 index 00000000..14bbd843 --- /dev/null +++ b/lib/bidding/detail/table/price-adjustment-dialog.tsx @@ -0,0 +1,195 @@ +"use client" + +import * as React from "react" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { Switch } from "@/components/ui/switch" +import { useToast } from "@/hooks/use-toast" +import { updatePriceAdjustmentInfo } from "@/lib/bidding/detail/service" +import { QuotationVendor } from "@/lib/bidding/detail/service" +import { Loader2 } from "lucide-react" + +interface PriceAdjustmentDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + vendor: QuotationVendor | null + onSuccess: () => void +} + +export function PriceAdjustmentDialog({ + open, + onOpenChange, + vendor, + onSuccess, +}: PriceAdjustmentDialogProps) { + const { toast } = useToast() + const [isSubmitting, setIsSubmitting] = React.useState(false) + + // 폼 상태 + const [shiPriceAdjustmentApplied, setSHIPriceAdjustmentApplied] = React.useState(null) + const [priceAdjustmentNote, setPriceAdjustmentNote] = React.useState("") + const [hasChemicalSubstance, setHasChemicalSubstance] = React.useState(null) + + // 다이얼로그가 열릴 때 벤더 정보로 폼 초기화 + React.useEffect(() => { + if (open && vendor) { + setSHIPriceAdjustmentApplied(vendor.shiPriceAdjustmentApplied ?? null) + setPriceAdjustmentNote(vendor.priceAdjustmentNote || "") + setHasChemicalSubstance(vendor.hasChemicalSubstance ?? null) + } + }, [open, vendor]) + + const handleSubmit = async () => { + if (!vendor) return + + setIsSubmitting(true) + try { + const result = await updatePriceAdjustmentInfo({ + biddingCompanyId: vendor.id, + shiPriceAdjustmentApplied, + priceAdjustmentNote: priceAdjustmentNote || null, + hasChemicalSubstance, + }) + + if (result.success) { + toast({ + title: "저장 완료", + description: "연동제 정보가 저장되었습니다.", + }) + onOpenChange(false) + onSuccess() + } else { + toast({ + title: "오류", + description: result.error || "저장 중 오류가 발생했습니다.", + variant: "destructive", + }) + } + } catch (error) { + console.error("연동제 정보 저장 오류:", error) + toast({ + title: "오류", + description: "저장 중 오류가 발생했습니다.", + variant: "destructive", + }) + } finally { + setIsSubmitting(false) + } + } + + if (!vendor) return null + + return ( + + + + 연동제 적용 설정 + + {vendor.vendorName} 업체의 연동제 적용 여부 및 화학물질 정보를 설정합니다. + + + +
+ {/* 업체가 제출한 연동제 요청 여부 (읽기 전용) */} +
+
+ +

+ 업체가 제출한 연동제 적용 요청 여부입니다. +

+
+ + {vendor.isPriceAdjustmentApplicableQuestion === null ? '미정' : vendor.isPriceAdjustmentApplicableQuestion ? '예' : '아니오'} + +
+ + {/* SHI 연동제 적용여부 */} +
+
+ +

+ 해당 업체에 연동제를 적용할지 결정합니다. +

+
+
+ + 미적용 + + setSHIPriceAdjustmentApplied(checked)} + /> + + 적용 + +
+
+ + {/* 연동제 Note */} +
+ +