diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-27 12:06:26 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-27 12:06:26 +0000 |
| commit | 7548e2ad6948f1c6aa102fcac408bc6c9c0f9796 (patch) | |
| tree | 8e66703ec821888ad51dcc242a508813a027bf71 /lib/bidding/vendor | |
| parent | 7eac558470ef179dad626a8e82db5784fe86a556 (diff) | |
(대표님, 최겸) 기본계약, 입찰, 파일라우트, 계약서명라우트, 인포메이션, 메뉴설정, PQ(메일템플릿 관련)
Diffstat (limited to 'lib/bidding/vendor')
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-attendance-dialog.tsx | 252 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-detail.tsx | 562 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-list-columns.tsx | 260 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-list.tsx | 156 |
4 files changed, 1230 insertions, 0 deletions
diff --git a/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx b/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx new file mode 100644 index 00000000..270d9ccd --- /dev/null +++ b/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx @@ -0,0 +1,252 @@ +'use client' + +import * as React from 'react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} 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 { Badge } from '@/components/ui/badge' +import { + Calendar, + Users, + MapPin, + Clock, + FileText, + CheckCircle, + XCircle +} from 'lucide-react' + +import { formatDate } from '@/lib/utils' +import { updatePartnerAttendance } from '../detail/service' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' + +interface PartnersBiddingAttendanceDialogProps { + biddingDetail: { + id: number + biddingNumber: string + title: string + preQuoteDate: string | null + biddingRegistrationDate: string | null + evaluationDate: string | null + } | null + biddingCompanyId: number + isAttending: boolean | null + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: () => void +} + +export function PartnersBiddingAttendanceDialog({ + biddingDetail, + biddingCompanyId, + isAttending, + open, + onOpenChange, + onSuccess, +}: PartnersBiddingAttendanceDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [attendance, setAttendance] = React.useState<string>('') + const [comments, setComments] = React.useState<string>('') + + // 다이얼로그 열릴 때 기존 값으로 초기화 + React.useEffect(() => { + if (open) { + if (isAttending === true) { + setAttendance('attending') + } else if (isAttending === false) { + setAttendance('not_attending') + } else { + setAttendance('') + } + setComments('') + } + }, [open, isAttending]) + + const handleSubmit = () => { + if (!attendance) { + toast({ + title: '선택 필요', + description: '참석 여부를 선택해주세요.', + variant: 'destructive', + }) + return + } + + startTransition(async () => { + const result = await updatePartnerAttendance( + biddingCompanyId, + attendance === 'attending', + 'current-user' // TODO: 실제 사용자 ID + ) + + if (result.success) { + toast({ + title: '성공', + description: result.message, + }) + onSuccess() + onOpenChange(false) + } else { + toast({ + title: '오류', + description: result.error, + variant: 'destructive', + }) + } + }) + } + + if (!biddingDetail) return null + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Users className="w-5 h-5" /> + 사양설명회 참석 여부 + </DialogTitle> + <DialogDescription> + 입찰에 대한 사양설명회 참석 여부를 선택해주세요. + </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> + </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> + )} + + {/* 참석하는 경우 추가 정보 */} + {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> + )} + + {/* 현재 상태 표시 */} + {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> + </div> + )} + </div> + + <DialogFooter className="flex gap-2"> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isPending} + > + 취소 + </Button> + <Button + onClick={handleSubmit} + disabled={isPending || !attendance} + className="min-w-[100px]" + > + {isPending ? '저장 중...' : '저장'} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx new file mode 100644 index 00000000..4c4db37f --- /dev/null +++ b/lib/bidding/vendor/partners-bidding-detail.tsx @@ -0,0 +1,562 @@ +'use client' + +import * as React from 'react' +import { useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Checkbox } from '@/components/ui/checkbox' +import { + ArrowLeft, + Calendar, + Building2, + Package, + User, + DollarSign, + FileText, + Users, + Send, + CheckCircle, + XCircle +} from 'lucide-react' + +import { formatDate } from '@/lib/utils' +import { + getBiddingDetailsForPartners, + submitPartnerResponse, + updatePartnerAttendance +} from '../detail/service' +import { PartnersBiddingAttendanceDialog } from './partners-bidding-attendance-dialog' +import { + biddingStatusLabels, + contractTypeLabels, + biddingTypeLabels +} from '@/db/schema' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' + +interface PartnersBiddingDetailProps { + biddingId: number + companyId: number +} + +interface BiddingDetail { + id: number + biddingNumber: string + revision: number + projectName: string + itemName: string + title: string + description: string + content: string + contractType: string + biddingType: string + awardCount: string + contractPeriod: string + preQuoteDate: string + biddingRegistrationDate: string + submissionStartDate: string + submissionEndDate: string + evaluationDate: string + currency: string + budget: number + targetPrice: number + status: string + managerName: string + managerEmail: string + managerPhone: string + biddingCompanyId: number + biddingId: number // bidding의 ID 추가 + invitationStatus: string + finalQuoteAmount: number + 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 + proposedContractDeliveryDate: string + proposedShippingPort: string + proposedDestinationPort: string + priceAdjustmentResponse: boolean + additionalProposals: string + responseSubmittedAt: string +} + +export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingDetailProps) { + const router = useRouter() + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [biddingDetail, setBiddingDetail] = React.useState<BiddingDetail | null>(null) + const [isLoading, setIsLoading] = React.useState(true) + + // 응찰 폼 상태 + const [responseData, setResponseData] = React.useState({ + finalQuoteAmount: '', + paymentTermsResponse: '', + taxConditionsResponse: '', + incotermsResponse: '', + proposedContractDeliveryDate: '', + proposedShippingPort: '', + proposedDestinationPort: '', + priceAdjustmentResponse: false, + additionalProposals: '', + isAttendingMeeting: false, + }) + + // 사양설명회 참석 여부 다이얼로그 상태 + const [isAttendanceDialogOpen, setIsAttendanceDialogOpen] = React.useState(false) + + // 데이터 로드 + React.useEffect(() => { + const loadData = async () => { + try { + setIsLoading(true) + const result = await getBiddingDetailsForPartners(biddingId, companyId) + if (result) { + setBiddingDetail(result) + + // 기존 응답 데이터로 폼 초기화 + setResponseData({ + finalQuoteAmount: result.finalQuoteAmount?.toString() || '', + paymentTermsResponse: result.responsePaymentTerms || '', + taxConditionsResponse: result.responseTaxConditions || '', + incotermsResponse: result.responseIncoterms || '', + proposedContractDeliveryDate: result.proposedContractDeliveryDate || '', + proposedShippingPort: result.proposedShippingPort || '', + proposedDestinationPort: result.proposedDestinationPort || '', + priceAdjustmentResponse: result.priceAdjustmentResponse || false, + additionalProposals: result.additionalProposals || '', + isAttendingMeeting: false, // TODO: biddingCompanies에서 가져와야 함 + }) + } + } catch (error) { + console.error('Failed to load bidding detail:', error) + toast({ + title: '오류', + description: '입찰 정보를 불러오는데 실패했습니다.', + variant: 'destructive', + }) + } finally { + setIsLoading(false) + } + } + + loadData() + }, [biddingId, companyId, toast]) + + const handleSubmitResponse = () => { + if (!biddingDetail) return + + // 필수값 검증 + if (!responseData.finalQuoteAmount.trim()) { + toast({ + title: '유효성 오류', + description: '견적 금액을 입력해주세요.', + variant: 'destructive', + }) + return + } + + startTransition(async () => { + const result = await submitPartnerResponse( + biddingDetail.biddingCompanyId, + { + finalQuoteAmount: parseFloat(responseData.finalQuoteAmount), + paymentTermsResponse: responseData.paymentTermsResponse, + taxConditionsResponse: responseData.taxConditionsResponse, + incotermsResponse: responseData.incotermsResponse, + proposedContractDeliveryDate: responseData.proposedContractDeliveryDate, + proposedShippingPort: responseData.proposedShippingPort, + proposedDestinationPort: responseData.proposedDestinationPort, + priceAdjustmentResponse: responseData.priceAdjustmentResponse, + additionalProposals: responseData.additionalProposals, + }, + 'current-user' // TODO: 실제 사용자 ID + ) + + if (result.success) { + toast({ + title: '성공', + description: result.message, + }) + + // 사양설명회 참석 여부도 업데이트 + if (responseData.isAttendingMeeting !== undefined) { + await updatePartnerAttendance( + biddingDetail.biddingCompanyId, + responseData.isAttendingMeeting, + 'current-user' + ) + } + + // 데이터 새로고침 + const updatedDetail = await getBiddingDetailsForPartners(biddingId, companyId) + if (updatedDetail) { + setBiddingDetail(updatedDetail) + } + } else { + toast({ + title: '오류', + description: result.error, + variant: 'destructive', + }) + } + }) + } + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: biddingDetail?.currency || 'KRW', + }).format(amount) + } + + if (isLoading) { + return ( + <div className="flex items-center justify-center py-12"> + <div className="text-center"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div> + <p className="text-muted-foreground">입찰 정보를 불러오는 중...</p> + </div> + </div> + ) + } + + if (!biddingDetail) { + return ( + <div className="text-center py-12"> + <p className="text-muted-foreground">입찰 정보를 찾을 수 없습니다.</p> + <Button onClick={() => router.back()} className="mt-4"> + <ArrowLeft className="w-4 h-4 mr-2" /> + 돌아가기 + </Button> + </div> + ) + } + + return ( + <div className="space-y-6"> + {/* 헤더 */} + <div className="flex items-center justify-between"> + <div className="flex items-center gap-4"> + <Button variant="outline" onClick={() => router.back()}> + <ArrowLeft className="w-4 h-4 mr-2" /> + 목록으로 + </Button> + <div> + <h1 className="text-2xl font-semibold">{biddingDetail.title}</h1> + <div className="flex items-center gap-2 mt-1"> + <Badge variant="outline" className="font-mono"> + {biddingDetail.biddingNumber} + {biddingDetail.revision > 0 && ` Rev.${biddingDetail.revision}`} + </Badge> + <Badge variant={ + biddingDetail.status === 'bidding_disposal' ? 'destructive' : + biddingDetail.status === 'vendor_selected' ? 'default' : + 'secondary' + }> + {biddingStatusLabels[biddingDetail.status]} + </Badge> + </div> + </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> + + {/* 입찰 공고 섹션 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <FileText className="w-5 h-5" /> + 입찰 공고 + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div> + <Label className="text-sm font-medium text-muted-foreground">프로젝트</Label> + <div className="flex items-center gap-2 mt-1"> + <Building2 className="w-4 h-4" /> + <span>{biddingDetail.projectName}</span> + </div> + </div> + <div> + <Label className="text-sm font-medium text-muted-foreground">품목</Label> + <div className="flex items-center gap-2 mt-1"> + <Package className="w-4 h-4" /> + <span>{biddingDetail.itemName}</span> + </div> + </div> + <div> + <Label className="text-sm font-medium text-muted-foreground">계약구분</Label> + <div className="mt-1">{contractTypeLabels[biddingDetail.contractType]}</div> + </div> + <div> + <Label className="text-sm font-medium text-muted-foreground">입찰유형</Label> + <div className="mt-1">{biddingTypeLabels[biddingDetail.biddingType]}</div> + </div> + <div> + <Label className="text-sm font-medium text-muted-foreground">낙찰수</Label> + <div className="mt-1">{biddingDetail.awardCount === 'single' ? '단수' : '복수'}</div> + </div> + <div> + <Label className="text-sm font-medium text-muted-foreground">담당자</Label> + <div className="flex items-center gap-2 mt-1"> + <User className="w-4 h-4" /> + <span>{biddingDetail.managerName}</span> + </div> + </div> + </div> + + {biddingDetail.budget && ( + <div> + <Label className="text-sm font-medium text-muted-foreground">예산</Label> + <div className="flex items-center gap-2 mt-1"> + <DollarSign className="w-4 h-4" /> + <span className="font-semibold">{formatCurrency(biddingDetail.budget)}</span> + </div> + </div> + )} + + {/* 일정 정보 */} + <div className="pt-4 border-t"> + <Label className="text-sm font-medium text-muted-foreground mb-2 block">일정 정보</Label> + <div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm"> + {biddingDetail.submissionStartDate && biddingDetail.submissionEndDate && ( + <div> + <span className="font-medium">제출기간:</span> {formatDate(biddingDetail.submissionStartDate, 'KR')} ~ {formatDate(biddingDetail.submissionEndDate, 'KR')} + </div> + )} + {biddingDetail.evaluationDate && ( + <div> + <span className="font-medium">평가일:</span> {formatDate(biddingDetail.evaluationDate, 'KR')} + </div> + )} + </div> + </div> + </CardContent> + </Card> + + {/* 제시된 조건 섹션 */} + <Card> + <CardHeader> + <CardTitle>제시된 입찰 조건</CardTitle> + </CardHeader> + <CardContent> + <div className="space-y-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(', ') : + '정보 없음'} + </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(', ') : + '정보 없음'} + </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(', ') : + '정보 없음'} + </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> + )} + </div> + </CardContent> + </Card> + + {/* 응찰 폼 섹션 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Send className="w-5 h-5" /> + 응찰하기 + </CardTitle> + </CardHeader> + <CardContent className="space-y-6"> + <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> + <div className="space-y-2"> + <Label htmlFor="finalQuoteAmount">견적금액 *</Label> + <Input + id="finalQuoteAmount" + type="number" + value={responseData.finalQuoteAmount} + onChange={(e) => setResponseData({...responseData, finalQuoteAmount: e.target.value})} + placeholder="견적금액을 입력하세요" + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="proposedContractDeliveryDate">제안 납품일</Label> + <Input + id="proposedContractDeliveryDate" + type="date" + value={responseData.proposedContractDeliveryDate} + onChange={(e) => setResponseData({...responseData, proposedContractDeliveryDate: e.target.value})} + /> + </div> + </div> + + <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> + <div className="space-y-2"> + <Label htmlFor="paymentTermsResponse">응답 지급조건</Label> + <Input + id="paymentTermsResponse" + value={responseData.paymentTermsResponse} + onChange={(e) => setResponseData({...responseData, paymentTermsResponse: e.target.value})} + placeholder="지급조건에 대한 의견을 입력하세요" + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="taxConditionsResponse">응답 세금조건</Label> + <Input + id="taxConditionsResponse" + value={responseData.taxConditionsResponse} + onChange={(e) => setResponseData({...responseData, taxConditionsResponse: e.target.value})} + placeholder="세금조건에 대한 의견을 입력하세요" + /> + </div> + </div> + + <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> + <div className="space-y-2"> + <Label htmlFor="incotermsResponse">응답 운송조건</Label> + <Input + id="incotermsResponse" + value={responseData.incotermsResponse} + onChange={(e) => setResponseData({...responseData, incotermsResponse: e.target.value})} + placeholder="운송조건에 대한 의견을 입력하세요" + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="proposedShippingPort">제안 선적지</Label> + <Input + id="proposedShippingPort" + value={responseData.proposedShippingPort} + onChange={(e) => setResponseData({...responseData, proposedShippingPort: e.target.value})} + placeholder="선적지를 입력하세요" + /> + </div> + </div> + + <div className="space-y-2"> + <Label htmlFor="additionalProposals">추가 제안사항</Label> + <Textarea + id="additionalProposals" + value={responseData.additionalProposals} + onChange={(e) => setResponseData({...responseData, additionalProposals: e.target.value})} + placeholder="추가 제안사항을 입력하세요" + rows={4} + /> + </div> + + <div className="flex items-center space-x-2"> + <Checkbox + id="priceAdjustmentResponse" + checked={responseData.priceAdjustmentResponse} + onCheckedChange={(checked) => + setResponseData({...responseData, priceAdjustmentResponse: !!checked}) + } + /> + <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" /> + 응찰 제출 + </Button> + </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 new file mode 100644 index 00000000..b54ca967 --- /dev/null +++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx @@ -0,0 +1,260 @@ +'use client' + +import * as React from 'react' +import { createColumnHelper } from '@tanstack/react-table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { + CheckCircle, + XCircle, + Users, + Eye, + MoreHorizontal, + Calendar, + User +} from 'lucide-react' +import { formatDate } from '@/lib/utils' +import { biddingStatusLabels, contractTypeLabels } from '@/db/schema' +import { PartnersBiddingListItem } from '../detail/service' + +const columnHelper = createColumnHelper<PartnersBiddingListItem>() + +interface PartnersBiddingListColumnsProps { + setRowAction?: (action: { type: string; row: { original: PartnersBiddingListItem } }) => void +} + +export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingListColumnsProps = {}) { + return [ + // 입찰 No. + columnHelper.accessor('biddingNumber', { + header: '입찰 No.', + cell: ({ row }) => { + const biddingNumber = row.original.biddingNumber + const revision = row.original.revision + return ( + <div className="font-mono text-sm"> + <div>{biddingNumber}</div> + {revision > 0 && ( + <div className="text-muted-foreground">Rev.{revision}</div> + )} + </div> + ) + }, + }), + + // 입찰상태 + columnHelper.accessor('status', { + header: '입찰상태', + cell: ({ row }) => { + const status = row.original.status + return ( + <Badge variant={ + status === 'bidding_disposal' ? 'destructive' : + status === 'vendor_selected' ? 'default' : + status === 'bidding_generated' ? 'secondary' : + 'outline' + }> + {biddingStatusLabels[status] || status} + </Badge> + ) + }, + }), + + // 상세 (액션 버튼) + columnHelper.display({ + id: 'actions', + header: '상세', + cell: ({ row }) => { + const handleView = () => { + if (setRowAction) { + setRowAction({ + type: 'view', + row: { original: row.original } + }) + } + } + + return ( + <Button + variant="outline" + size="sm" + onClick={handleView} + className="h-8 w-8 p-0" + > + <Eye className="h-4 w-4" /> + </Button> + ) + }, + }), + + // 품목명 + columnHelper.accessor('itemName', { + header: '품목명', + cell: ({ row }) => ( + <div className="max-w-32 truncate" title={row.original.itemName}> + {row.original.itemName} + </div> + ), + }), + + // 입찰명 + columnHelper.accessor('title', { + header: '입찰명', + cell: ({ row }) => ( + <div className="max-w-48 truncate" title={row.original.title}> + {row.original.title} + </div> + ), + }), + + // 사양설명회 + columnHelper.accessor('isAttendingMeeting', { + header: '사양설명회', + cell: ({ row }) => { + const isAttending = row.original.isAttendingMeeting + if (isAttending === null) { + return <div className="text-muted-foreground text-center">-</div> + } + return isAttending ? ( + <CheckCircle className="h-5 w-5 text-green-600 mx-auto" /> + ) : ( + <XCircle className="h-5 w-5 text-red-600 mx-auto" /> + ) + }, + }), + + // 입찰 참여의사 + columnHelper.accessor('invitationStatus', { + header: '입찰 참여의사', + cell: ({ row }) => { + const status = row.original.invitationStatus + const statusLabels = { + sent: '초대됨', + submitted: '참여', + declined: '불참', + pending: '대기중' + } + return ( + <Badge variant={ + status === 'submitted' ? 'default' : + status === 'declined' ? 'destructive' : + status === 'sent' ? 'secondary' : + 'outline' + }> + {statusLabels[status as keyof typeof statusLabels] || status} + </Badge> + ) + }, + }), + + // 계약구분 + columnHelper.accessor('contractType', { + header: '계약구분', + cell: ({ row }) => ( + <div>{contractTypeLabels[row.original.contractType] || row.original.contractType}</div> + ), + }), + + // 입찰기간 + columnHelper.accessor('submissionStartDate', { + header: '입찰기간', + cell: ({ row }) => { + const startDate = row.original.submissionStartDate + const endDate = row.original.submissionEndDate + if (!startDate || !endDate) { + return <div className="text-muted-foreground">-</div> + } + return ( + <div className="text-sm"> + <div>{formatDate(startDate, 'KR')}</div> + <div className="text-muted-foreground">~</div> + <div>{formatDate(endDate, 'KR')}</div> + </div> + ) + }, + }), + + // 계약기간 + columnHelper.accessor('contractPeriod', { + header: '계약기간', + cell: ({ row }) => ( + <div className="max-w-24 truncate" title={row.original.contractPeriod || ''}> + {row.original.contractPeriod || '-'} + </div> + ), + }), + + // 참여회신 마감일 + columnHelper.accessor('responseDeadline', { + header: '참여회신 마감일', + cell: ({ row }) => { + const deadline = row.original.responseDeadline + if (!deadline) { + return <div className="text-muted-foreground">-</div> + } + return <div className="text-sm">{formatDate(deadline, 'KR')}</div> + }, + }), + + // 입찰제출일 + columnHelper.accessor('submissionDate', { + header: '입찰제출일', + cell: ({ row }) => { + const date = row.original.submissionDate + if (!date) { + return <div className="text-muted-foreground">-</div> + } + return <div className="text-sm">{formatDate(date, 'KR')}</div> + }, + }), + + // 입찰담당자 + columnHelper.accessor('managerName', { + header: '입찰담당자', + cell: ({ row }) => { + const name = row.original.managerName + const email = row.original.managerEmail + if (!name) { + return <div className="text-muted-foreground">-</div> + } + return ( + <div className="flex items-center gap-1"> + <User className="h-4 w-4" /> + <div> + <div className="text-sm">{name}</div> + {email && ( + <div className="text-xs text-muted-foreground truncate max-w-32" title={email}> + {email} + </div> + )} + </div> + </div> + ) + }, + }), + + // 최종수정일 + columnHelper.accessor('updatedAt', { + header: '최종수정일', + cell: ({ row }) => ( + <div className="text-sm">{formatDate(row.original.updatedAt, 'KR')}</div> + ), + }), + + // 최종수정자 + 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 new file mode 100644 index 00000000..c0356e22 --- /dev/null +++ b/lib/bidding/vendor/partners-bidding-list.tsx @@ -0,0 +1,156 @@ +'use client' + +import * as React from 'react' +import { useRouter } from 'next/navigation' +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from '@/types/table' + +import { useDataTable } from '@/hooks/use-data-table' +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' + +interface PartnersBiddingListProps { + companyId: number +} + +export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) { + const [data, setData] = React.useState<PartnersBiddingListItem[]>([]) + const [pageCount, setPageCount] = React.useState<number>(1) + const [isLoading, setIsLoading] = React.useState(true) + const [rowAction, setRowAction] = React.useState<DataTableRowAction<PartnersBiddingListItem> | null>(null) + + const router = useRouter() + + // 데이터 로드 + React.useEffect(() => { + const loadData = async () => { + try { + setIsLoading(true) + const result = await getBiddingListForPartners(companyId) + setData(result) + setPageCount(1) // 클라이언트 사이드 페이징이므로 1로 설정 + } catch (error) { + console.error('Failed to load bidding list:', error) + setData([]) + } finally { + setIsLoading(false) + } + } + + loadData() + }, [companyId]) + + // rowAction 변경 감지하여 해당 페이지로 이동 + React.useEffect(() => { + if (rowAction) { + switch (rowAction.type) { + case 'view': + // 상세 페이지로 이동 (biddingId 사용) + router.push(`/partners/bid/${rowAction.row.original.biddingId}`) + break + default: + break + } + } + }, [rowAction, router]) + + const columns = React.useMemo( + () => getPartnersBiddingListColumns({ setRowAction }), + [setRowAction] + ) + + const filterFields: DataTableFilterField<PartnersBiddingListItem>[] = [ + { + id: 'title', + label: '입찰명', + placeholder: '입찰명으로 검색...', + }, + { + id: 'biddingNumber', + label: '입찰번호', + placeholder: '입찰번호로 검색...', + }, + { + id: 'itemName', + label: '품목명', + placeholder: '품목명으로 검색...', + }, + { + id: 'projectName', + label: '프로젝트명', + placeholder: '프로젝트명으로 검색...', + }, + { + id: 'managerName', + label: '담당자', + placeholder: '담당자로 검색...', + }, + { + id: 'invitationStatus', + label: '참여의사', + placeholder: '참여의사로 필터링...', + }, + { + id: 'status', + label: '입찰상태', + placeholder: '입찰상태로 필터링...', + }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField<PartnersBiddingListItem>[] = [ + { id: 'title', label: '입찰명', type: 'text' }, + { id: 'biddingNumber', label: '입찰번호', type: 'text' }, + { id: 'itemName', label: '품목명', type: 'text' }, + { id: 'projectName', label: '프로젝트명', type: 'text' }, + { id: 'managerName', label: '담당자', type: 'text' }, + { id: 'contractType', label: '계약구분', type: 'text' }, + { id: 'invitationStatus', label: '참여의사', type: 'text' }, + { id: 'status', label: '입찰상태', type: 'text' }, + { id: 'submissionStartDate', label: '입찰시작일', type: 'date' }, + { id: 'submissionEndDate', label: '입찰마감일', type: 'date' }, + { id: 'responseDeadline', label: '참여회신마감일', type: 'date' }, + { id: 'createdAt', label: '등록일', type: 'date' }, + { id: 'updatedAt', label: '수정일', type: 'date' }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: 'createdAt', desc: true }], + columnPinning: { right: ['actions'] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + if (isLoading) { + return ( + <div className="flex items-center justify-center py-12"> + <div className="text-center"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div> + <p className="text-muted-foreground">입찰 목록을 불러오는 중...</p> + </div> + </div> + ) + } + + return ( + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + /> + </DataTable> + ) +} |
