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 +++++++++++++++------ 1 file changed, 243 insertions(+), 97 deletions(-) (limited to 'components/bidding/manage/bidding-schedule-editor.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 || '미설정'}` : '미설정' } -- cgit v1.2.3