From d47334639bd717aa860563ec1020a29827524fd4 Mon Sep 17 00:00:00 2001
From: dujinkim <dujin.kim@dtsolution.co.kr>
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
               <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>
-- 
cgit v1.2.3

