summaryrefslogtreecommitdiff
path: root/lib/bidding/vendor
diff options
context:
space:
mode:
Diffstat (limited to 'lib/bidding/vendor')
-rw-r--r--lib/bidding/vendor/partners-bidding-attendance-dialog.tsx381
-rw-r--r--lib/bidding/vendor/partners-bidding-detail.tsx176
-rw-r--r--lib/bidding/vendor/partners-bidding-list-columns.tsx83
-rw-r--r--lib/bidding/vendor/partners-bidding-list.tsx47
-rw-r--r--lib/bidding/vendor/partners-bidding-toolbar-actions.tsx55
5 files changed, 500 insertions, 242 deletions
diff --git a/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx b/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx
index 270d9ccd..9205c46a 100644
--- a/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx
+++ b/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx
@@ -12,8 +12,10 @@ import {
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
-import { Textarea } from '@/components/ui/textarea'
+import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { ScrollArea } from '@/components/ui/scroll-area'
import {
Calendar,
Users,
@@ -21,11 +23,14 @@ import {
Clock,
FileText,
CheckCircle,
- XCircle
+ XCircle,
+ Download,
+ User,
+ Phone
} from 'lucide-react'
import { formatDate } from '@/lib/utils'
-import { updatePartnerAttendance } from '../detail/service'
+import { updatePartnerAttendance, getSpecificationMeetingForPartners } from '../detail/service'
import { useToast } from '@/hooks/use-toast'
import { useTransition } from 'react'
@@ -55,8 +60,47 @@ export function PartnersBiddingAttendanceDialog({
}: PartnersBiddingAttendanceDialogProps) {
const { toast } = useToast()
const [isPending, startTransition] = useTransition()
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [meetingData, setMeetingData] = React.useState<any>(null)
+
+ // 폼 상태
const [attendance, setAttendance] = React.useState<string>('')
- const [comments, setComments] = React.useState<string>('')
+ const [attendeeCount, setAttendeeCount] = React.useState<string>('')
+ const [representativeName, setRepresentativeName] = React.useState<string>('')
+ const [representativePhone, setRepresentativePhone] = React.useState<string>('')
+
+ // 사양설명회 정보 가져오기
+ React.useEffect(() => {
+ if (open && biddingDetail) {
+ fetchMeetingData()
+ }
+ }, [open, biddingDetail])
+
+ const fetchMeetingData = async () => {
+ if (!biddingDetail) return
+
+ setIsLoading(true)
+ try {
+ const result = await getSpecificationMeetingForPartners(biddingDetail.id)
+ if (result.success) {
+ setMeetingData(result.data)
+ } else {
+ toast({
+ title: '오류',
+ description: result.error,
+ variant: 'destructive',
+ })
+ }
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '사양설명회 정보를 불러오는데 실패했습니다.',
+ variant: 'destructive',
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
// 다이얼로그 열릴 때 기존 값으로 초기화
React.useEffect(() => {
@@ -68,7 +112,9 @@ export function PartnersBiddingAttendanceDialog({
} else {
setAttendance('')
}
- setComments('')
+ setAttendeeCount('')
+ setRepresentativeName('')
+ setRepresentativePhone('')
}
}, [open, isAttending])
@@ -82,10 +128,39 @@ export function PartnersBiddingAttendanceDialog({
return
}
+ // 참석하는 경우 필수 정보 체크
+ if (attendance === 'attending') {
+ if (!attendeeCount || !representativeName || !representativePhone) {
+ toast({
+ title: '필수 정보 누락',
+ description: '참석인원수, 참석자 대표 이름, 연락처를 모두 입력해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ const countNum = parseInt(attendeeCount)
+ if (isNaN(countNum) || countNum < 1) {
+ toast({
+ title: '잘못된 입력',
+ description: '참석인원수는 1 이상의 숫자를 입력해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+ }
+
startTransition(async () => {
+ const attendanceData = {
+ isAttending: attendance === 'attending',
+ attendeeCount: attendance === 'attending' ? parseInt(attendeeCount) : undefined,
+ representativeName: attendance === 'attending' ? representativeName : undefined,
+ representativePhone: attendance === 'attending' ? representativePhone : undefined,
+ }
+
const result = await updatePartnerAttendance(
biddingCompanyId,
- attendance === 'attending',
+ attendanceData,
'current-user' // TODO: 실제 사용자 ID
)
@@ -94,6 +169,15 @@ export function PartnersBiddingAttendanceDialog({
title: '성공',
description: result.message,
})
+
+ // 참석하는 경우 이메일 발송 알림
+ if (attendance === 'attending' && result.data) {
+ toast({
+ title: '참석 알림 발송',
+ description: '사양설명회 담당자에게 참석 알림이 발송되었습니다.',
+ })
+ }
+
onSuccess()
onOpenChange(false)
} else {
@@ -106,129 +190,200 @@ export function PartnersBiddingAttendanceDialog({
})
}
+ const handleFileDownload = async (filePath: string, fileName: string) => {
+ try {
+ const { downloadFile } = await import('@/lib/file-download')
+ await downloadFile(filePath, fileName)
+ } catch (error) {
+ console.error('파일 다운로드 실패:', error)
+ toast({
+ title: '다운로드 실패',
+ description: '파일 다운로드 중 오류가 발생했습니다.',
+ variant: 'destructive',
+ })
+ }
+ }
+
if (!biddingDetail) return null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="sm:max-w-[500px]">
+ <DialogContent className="max-w-4xl max-h-[90vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Users className="w-5 h-5" />
- 사양설명회 참석 여부
+ 사양설명회
</DialogTitle>
<DialogDescription>
- 입찰에 대한 사양설명회 참석 여부를 선택해주세요.
+ {biddingDetail.title}의 사양설명회 정보 및 참석 여부를 확인해주세요.
</DialogDescription>
</DialogHeader>
- <div className="space-y-6">
- {/* 입찰 정보 요약 */}
- <div className="bg-muted p-4 rounded-lg space-y-3">
- <div className="flex items-center gap-2">
- <FileText className="w-4 h-4 text-muted-foreground" />
- <span className="font-medium">{biddingDetail.title}</span>
- </div>
- <div className="flex items-center gap-2">
- <Badge variant="outline" className="font-mono">
- {biddingDetail.biddingNumber}
- </Badge>
- </div>
-
- {/* 주요 일정 */}
- <div className="grid grid-cols-1 gap-2 text-sm">
- {biddingDetail.preQuoteDate && (
- <div className="flex items-center gap-2">
- <Calendar className="w-4 h-4 text-muted-foreground" />
- <span>사전견적 마감: {formatDate(biddingDetail.preQuoteDate, 'KR')}</span>
- </div>
- )}
- {biddingDetail.biddingRegistrationDate && (
- <div className="flex items-center gap-2">
- <Calendar className="w-4 h-4 text-muted-foreground" />
- <span>입찰등록 마감: {formatDate(biddingDetail.biddingRegistrationDate, 'KR')}</span>
- </div>
- )}
- {biddingDetail.evaluationDate && (
- <div className="flex items-center gap-2">
- <Calendar className="w-4 h-4 text-muted-foreground" />
- <span>평가일: {formatDate(biddingDetail.evaluationDate, 'KR')}</span>
- </div>
- )}
- </div>
- </div>
-
- {/* 참석 여부 선택 */}
- <div className="space-y-3">
- <Label className="text-base font-medium">참석 여부를 선택해주세요</Label>
- <RadioGroup
- value={attendance}
- onValueChange={setAttendance}
- className="space-y-3"
- >
- <div className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/50 transition-colors">
- <RadioGroupItem value="attending" id="attending" />
- <div className="flex items-center gap-2 flex-1">
- <CheckCircle className="w-5 h-5 text-green-600" />
- <Label htmlFor="attending" className="font-medium cursor-pointer">
- 참석합니다
- </Label>
- </div>
- </div>
- <div className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/50 transition-colors">
- <RadioGroupItem value="not_attending" id="not_attending" />
- <div className="flex items-center gap-2 flex-1">
- <XCircle className="w-5 h-5 text-red-600" />
- <Label htmlFor="not_attending" className="font-medium cursor-pointer">
- 참석하지 않습니다
- </Label>
- </div>
+ <ScrollArea className="max-h-[75vh]">
+ {isLoading ? (
+ <div className="flex items-center justify-center py-6">
+ <div className="text-center">
+ <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto mb-2"></div>
+ <p className="text-sm text-muted-foreground">로딩 중...</p>
</div>
- </RadioGroup>
- </div>
-
- {/* 참석하지 않는 경우 의견 */}
- {attendance === 'not_attending' && (
- <div className="space-y-2">
- <Label htmlFor="comments">불참 사유 (선택사항)</Label>
- <Textarea
- id="comments"
- value={comments}
- onChange={(e) => setComments(e.target.value)}
- placeholder="참석하지 않는 이유를 간단히 설명해주세요."
- rows={3}
- className="resize-none"
- />
</div>
- )}
+ ) : (
+ <div className="space-y-6">
+ {/* 사양설명회 기본 정보 */}
+ {meetingData && (
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base flex items-center gap-2">
+ <Calendar className="w-4 h-4" />
+ 설명회 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="pt-0">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div>
+ <div className="text-sm space-y-2">
+ <div>
+ <span className="font-medium">설명회 일정:</span>
+ <span className="ml-2">
+ {meetingData.meetingDate ? formatDate(meetingData.meetingDate, 'KR') : '미정'}
+ </span>
+ </div>
+ <div>
+ <span className="font-medium">설명회 담당자:</span>
+ <div className="ml-2 text-muted-foreground">
+ {meetingData.contactPerson && (
+ <div>{meetingData.contactPerson}</div>
+ )}
+ {meetingData.contactEmail && (
+ <div>{meetingData.contactEmail}</div>
+ )}
+ {meetingData.contactPhone && (
+ <div>{meetingData.contactPhone}</div>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div>
+ <div className="text-sm">
+ <span className="font-medium">설명회 자료:</span>
+ <div className="mt-2 space-y-1">
+ {meetingData.documents && meetingData.documents.length > 0 ? (
+ meetingData.documents.map((doc: any) => (
+ <Button
+ key={doc.id}
+ variant="outline"
+ size="sm"
+ onClick={() => handleFileDownload(doc.filePath, doc.originalFileName)}
+ className="flex items-center gap-2 h-8 text-xs"
+ >
+ <Download className="w-3 h-3" />
+ {doc.originalFileName}
+ </Button>
+ ))
+ ) : (
+ <span className="text-muted-foreground">첨부파일 없음</span>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ )}
- {/* 참석하는 경우 추가 정보 */}
- {attendance === 'attending' && (
- <div className="bg-green-50 border border-green-200 rounded-lg p-4">
- <div className="flex items-start gap-2">
- <CheckCircle className="w-5 h-5 text-green-600 mt-0.5" />
- <div className="space-y-1">
- <p className="font-medium text-green-800">참석 확인</p>
- <p className="text-sm text-green-700">
- 사양설명회에 참석하겠다고 응답하셨습니다.
- 회의 일정 및 장소는 추후 별도 안내드리겠습니다.
- </p>
- </div>
- </div>
- </div>
- )}
+ {/* 참석 여부 입력 폼 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base">참석 여부</CardTitle>
+ </CardHeader>
+ <CardContent className="pt-0 space-y-4">
+ <RadioGroup
+ value={attendance}
+ onValueChange={setAttendance}
+ className="space-y-3"
+ >
+ <div className="flex items-start space-x-3 p-3 border rounded-lg hover:bg-muted/50 transition-colors">
+ <RadioGroupItem value="attending" id="attending" className="mt-1" />
+ <div className="flex-1">
+ <div className="flex items-center gap-2 mb-3">
+ <CheckCircle className="w-5 h-5 text-green-600" />
+ <Label htmlFor="attending" className="font-medium cursor-pointer">
+ Y (참석)
+ </Label>
+ </div>
+
+ {attendance === 'attending' && (
+ <div className="space-y-3 mt-3 pl-2">
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-3">
+ <div>
+ <Label htmlFor="attendeeCount" className="text-xs">참석인원수</Label>
+ <Input
+ id="attendeeCount"
+ type="number"
+ min="1"
+ value={attendeeCount}
+ onChange={(e) => setAttendeeCount(e.target.value)}
+ placeholder="명"
+ className="h-8 text-sm"
+ />
+ </div>
+ <div>
+ <Label htmlFor="representativeName" className="text-xs">참석자 대표(이름)</Label>
+ <Input
+ id="representativeName"
+ value={representativeName}
+ onChange={(e) => setRepresentativeName(e.target.value)}
+ placeholder="홍길동 과장"
+ className="h-8 text-sm"
+ />
+ </div>
+ <div>
+ <Label htmlFor="representativePhone" className="text-xs">참석자 대표(연락처)</Label>
+ <Input
+ id="representativePhone"
+ value={representativePhone}
+ onChange={(e) => setRepresentativePhone(e.target.value)}
+ placeholder="010-0000-0000"
+ className="h-8 text-sm"
+ />
+ </div>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+
+ <div className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/50 transition-colors">
+ <RadioGroupItem value="not_attending" id="not_attending" />
+ <div className="flex items-center gap-2 flex-1">
+ <XCircle className="w-5 h-5 text-red-600" />
+ <Label htmlFor="not_attending" className="font-medium cursor-pointer">
+ N (불참)
+ </Label>
+ </div>
+ </div>
+ </RadioGroup>
+ </CardContent>
+ </Card>
- {/* 현재 상태 표시 */}
- {isAttending !== null && (
- <div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
- <div className="flex items-center gap-2 text-blue-800">
- <Clock className="w-4 h-4" />
- <span className="text-sm">
- 현재 상태: {isAttending ? '참석' : '불참'} ({formatDate(new Date().toISOString(), 'KR')} 기준)
- </span>
- </div>
+ {/* 현재 상태 표시 */}
+ {isAttending !== null && (
+ <Card>
+ <CardContent className="pt-6">
+ <div className="flex items-center gap-2 text-blue-800">
+ <Clock className="w-4 h-4" />
+ <span className="text-sm">
+ 현재 상태: {isAttending ? '참석' : '불참'} ({formatDate(new Date().toISOString(), 'KR')} 기준)
+ </span>
+ </div>
+ </CardContent>
+ </Card>
+ )}
</div>
)}
- </div>
+ </ScrollArea>
<DialogFooter className="flex gap-2">
<Button
@@ -240,10 +395,10 @@ export function PartnersBiddingAttendanceDialog({
</Button>
<Button
onClick={handleSubmit}
- disabled={isPending || !attendance}
+ disabled={isPending || !attendance || isLoading}
className="min-w-[100px]"
>
- {isPending ? '저장 중...' : '저장'}
+ {isPending ? '저장 중...' : '확인'}
</Button>
</DialogFooter>
</DialogContent>
diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx
index 4c4db37f..c6ba4926 100644
--- a/lib/bidding/vendor/partners-bidding-detail.tsx
+++ b/lib/bidding/vendor/partners-bidding-detail.tsx
@@ -29,7 +29,6 @@ import {
submitPartnerResponse,
updatePartnerAttendance
} from '../detail/service'
-import { PartnersBiddingAttendanceDialog } from './partners-bidding-attendance-dialog'
import {
biddingStatusLabels,
contractTypeLabels,
@@ -75,20 +74,15 @@ interface BiddingDetail {
finalQuoteSubmittedAt: string
isWinner: boolean
isAttendingMeeting: boolean | null
- offeredPaymentTerms: string
- offeredTaxConditions: string
- offeredIncoterms: string
- offeredContractDeliveryDate: string
- offeredShippingPort: string
- offeredDestinationPort: string
- isPriceAdjustmentApplicable: boolean
- responsePaymentTerms: string
- responseTaxConditions: string
- responseIncoterms: string
+ // companyConditionResponses에서 가져온 조건들 (제시된 조건과 응답 모두)
+ paymentTermsResponse: string
+ taxConditionsResponse: string
+ incotermsResponse: string
proposedContractDeliveryDate: string
proposedShippingPort: string
proposedDestinationPort: string
priceAdjustmentResponse: boolean
+ sparePartResponse: string
additionalProposals: string
responseSubmittedAt: string
}
@@ -110,13 +104,11 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
proposedShippingPort: '',
proposedDestinationPort: '',
priceAdjustmentResponse: false,
+ sparePartResponse: '',
additionalProposals: '',
isAttendingMeeting: false,
})
- // 사양설명회 참석 여부 다이얼로그 상태
- const [isAttendanceDialogOpen, setIsAttendanceDialogOpen] = React.useState(false)
-
// 데이터 로드
React.useEffect(() => {
const loadData = async () => {
@@ -129,15 +121,16 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
// 기존 응답 데이터로 폼 초기화
setResponseData({
finalQuoteAmount: result.finalQuoteAmount?.toString() || '',
- paymentTermsResponse: result.responsePaymentTerms || '',
- taxConditionsResponse: result.responseTaxConditions || '',
- incotermsResponse: result.responseIncoterms || '',
+ paymentTermsResponse: result.paymentTermsResponse || '',
+ taxConditionsResponse: result.taxConditionsResponse || '',
+ incotermsResponse: result.incotermsResponse || '',
proposedContractDeliveryDate: result.proposedContractDeliveryDate || '',
proposedShippingPort: result.proposedShippingPort || '',
proposedDestinationPort: result.proposedDestinationPort || '',
priceAdjustmentResponse: result.priceAdjustmentResponse || false,
+ sparePartResponse: result.sparePartResponse || '',
additionalProposals: result.additionalProposals || '',
- isAttendingMeeting: false, // TODO: biddingCompanies에서 가져와야 함
+ isAttendingMeeting: result.isAttendingMeeting || false,
})
}
} catch (error) {
@@ -180,6 +173,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
proposedShippingPort: responseData.proposedShippingPort,
proposedDestinationPort: responseData.proposedDestinationPort,
priceAdjustmentResponse: responseData.priceAdjustmentResponse,
+ sparePartResponse: responseData.sparePartResponse,
additionalProposals: responseData.additionalProposals,
},
'current-user' // TODO: 실제 사용자 ID
@@ -191,15 +185,6 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
description: result.message,
})
- // 사양설명회 참석 여부도 업데이트
- if (responseData.isAttendingMeeting !== undefined) {
- await updatePartnerAttendance(
- biddingDetail.biddingCompanyId,
- responseData.isAttendingMeeting,
- 'current-user'
- )
- }
-
// 데이터 새로고침
const updatedDetail = await getBiddingDetailsForPartners(biddingId, companyId)
if (updatedDetail) {
@@ -272,26 +257,6 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</div>
</div>
- {/* 사양설명회 참석 여부 버튼 */}
- <div className="flex items-center gap-2">
- <Button
- variant="outline"
- onClick={() => setIsAttendanceDialogOpen(true)}
- className="flex items-center gap-2"
- >
- <Users className="w-4 h-4" />
- 사양설명회 참석
- {biddingDetail.isAttendingMeeting !== null && (
- <div className="ml-1">
- {biddingDetail.isAttendingMeeting ? (
- <CheckCircle className="w-4 h-4 text-green-600" />
- ) : (
- <XCircle className="w-4 h-4 text-red-600" />
- )}
- </div>
- )}
- </Button>
- </div>
</div>
{/* 입찰 공고 섹션 */}
@@ -368,48 +333,68 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</CardContent>
</Card>
- {/* 제시된 조건 섹션 */}
+ {/* 현재 설정된 조건 섹션 */}
<Card>
<CardHeader>
- <CardTitle>제시된 입찰 조건</CardTitle>
+ <CardTitle>현재 설정된 입찰 조건</CardTitle>
</CardHeader>
<CardContent>
- <div className="space-y-4">
+ <div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-sm font-medium">지급조건</Label>
<div className="mt-1 p-3 bg-muted rounded-md">
- {biddingDetail.offeredPaymentTerms ?
- JSON.parse(biddingDetail.offeredPaymentTerms).join(', ') :
- '정보 없음'}
+ {biddingDetail.paymentTermsResponse}
</div>
</div>
<div>
<Label className="text-sm font-medium">세금조건</Label>
<div className="mt-1 p-3 bg-muted rounded-md">
- {biddingDetail.offeredTaxConditions ?
- JSON.parse(biddingDetail.offeredTaxConditions).join(', ') :
- '정보 없음'}
+ {biddingDetail.taxConditionsResponse}
</div>
</div>
<div>
<Label className="text-sm font-medium">운송조건</Label>
<div className="mt-1 p-3 bg-muted rounded-md">
- {biddingDetail.offeredIncoterms ?
- JSON.parse(biddingDetail.offeredIncoterms).join(', ') :
- '정보 없음'}
+ {biddingDetail.incotermsResponse}
</div>
</div>
- {biddingDetail.offeredContractDeliveryDate && (
- <div>
- <Label className="text-sm font-medium">계약납기일</Label>
- <div className="mt-1 p-3 bg-muted rounded-md">
- {formatDate(biddingDetail.offeredContractDeliveryDate, 'KR')}
- </div>
+ <div>
+ <Label className="text-sm font-medium">제안 계약납기일</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ {biddingDetail.proposedContractDeliveryDate ? formatDate(biddingDetail.proposedContractDeliveryDate, 'KR') : '미설정'}
+ </div>
+ </div>
+
+ <div>
+ <Label className="text-sm font-medium">제안 선적지</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ {biddingDetail.proposedShippingPort}
+ </div>
+ </div>
+
+ <div>
+ <Label className="text-sm font-medium">제안 도착지</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ {biddingDetail.proposedDestinationPort}
+ </div>
+ </div>
+
+ <div>
+ <Label className="text-sm font-medium">스페어파트 응답</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ {biddingDetail.sparePartResponse}
+ </div>
+ </div>
+
+ <div>
+ <Label className="text-sm font-medium">연동제 적용</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ {biddingDetail.priceAdjustmentResponse ? '적용' : '미적용'}
</div>
- )}
+ </div>
</div>
</CardContent>
</Card>
@@ -490,6 +475,28 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</div>
</div>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
+ <div className="space-y-2">
+ <Label htmlFor="proposedDestinationPort">제안 도착지</Label>
+ <Input
+ id="proposedDestinationPort"
+ value={responseData.proposedDestinationPort}
+ onChange={(e) => setResponseData({...responseData, proposedDestinationPort: e.target.value})}
+ placeholder="도착지를 입력하세요"
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="sparePartResponse">스페어파트 응답</Label>
+ <Input
+ id="sparePartResponse"
+ value={responseData.sparePartResponse}
+ onChange={(e) => setResponseData({...responseData, sparePartResponse: e.target.value})}
+ placeholder="스페어파트 관련 응답을 입력하세요"
+ />
+ </div>
+ </div>
+
<div className="space-y-2">
<Label htmlFor="additionalProposals">추가 제안사항</Label>
<Textarea
@@ -512,17 +519,6 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
<Label htmlFor="priceAdjustmentResponse">연동제 적용에 동의합니다</Label>
</div>
- <div className="flex items-center space-x-2">
- <Checkbox
- id="isAttendingMeeting"
- checked={responseData.isAttendingMeeting}
- onCheckedChange={(checked) =>
- setResponseData({...responseData, isAttendingMeeting: !!checked})
- }
- />
- <Label htmlFor="isAttendingMeeting">사양설명회에 참석합니다</Label>
- </div>
-
<div className="flex justify-end pt-4">
<Button onClick={handleSubmitResponse} disabled={isPending}>
<Send className="w-4 h-4 mr-2" />
@@ -531,32 +527,6 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</div>
</CardContent>
</Card>
-
- {/* 사양설명회 참석 여부 다이얼로그 */}
- <PartnersBiddingAttendanceDialog
- biddingDetail={{
- id: biddingDetail.id,
- biddingNumber: biddingDetail.biddingNumber,
- title: biddingDetail.title,
- preQuoteDate: biddingDetail.preQuoteDate,
- biddingRegistrationDate: biddingDetail.biddingRegistrationDate,
- evaluationDate: biddingDetail.evaluationDate,
- }}
- biddingCompanyId={biddingDetail.biddingCompanyId}
- isAttending={biddingDetail.isAttendingMeeting}
- open={isAttendanceDialogOpen}
- onOpenChange={setIsAttendanceDialogOpen}
- onSuccess={() => {
- // 데이터 새로고침
- const refreshData = async () => {
- const updatedDetail = await getBiddingDetailsForPartners(biddingId, companyId)
- if (updatedDetail) {
- setBiddingDetail(updatedDetail)
- }
- }
- refreshData()
- }}
- />
</div>
)
}
diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx
index b54ca967..41cc329f 100644
--- a/lib/bidding/vendor/partners-bidding-list-columns.tsx
+++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx
@@ -14,7 +14,7 @@ import {
CheckCircle,
XCircle,
Users,
- Eye,
+ FileText,
MoreHorizontal,
Calendar,
User
@@ -22,6 +22,7 @@ import {
import { formatDate } from '@/lib/utils'
import { biddingStatusLabels, contractTypeLabels } from '@/db/schema'
import { PartnersBiddingListItem } from '../detail/service'
+import { Checkbox } from '@/components/ui/checkbox'
const columnHelper = createColumnHelper<PartnersBiddingListItem>()
@@ -31,6 +32,29 @@ interface PartnersBiddingListColumnsProps {
export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingListColumnsProps = {}) {
return [
+ // select 버튼
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
+ onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
+ aria-label="select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(v) => row.toggleSelected(!!v)}
+ aria-label="select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ },
// 입찰 No.
columnHelper.accessor('biddingNumber', {
header: '입찰 No.',
@@ -66,10 +90,10 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
},
}),
- // 상세 (액션 버튼)
+ // 액션 (드롭다운 메뉴)
columnHelper.display({
id: 'actions',
- header: '상세',
+ header: 'Actions',
cell: ({ row }) => {
const handleView = () => {
if (setRowAction) {
@@ -80,15 +104,42 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
}
}
+ const handleAttendance = () => {
+ if (setRowAction) {
+ setRowAction({
+ type: 'attendance',
+ row: { original: row.original }
+ })
+ }
+ }
+
+ const canManageAttendance = row.original.invitationStatus === 'sent' ||
+ row.original.invitationStatus === 'accepted'
+
return (
- <Button
- variant="outline"
- size="sm"
- onClick={handleView}
- className="h-8 w-8 p-0"
- >
- <Eye className="h-4 w-4" />
- </Button>
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="ghost"
+ className="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
+ >
+ <MoreHorizontal className="h-4 w-4" />
+ <span className="sr-only">메뉴 열기</span>
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-[160px]">
+ <DropdownMenuItem onClick={handleView}>
+ <FileText className="mr-2 h-4 w-4" />
+ 상세보기
+ </DropdownMenuItem>
+ {canManageAttendance && (
+ <DropdownMenuItem onClick={handleAttendance}>
+ <Users className="mr-2 h-4 w-4" />
+ 참석여부
+ </DropdownMenuItem>
+ )}
+ </DropdownMenuContent>
+ </DropdownMenu>
)
},
}),
@@ -247,14 +298,6 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
),
}),
- // 최종수정자
- columnHelper.accessor('updatedBy', {
- header: '최종수정자',
- cell: ({ row }) => (
- <div className="max-w-20 truncate" title={row.original.updatedBy || ''}>
- {row.original.updatedBy || '-'}
- </div>
- ),
- }),
+
]
}
diff --git a/lib/bidding/vendor/partners-bidding-list.tsx b/lib/bidding/vendor/partners-bidding-list.tsx
index c0356e22..aa185c3a 100644
--- a/lib/bidding/vendor/partners-bidding-list.tsx
+++ b/lib/bidding/vendor/partners-bidding-list.tsx
@@ -13,6 +13,8 @@ import { DataTable } from '@/components/data-table/data-table'
import { DataTableAdvancedToolbar } from '@/components/data-table/data-table-advanced-toolbar'
import { getPartnersBiddingListColumns } from './partners-bidding-list-columns'
import { getBiddingListForPartners, PartnersBiddingListItem } from '../detail/service'
+import { PartnersBiddingToolbarActions } from './partners-bidding-toolbar-actions'
+import { PartnersBiddingAttendanceDialog } from './partners-bidding-attendance-dialog'
interface PartnersBiddingListProps {
companyId: number
@@ -133,6 +135,19 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) {
clearOnDefault: true,
})
+ // 데이터 새로고침 함수
+ const refreshData = React.useCallback(async () => {
+ try {
+ setIsLoading(true)
+ const result = await getBiddingListForPartners(companyId)
+ setData(result)
+ } catch (error) {
+ console.error('Failed to refresh bidding list:', error)
+ } finally {
+ setIsLoading(false)
+ }
+ }, [companyId])
+
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
@@ -145,12 +160,32 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) {
}
return (
- <DataTable table={table}>
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <PartnersBiddingToolbarActions table={table} onRefresh={refreshData} setRowAction={setRowAction} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <PartnersBiddingAttendanceDialog
+ open={rowAction?.type === "attendance"}
+ onOpenChange={() => setRowAction(null)}
+ biddingDetail={rowAction?.row.original ? {
+ id: rowAction.row.original.biddingId,
+ biddingNumber: rowAction.row.original.biddingNumber,
+ title: rowAction.row.original.title,
+ preQuoteDate: null,
+ biddingRegistrationDate: rowAction.row.original.submissionStartDate?.toISOString() || null,
+ evaluationDate: null,
+ } : null}
+ biddingCompanyId={rowAction?.row.original?.biddingCompanyId || 0}
+ isAttending={rowAction?.row.original?.isAttendingMeeting || null}
+ onSuccess={refreshData}
/>
- </DataTable>
+ </>
)
}
diff --git a/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx b/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx
new file mode 100644
index 00000000..c45568bd
--- /dev/null
+++ b/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx
@@ -0,0 +1,55 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Users } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import { PartnersBiddingListItem } from '../detail/service'
+
+interface PartnersBiddingToolbarActionsProps {
+ table: Table<PartnersBiddingListItem>
+ onRefresh: () => void
+ setRowAction?: (action: { type: string; row: { original: PartnersBiddingListItem } }) => void
+}
+
+export function PartnersBiddingToolbarActions({
+ table,
+ onRefresh,
+ setRowAction
+}: PartnersBiddingToolbarActionsProps) {
+ // 선택된 행 가져오기 (단일 선택)
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const selectedBidding = selectedRows.length === 1 ? selectedRows[0].original : null
+
+ // 사양설명회 참석 여부 버튼 활성화 조건
+ const canManageAttendance = selectedBidding && (
+ selectedBidding.invitationStatus === 'sent' ||
+ selectedBidding.invitationStatus === 'accepted' ||
+ selectedBidding.invitationStatus === 'submitted'
+ )
+
+ const handleAttendanceClick = () => {
+ if (selectedBidding && setRowAction) {
+ setRowAction({
+ type: 'attendance',
+ row: { original: selectedBidding }
+ })
+ }
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleAttendanceClick}
+ disabled={!canManageAttendance}
+ className="flex items-center gap-2"
+ >
+ <Users className="w-4 h-4" />
+ 사양설명회 참석여부
+ </Button>
+ </div>
+ )
+} \ No newline at end of file