diff options
Diffstat (limited to 'lib/bidding/vendor')
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-attendance-dialog.tsx | 381 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-detail.tsx | 176 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-list-columns.tsx | 83 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-list.tsx | 47 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-toolbar-actions.tsx | 55 |
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 |
