diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-12-05 06:29:23 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-12-05 06:29:23 +0000 |
| commit | d47334639bd717aa860563ec1020a29827524fd4 (patch) | |
| tree | 1841a95bb6e6dc336e00faa868235bbcd9b9f3f0 /components | |
| parent | 93b6b8868d409c7f6c9d9222b93750848caaedde (diff) | |
(최겸)구매 결재일 기준 공고 수정
Diffstat (limited to 'components')
| -rw-r--r-- | components/bidding/manage/bidding-schedule-editor.tsx | 340 |
1 files changed, 243 insertions, 97 deletions
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 <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 +827,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 +971,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> |
