'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 { submissionStartDate?: string submissionEndDate?: string 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 } // 데이터 로딩 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({ submissionStartDate: bidding.submissionStartDate ? new Date(bidding.submissionStartDate).toISOString().slice(0, 16) : '', submissionEndDate: bidding.submissionEndDate ? new Date(bidding.submissionEndDate).toISOString().slice(0, 16) : '', 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: meeting.meetingDate ? new Date(meeting.meetingDate).toISOString().slice(0, 16) : '', 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.submissionStartDate || !schedule.submissionEndDate) { 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: session.user.id, epId: session.user.epId, email: session.user.email || undefined, }, approvers, }) 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 || '', }) // 결재 준비 완료 - 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.submissionStartDate || !schedule.submissionEndDate) { toast({ title: '입찰서 제출기간 미설정', description: '입찰서 제출 시작일시와 마감일시를 모두 설정해주세요.', 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) => { // 마감일시 검증 - 현재일 이전 설정 불가 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) { toast({ title: '마감일시 오류', description: '마감일시는 현재일 이전으로 설정할 수 없습니다.', variant: 'destructive', }) 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 (!isUrgent && selectedDate.getTime() === today.getTime()) { toast({ title: '제출 시작일시 오류', description: '긴급 입찰이 아닌 경우 당일 제출 시작은 불가능합니다.', variant: 'destructive', }) 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, }) } } if (isLoading) { return (
일정 정보를 불러오는 중...
) } return (
입찰 일정 관리

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

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

입찰서 제출 기간

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

제출 시작일시는 필수입니다

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

제출 마감일시는 필수입니다

)}
{/* 긴급 여부 */}

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

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

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

handleScheduleChange('hasSpecificationMeeting', checked)} />
{/* 사양설명회 상세 정보 */} {schedule.hasSpecificationMeeting && (
setSpecMeetingInfo((prev) => ({ ...prev, meetingDate: e.target.value }))} className={!specMeetingInfo.meetingDate ? 'border-red-200' : ''} /> {!specMeetingInfo.meetingDate && (

회의일시는 필수입니다

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

회의 장소는 필수입니다

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

담당자는 필수입니다

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