From ba8cd44a0ed2c613a5f2cee06bfc9bd0f61f21c7 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 7 Nov 2025 08:39:04 +0000 Subject: (최겸) 입찰/견적 수정사항 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bidding/manage/bidding-schedule-editor.tsx | 661 +++++++++++++++++++++ 1 file changed, 661 insertions(+) create mode 100644 components/bidding/manage/bidding-schedule-editor.tsx (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 new file mode 100644 index 00000000..d64c16c0 --- /dev/null +++ b/components/bidding/manage/bidding-schedule-editor.tsx @@ -0,0 +1,661 @@ +'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 { BiddingInvitationDialog } from '@/lib/bidding/detail/table/bidding-invitation-dialog' +import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from '@/lib/bidding/pre-quote/service' +import { registerBidding } from '@/lib/bidding/detail/service' +import { useToast } from '@/hooks/use-toast' + +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 +} + +interface VendorContractRequirement { + vendorId: number + vendorName: string + vendorCode?: string | null + vendorCountry?: string + vendorEmail?: string | null + contactPerson?: string | null + contactEmail?: string | null + ndaYn?: boolean + generalGtcYn?: boolean + projectGtcYn?: boolean + agreementYn?: boolean + biddingCompanyId: number + biddingId: number +} + +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 }: 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 } | null>(null) + const [isBiddingInvitationDialogOpen, setIsBiddingInvitationDialogOpen] = React.useState(false) + const [selectedVendors, setSelectedVendors] = React.useState([]) + + // 데이터 로딩 + React.useEffect(() => { + const loadSchedule = async () => { + setIsLoading(true) + try { + const bidding = await getBiddingById(biddingId) + if (bidding) { + // 입찰 정보 저장 + setBiddingInfo({ + title: bidding.title || '', + projectName: bidding.projectName || undefined, + }) + + // 날짜를 문자열로 변환하는 헬퍼 + const formatDateTime = (date: unknown): string => { + if (!date) return '' + if (typeof date === 'string') { + // 이미 datetime-local 형식인 경우 + if (date.includes('T')) { + return date.slice(0, 16) + } + return date + } + if (date instanceof Date) return date.toISOString().slice(0, 16) + return '' + } + + setSchedule({ + submissionStartDate: formatDateTime(bidding.submissionStartDate), + submissionEndDate: formatDateTime(bidding.submissionEndDate), + 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, + })) + } 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 handleBiddingInvitationSend = async (data: BiddingInvitationData) => { + try { + const userId = session?.user?.id?.toString() || '1' + + // 1. 기본계약 발송 + // sendBiddingBasicContracts에 필요한 형식으로 변환 + const vendorDataForContract = data.vendors.map(vendor => ({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + vendorCode: vendor.vendorCode || undefined, + vendorCountry: vendor.vendorCountry, + selectedMainEmail: vendor.selectedMainEmail, + additionalEmails: vendor.additionalEmails, + customEmails: vendor.customEmails, + contractRequirements: { + ndaYn: vendor.ndaYn || false, + generalGtcYn: vendor.generalGtcYn || false, + projectGtcYn: vendor.projectGtcYn || false, + agreementYn: vendor.agreementYn || false, + }, + biddingCompanyId: vendor.biddingCompanyId, + biddingId: vendor.biddingId, + hasExistingContracts: vendor.hasExistingContracts, + })) + + const contractResult = await sendBiddingBasicContracts( + biddingId, + vendorDataForContract, + data.generatedPdfs, + data.message + ) + + if (!contractResult.success) { + const errorMessage = 'message' in contractResult + ? contractResult.message + : 'error' in contractResult + ? contractResult.error + : '기본계약 발송에 실패했습니다.' + toast({ + title: '기본계약 발송 실패', + description: errorMessage, + variant: 'destructive', + }) + return + } + + // 2. 입찰 등록 진행 + const registerResult = await registerBidding(biddingId, userId) + + if (registerResult.success) { + toast({ + title: '본입찰 초대 완료', + description: '기본계약 발송 및 본입찰 초대가 완료되었습니다.', + }) + setIsBiddingInvitationDialogOpen(false) + router.refresh() + } else { + toast({ + title: '오류', + description: 'error' in registerResult ? registerResult.error : '입찰 등록에 실패했습니다.', + variant: 'destructive', + }) + } + } 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.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) => { + 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)} + /> +
+
+ + handleScheduleChange('submissionEndDate', e.target.value)} + /> +
+
+
+ + {/* 긴급 여부 */} +
+
+ +

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

+
+ 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 }))} + /> +
+
+
+ +