summaryrefslogtreecommitdiff
path: root/components/bidding/manage
diff options
context:
space:
mode:
Diffstat (limited to 'components/bidding/manage')
-rw-r--r--components/bidding/manage/bidding-schedule-editor.tsx340
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>