'use client' import * as React from 'react' import { Calendar, Save, RefreshCw, Clock, Send } from 'lucide-react' import { updateBiddingSchedule, getBiddingById, getSpecificationMeetingDetailsAction } from '@/lib/bidding/service' import { useSession } from 'next-auth/react' import { useRouter } from 'next/navigation' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' import { Switch } from '@/components/ui/switch' import { ApprovalPreviewDialog } from '@/lib/approval/approval-preview-dialog' import { requestBiddingInvitationWithApproval } from '@/lib/bidding/approval-actions' import { prepareBiddingApprovalData } from '@/lib/bidding/approval-actions' import { BiddingInvitationDialog } from '@/lib/bidding/detail/table/bidding-invitation-dialog' import { sendBiddingBasicContracts, getSelectedVendorsForBidding, getPrItemsForBidding } from '@/lib/bidding/pre-quote/service' import { registerBidding } from '@/lib/bidding/detail/service' import { useToast } from '@/hooks/use-toast' import { format } from 'date-fns' interface BiddingSchedule { submissionStartOffset?: number // 시작일 오프셋 (결재 후 n일) submissionStartTime?: string // 시작 시간 (HH:MM) submissionDurationDays?: number // 기간 (시작일 + n일) submissionEndTime?: string // 마감 시간 (HH:MM) remarks?: string isUrgent?: boolean hasSpecificationMeeting?: boolean } interface SpecificationMeetingInfo { meetingDate: string meetingTime: string location: string address: string contactPerson: string contactPhone: string contactEmail: string agenda: string materials: string notes: string isRequired: boolean } interface BiddingScheduleEditorProps { biddingId: number readonly?: boolean } interface VendorContractRequirement { vendorId: number vendorName: string vendorCode?: string vendorCountry?: string vendorEmail?: string contactPerson?: string contactEmail?: string ndaYn?: boolean generalGtcYn?: boolean projectGtcYn?: boolean agreementYn?: boolean biddingCompanyId: number biddingId: number isPreQuoteSelected?: boolean contacts?: Array<{ id: number contactName: string contactEmail: string contactNumber?: string | null }> } interface VendorWithContactInfo extends VendorContractRequirement { contacts: Array<{ id: number contactName: string contactEmail: string contactPhone?: string | null contactPosition?: string | null contactDepartment?: string | null }> selectedMainEmail: string additionalEmails: string[] customEmails: Array<{ id: string email: string name?: string }> hasExistingContracts: boolean } interface BiddingInvitationData { vendors: VendorWithContactInfo[] generatedPdfs: Array<{ key: string buffer: number[] fileName: string }> message?: string } export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingScheduleEditorProps) { const { data: session } = useSession() const router = useRouter() const { toast } = useToast() const [schedule, setSchedule] = React.useState({}) const [specMeetingInfo, setSpecMeetingInfo] = React.useState({ meetingDate: '', meetingTime: '', location: '', address: '', contactPerson: '', contactPhone: '', contactEmail: '', agenda: '', materials: '', notes: '', isRequired: false, }) const [isLoading, setIsLoading] = React.useState(false) const [isSubmitting, setIsSubmitting] = React.useState(false) const [biddingInfo, setBiddingInfo] = React.useState<{ title: string; projectName?: string; status: string; biddingNumber?: string } | null>(null) const [isBiddingInvitationDialogOpen, setIsBiddingInvitationDialogOpen] = React.useState(false) const [isApprovalDialogOpen, setIsApprovalDialogOpen] = React.useState(false) const [selectedVendors, setSelectedVendors] = React.useState([]) const [approvalVariables, setApprovalVariables] = React.useState>({}) const [approvalTitle, setApprovalTitle] = React.useState('') const [invitationData, setInvitationData] = React.useState(null) // 차수 추출 헬퍼 함수 const getRoundNumber = (biddingNumber: string): number => { const match = biddingNumber.match(/-(\d+)$/) return match ? parseInt(match[1]) : 1 } // 차수 증가된 입찰인지 확인 (01이 아닌 경우) const isRoundIncreased = (biddingNumber?: string): boolean => { if (!biddingNumber) return false const round = getRoundNumber(biddingNumber) return round > 1 } // UTC 날짜를 한국 시간(KST) 기준 datetime-local 입력값으로 변환 const toKstInputValue = (date: string | Date | undefined | null) => { if (!date) return '' const d = new Date(date) // UTC 시간에 9시간을 더함 const kstTime = d.getTime() + (9 * 60 * 60 * 1000) 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 () => { setIsLoading(true) try { const bidding = await getBiddingById(biddingId) if (bidding) { // 입찰 정보 저장 setBiddingInfo({ title: bidding.title || '', projectName: bidding.projectName || undefined, status: bidding.status || '', biddingNumber: bidding.biddingNumber || undefined, }) setSchedule({ 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, }) // 사양설명회 정보 로드 (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) { console.error('Failed to load schedule:', error) toast({ title: '오류', description: '일정 정보를 불러오는데 실패했습니다.', variant: 'destructive', }) } finally { setIsLoading(false) } } loadSchedule() }, [biddingId, toast]) // 선정된 업체들 조회 const getSelectedVendors = React.useCallback(async (): Promise => { try { const result = await getSelectedVendorsForBidding(biddingId) if (result.success) { // 타입 변환: null을 undefined로 변환 return result.vendors.map((vendor): VendorContractRequirement => ({ vendorId: vendor.vendorId, vendorName: vendor.vendorName, vendorCode: vendor.vendorCode || undefined, vendorCountry: vendor.vendorCountry, vendorEmail: vendor.vendorEmail || undefined, contactPerson: vendor.contactPerson || undefined, contactEmail: vendor.contactEmail || undefined, ndaYn: vendor.ndaYn, generalGtcYn: vendor.generalGtcYn, projectGtcYn: vendor.projectGtcYn, agreementYn: vendor.agreementYn, biddingCompanyId: vendor.biddingCompanyId, biddingId: vendor.biddingId, isPreQuoteSelected: vendor.isPreQuoteSelected, contacts: vendor.contacts || [], })) } else { console.error('선정된 업체 조회 실패:', 'error' in result ? result.error : '알 수 없는 오류') return [] } } catch (error) { console.error('선정된 업체 조회 실패:', error) return [] } }, [biddingId]) // 입찰 초대 다이얼로그가 열릴 때 선정된 업체들 조회 React.useEffect(() => { if (isBiddingInvitationDialogOpen) { getSelectedVendors().then(vendors => { setSelectedVendors(vendors) }) } }, [isBiddingInvitationDialogOpen, getSelectedVendors]) // 입찰공고 버튼 클릭 핸들러 - 입찰 초대 다이얼로그 열기 const handleBiddingInvitationClick = async () => { try { // 1. 입찰서 제출기간 검증 if (schedule.submissionStartOffset === undefined || schedule.submissionDurationDays === undefined) { toast({ title: '입찰서 제출기간 미설정', description: '입찰서 제출 시작일 오프셋과 기간을 모두 설정해주세요.', variant: 'destructive', }) return } if (!schedule.submissionStartTime || !schedule.submissionEndTime) { toast({ title: '입찰서 제출시간 미설정', description: '입찰 시작 시간과 마감 시간을 모두 설정해주세요.', variant: 'destructive', }) return } // 2. 선정된 업체들 조회 및 검증 const vendors = await getSelectedVendors() if (vendors.length === 0) { toast({ title: '선정된 업체 없음', description: '입찰에 참여할 업체가 없습니다.', variant: 'destructive', }) return } // 3. 업체 담당자 검증 const vendorsWithoutContacts = vendors.filter(vendor => !vendor.contacts || vendor.contacts.length === 0 ) if (vendorsWithoutContacts.length > 0) { toast({ title: '업체 담당자 정보 부족', description: `${vendorsWithoutContacts.length}개 업체의 담당자가 없습니다. 각 업체에 담당자를 추가해주세요.`, variant: 'destructive', }) return } // 4. 입찰 품목 검증 const prItems = await getPrItemsForBidding(biddingId) if (!prItems || prItems.length === 0) { toast({ title: '입찰 품목 없음', description: '입찰에 포함할 품목이 없습니다.', variant: 'destructive', }) return } // 모든 검증 통과 시 다이얼로그 열기 setSelectedVendors(vendors) setIsBiddingInvitationDialogOpen(true) } catch (error) { console.error('입찰공고 검증 중 오류 발생:', error) toast({ title: '오류', description: '입찰공고 검증 중 오류가 발생했습니다.', variant: 'destructive', }) } } // 결재 상신 핸들러 - 결재 완료 시 실제 입찰 등록 실행 const handleApprovalSubmit = async ({ approvers, title, attachments }: { approvers: string[], title: string, attachments?: File[] }) => { try { if (!session?.user?.id || !session.user.epId || !invitationData) { toast({ title: '오류', description: '필요한 정보가 없습니다.', variant: 'destructive', }) return } // 결재 상신 const result = await requestBiddingInvitationWithApproval({ biddingId, vendors: selectedVendors, message: invitationData.message || '', currentUser: { id: Number(session.user.id), epId: session.user.epId, email: session.user.email || undefined, }, approvers, specificationMeeting: schedule.hasSpecificationMeeting ? { meetingDate: specMeetingInfo.meetingDate, meetingTime: specMeetingInfo.meetingTime, location: specMeetingInfo.location, address: specMeetingInfo.address, contactPerson: specMeetingInfo.contactPerson, contactPhone: specMeetingInfo.contactPhone, contactEmail: specMeetingInfo.contactEmail, agenda: specMeetingInfo.agenda, materials: specMeetingInfo.materials, notes: specMeetingInfo.notes, } : undefined, }) if (result.status === 'pending_approval') { toast({ title: '입찰초대 결재 상신 완료', description: `결재가 상신되었습니다. (ID: ${result.approvalId})`, }) setIsApprovalDialogOpen(false) setIsBiddingInvitationDialogOpen(false) setInvitationData(null) router.refresh() } } catch (error) { console.error('결재 상신 중 오류 발생:', error) toast({ title: '오류', description: '결재 상신 중 오류가 발생했습니다.', variant: 'destructive', }) } } // 차수 증가된 입찰의 직접 입찰공고 함수 (결재 생략) const handleDirectBiddingInvitation = async (data: BiddingInvitationData, vendors: VendorContractRequirement[]) => { try { if (!session?.user?.id) { toast({ title: '오류', description: '사용자 정보가 없습니다.', variant: 'destructive', }) return } // 입찰 등록 (결재 생략) const result = await registerBidding(biddingId, session.user.id.toString()) if (result.success) { toast({ title: '입찰공고 완료', description: '차수 증가된 입찰이 성공적으로 공고되었습니다.', }) // 다이얼로그 닫기 및 페이지 새로고침 setIsBiddingInvitationDialogOpen(false) setInvitationData(null) router.refresh() } else { toast({ title: '오류', description: result.error || '입찰공고 중 오류가 발생했습니다.', variant: 'destructive', }) } } catch (error) { console.error('직접 입찰공고 중 오류 발생:', error) toast({ title: '오류', description: '입찰공고 중 오류가 발생했습니다.', variant: 'destructive', }) } } // 입찰 초대 발송 핸들러 - 결재 준비 및 결재 다이얼로그 열기 const handleBiddingInvitationSend = async (data: BiddingInvitationData) => { try { if (!session?.user?.id) { toast({ title: '오류', description: '사용자 정보가 없습니다.', variant: 'destructive', }) return } // 선정된 업체들 조회 const vendors = await getSelectedVendors() if (vendors.length === 0) { toast({ title: '오류', description: '선정된 업체가 없습니다.', variant: 'destructive', }) return } // 차수 증가된 입찰(02, 03 등)인지 확인 if (isRoundIncreased(biddingInfo?.biddingNumber)) { // 차수 증가된 입찰은 결재 생략하고 바로 입찰 등록 await handleDirectBiddingInvitation(data, vendors) return } // 일반 입찰의 경우 결재 데이터 준비 (템플릿 변수, 제목 등) const approvalData = await prepareBiddingApprovalData({ biddingId, vendors, message: data.message || '', specificationMeeting: schedule.hasSpecificationMeeting ? { meetingDate: specMeetingInfo.meetingDate, meetingTime: specMeetingInfo.meetingTime, location: specMeetingInfo.location, address: specMeetingInfo.address, contactPerson: specMeetingInfo.contactPerson, contactPhone: specMeetingInfo.contactPhone, contactEmail: specMeetingInfo.contactEmail, agenda: specMeetingInfo.agenda, materials: specMeetingInfo.materials, notes: specMeetingInfo.notes, } : undefined, }) // 결재 준비 완료 - invitationData와 결재 데이터 저장 및 결재 다이얼로그 열기 setInvitationData(data) setApprovalVariables(approvalData.variables) setApprovalTitle(`입찰초대 - ${approvalData.bidding.title}`) setIsApprovalDialogOpen(true) } catch (error) { console.error('결재 준비 중 오류 발생:', error) toast({ title: '오류', description: '결재 준비 중 오류가 발생했습니다.', variant: 'destructive', }) } } const handleSave = async () => { setIsSubmitting(true) try { const userId = session?.user?.id?.toString() || '1' // 입찰서 제출기간 필수 검증 if (schedule.submissionStartOffset === undefined || schedule.submissionDurationDays === undefined) { toast({ title: '입찰서 제출기간 미설정', 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) return } // 사양설명회 정보 유효성 검사 if (schedule.hasSpecificationMeeting) { if (!specMeetingInfo.meetingDate || !specMeetingInfo.location || !specMeetingInfo.contactPerson) { toast({ title: '오류', description: '사양설명회 필수 정보가 누락되었습니다. (회의일시, 장소, 담당자)', variant: 'destructive', }) setIsSubmitting(false) return } } const result = await updateBiddingSchedule( biddingId, schedule, userId, schedule.hasSpecificationMeeting ? specMeetingInfo : undefined ) if (result.success) { toast({ title: '성공', description: '일정 정보가 성공적으로 저장되었습니다.', }) } else { toast({ title: '오류', description: 'error' in result ? result.error : '일정 정보 저장에 실패했습니다.', variant: 'destructive', }) } } catch (error) { console.error('Failed to save schedule:', error) toast({ title: '오류', description: '일정 정보 저장에 실패했습니다.', variant: 'destructive', }) } finally { setIsSubmitting(false) } } 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: '긴급 입찰이 아닌 경우 당일 시작(오프셋 0)은 불가능합니다.', variant: 'destructive', }) return } } // 기간 검증 if (field === 'submissionDurationDays' && typeof value === 'number') { if (value < 1) { toast({ title: '기간 오류', description: '입찰 기간은 최소 1일 이상이어야 합니다.', variant: 'destructive', }) return } } // 시간 형식 검증 (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: '시간은 HH:MM 형식으로 입력해주세요.', variant: 'destructive', }) return } } setSchedule(prev => ({ ...prev, [field]: value })) // 사양설명회 실시 여부가 false로 변경되어도 상세 정보 초기화하지 않음 (기존 데이터 유지) } if (isLoading) { return (
일정 정보를 불러오는 중...
) } return (
입찰 일정 관리

입찰의 주요 일정들을 설정하고 관리합니다.

{/* 입찰서 제출 기간 */}

입찰서 제출 기간

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

{/* 시작일 설정 */}
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('submissionStartTime', e.target.value)} className={!schedule.submissionStartTime ? 'border-red-200' : ''} disabled={readonly} /> {!schedule.submissionStartTime && (

시작 시간은 필수입니다

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

입찰 기간은 필수입니다

)}
handleScheduleChange('submissionEndTime', e.target.value)} className={!schedule.submissionEndTime ? 'border-red-200' : ''} disabled={readonly} /> {!schedule.submissionEndTime && (

마감 시간은 필수입니다

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

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

시작: {format(getPreviewDates().startDate, "yyyy-MM-dd HH:mm")} ~ 마감: {format(getPreviewDates().endDate, "yyyy-MM-dd HH:mm")}
)}
{/* 긴급 여부 */}

