diff options
Diffstat (limited to 'components/bidding/manage/bidding-schedule-editor.tsx')
| -rw-r--r-- | components/bidding/manage/bidding-schedule-editor.tsx | 661 |
1 files changed, 661 insertions, 0 deletions
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<BiddingSchedule>({}) + const [specMeetingInfo, setSpecMeetingInfo] = React.useState<SpecificationMeetingInfo>({ + 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<VendorContractRequirement[]>([]) + + // 데이터 로딩 + 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<VendorContractRequirement[]> => { + 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 ( + <div className="flex items-center justify-center p-8"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div> + <span className="ml-2">일정 정보를 불러오는 중...</span> + </div> + ) + } + + return ( + <div className="space-y-6"> + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Calendar className="h-5 w-5" /> + 입찰 일정 관리 + </CardTitle> + <p className="text-sm text-muted-foreground mt-1"> + 입찰의 주요 일정들을 설정하고 관리합니다. + </p> + </CardHeader> + <CardContent className="space-y-6"> + {/* 입찰서 제출 기간 */} + <div className="space-y-4"> + <h3 className="text-lg font-medium flex items-center gap-2"> + <Clock className="h-4 w-4" /> + 입찰서 제출 기간 + </h3> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="submission-start">제출 시작일시</Label> + <Input + id="submission-start" + type="datetime-local" + value={schedule.submissionStartDate || ''} + onChange={(e) => handleScheduleChange('submissionStartDate', e.target.value)} + /> + </div> + <div className="space-y-2"> + <Label htmlFor="submission-end">제출 마감일시</Label> + <Input + id="submission-end" + type="datetime-local" + value={schedule.submissionEndDate || ''} + onChange={(e) => handleScheduleChange('submissionEndDate', e.target.value)} + /> + </div> + </div> + </div> + + {/* 긴급 여부 */} + <div className="flex flex-row items-center justify-between rounded-lg border p-4"> + <div className="space-y-0.5"> + <Label className="text-base">긴급여부</Label> + <p className="text-sm text-muted-foreground"> + 긴급 입찰로 표시할 경우 활성화하세요 + </p> + </div> + <Switch + checked={schedule.isUrgent || false} + onCheckedChange={(checked) => handleScheduleChange('isUrgent', checked)} + /> + </div> + + {/* 사양설명회 실시 여부 */} + <div className="flex flex-row items-center justify-between rounded-lg border p-4"> + <div className="space-y-0.5"> + <Label className="text-base">사양설명회 실시</Label> + <p className="text-sm text-muted-foreground"> + 사양설명회를 실시할 경우 상세 정보를 입력하세요 + </p> + </div> + <Switch + checked={schedule.hasSpecificationMeeting || false} + onCheckedChange={(checked) => handleScheduleChange('hasSpecificationMeeting', checked)} + /> + </div> + + {/* 사양설명회 상세 정보 */} + {schedule.hasSpecificationMeeting && ( + <div className="space-y-6 p-4 border rounded-lg bg-muted/50"> + <div className="grid grid-cols-2 gap-4"> + <div> + <Label>회의일시 <span className="text-red-500">*</span></Label> + <Input + type="datetime-local" + value={specMeetingInfo.meetingDate} + onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, meetingDate: e.target.value }))} + className={!specMeetingInfo.meetingDate ? 'border-red-200' : ''} + /> + {!specMeetingInfo.meetingDate && ( + <p className="text-sm text-red-500 mt-1">회의일시는 필수입니다</p> + )} + </div> + <div> + <Label>회의시간</Label> + <Input + placeholder="예: 14:00 ~ 16:00" + value={specMeetingInfo.meetingTime} + onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, meetingTime: e.target.value }))} + /> + </div> + </div> + <div> + <Label>장소 <span className="text-red-500">*</span></Label> + <Input + placeholder="회의 장소" + value={specMeetingInfo.location} + onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, location: e.target.value }))} + className={!specMeetingInfo.location ? 'border-red-200' : ''} + /> + {!specMeetingInfo.location && ( + <p className="text-sm text-red-500 mt-1">회의 장소는 필수입니다</p> + )} + </div> + <div> + <Label>주소</Label> + <Input + placeholder="회의 장소 주소" + value={specMeetingInfo.address} + onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, address: e.target.value }))} + /> + </div> + <div className="grid grid-cols-3 gap-4"> + <div> + <Label>담당자 <span className="text-red-500">*</span></Label> + <Input + placeholder="담당자명" + value={specMeetingInfo.contactPerson} + onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactPerson: e.target.value }))} + className={!specMeetingInfo.contactPerson ? 'border-red-200' : ''} + /> + {!specMeetingInfo.contactPerson && ( + <p className="text-sm text-red-500 mt-1">담당자는 필수입니다</p> + )} + </div> + <div> + <Label>연락처</Label> + <Input + placeholder="전화번호" + value={specMeetingInfo.contactPhone} + onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactPhone: e.target.value }))} + /> + </div> + <div> + <Label>이메일</Label> + <Input + type="email" + placeholder="이메일" + value={specMeetingInfo.contactEmail} + onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactEmail: e.target.value }))} + /> + </div> + </div> + <div> + <Label>안건</Label> + <Textarea + placeholder="회의 안건을 입력하세요" + value={specMeetingInfo.agenda} + onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, agenda: e.target.value }))} + rows={3} + /> + </div> + <div> + <Label>자료</Label> + <Textarea + placeholder="회의 자료 정보를 입력하세요" + value={specMeetingInfo.materials} + onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, materials: e.target.value }))} + rows={3} + /> + </div> + <div> + <Label>비고</Label> + <Textarea + placeholder="추가 사항을 입력하세요" + value={specMeetingInfo.notes} + onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, notes: e.target.value }))} + rows={3} + /> + </div> + </div> + )} + + {/* 비고 */} + <div className="space-y-4"> + <h3 className="text-lg font-medium">비고</h3> + <div className="space-y-2"> + <Label htmlFor="remarks">추가 사항</Label> + <Textarea + id="remarks" + value={schedule.remarks || ''} + onChange={(e) => handleScheduleChange('remarks', e.target.value)} + placeholder="일정에 대한 추가 설명이나 참고사항을 입력하세요" + rows={4} + /> + </div> + </div> + </CardContent> + </Card> + + {/* 일정 요약 카드 */} + <Card> + <CardHeader> + <CardTitle>일정 요약</CardTitle> + </CardHeader> + <CardContent> + <div className="space-y-2 text-sm"> + <div className="flex justify-between"> + <span className="font-medium">입찰서 제출 기간:</span> + <span> + {schedule.submissionStartDate && schedule.submissionEndDate + ? `${new Date(schedule.submissionStartDate).toLocaleString('ko-KR')} ~ ${new Date(schedule.submissionEndDate).toLocaleString('ko-KR')}` + : '미설정' + } + </span> + </div> + <div className="flex justify-between"> + <span className="font-medium">긴급여부:</span> + <span> + {schedule.isUrgent ? '예' : '아니오'} + </span> + </div> + <div className="flex justify-between"> + <span className="font-medium">사양설명회 실시:</span> + <span> + {schedule.hasSpecificationMeeting ? '예' : '아니오'} + </span> + </div> + {schedule.hasSpecificationMeeting && specMeetingInfo.meetingDate && ( + <div className="flex justify-between"> + <span className="font-medium">사양설명회 일시:</span> + <span> + {new Date(specMeetingInfo.meetingDate).toLocaleString('ko-KR')} + </span> + </div> + )} + </div> + </CardContent> + </Card> + + {/* 액션 버튼 */} + <div className="flex justify-between gap-4"> + <Button + variant="default" + onClick={() => setIsBiddingInvitationDialogOpen(true)} + disabled={!biddingInfo} + className="min-w-[120px]" + > + <Send className="w-4 h-4 mr-2" /> + 입찰공고 + </Button> + <div className="flex gap-4"> + <Button + onClick={handleSave} + disabled={isSubmitting} + className="min-w-[120px]" + > + {isSubmitting ? ( + <> + <RefreshCw className="w-4 h-4 mr-2 animate-spin" /> + 저장 중... + </> + ) : ( + <> + <Save className="w-4 h-4 mr-2" /> + 저장 + </> + )} + </Button> + </div> + </div> + + {/* 입찰 초대 다이얼로그 */} + {biddingInfo && ( + <BiddingInvitationDialog + open={isBiddingInvitationDialogOpen} + onOpenChange={setIsBiddingInvitationDialogOpen} + vendors={selectedVendors} + biddingId={biddingId} + biddingTitle={biddingInfo.title} + onSend={handleBiddingInvitationSend} + /> + )} + </div> + ) +} + |
