summaryrefslogtreecommitdiff
path: root/components/bidding/manage/bidding-schedule-editor.tsx
diff options
context:
space:
mode:
authorTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2025-11-10 11:25:19 +0900
committerTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2025-11-10 11:25:19 +0900
commita5501ad1d1cb836d2b2f84e9b0f06049e22c901e (patch)
tree667ed8c5d6ec35b109190e9f976d66ae54def4ce /components/bidding/manage/bidding-schedule-editor.tsx
parentb0fe980376fcf1a19ff4b90851ca8b01f378fdc0 (diff)
parentf8a38907911d940cb2e8e6c9aa49488d05b2b578 (diff)
Merge remote-tracking branch 'origin/dujinkim' into master_homemaster
Diffstat (limited to 'components/bidding/manage/bidding-schedule-editor.tsx')
-rw-r--r--components/bidding/manage/bidding-schedule-editor.tsx661
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>
+ )
+}
+