긴급 입찰로 표시할 경우 활성화하세요

handleScheduleChange('isUrgent', checked)} disabled={readonly} />
{/* 사양설명회 실시 여부 */}

사양설명회를 실시할 경우 상세 정보를 입력하세요

handleScheduleChange('hasSpecificationMeeting', checked)} disabled={readonly} />
{/* 사양설명회 상세 정보 - T/F와 무관하게 기존 데이터가 있거나 hasSpecificationMeeting이 true면 표시 */} {(schedule.hasSpecificationMeeting) && (
setSpecMeetingInfo((prev) => ({ ...prev, meetingDate: e.target.value }))} className={!specMeetingInfo.meetingDate ? 'border-red-200' : ''} disabled={readonly} min="1900-01-01T00:00" max="2100-12-31T23:59" /> {!specMeetingInfo.meetingDate && (

회의일시는 필수입니다

)}
setSpecMeetingInfo((prev) => ({ ...prev, meetingTime: e.target.value }))} disabled={readonly} />
setSpecMeetingInfo((prev) => ({ ...prev, location: e.target.value }))} className={!specMeetingInfo.location ? 'border-red-200' : ''} disabled={readonly} /> {!specMeetingInfo.location && (

회의 장소는 필수입니다

)}
setSpecMeetingInfo((prev) => ({ ...prev, address: e.target.value }))} disabled={readonly} />
setSpecMeetingInfo((prev) => ({ ...prev, contactPerson: e.target.value }))} className={!specMeetingInfo.contactPerson ? 'border-red-200' : ''} disabled={readonly} /> {!specMeetingInfo.contactPerson && (

담당자는 필수입니다

)}
setSpecMeetingInfo((prev) => ({ ...prev, contactPhone: e.target.value }))} disabled={readonly} />
setSpecMeetingInfo((prev) => ({ ...prev, contactEmail: e.target.value }))} disabled={readonly} />