summaryrefslogtreecommitdiff
path: root/lib/bidding/vendor
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-08-27 12:06:26 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-08-27 12:06:26 +0000
commit7548e2ad6948f1c6aa102fcac408bc6c9c0f9796 (patch)
tree8e66703ec821888ad51dcc242a508813a027bf71 /lib/bidding/vendor
parent7eac558470ef179dad626a8e82db5784fe86a556 (diff)
(대표님, 최겸) 기본계약, 입찰, 파일라우트, 계약서명라우트, 인포메이션, 메뉴설정, PQ(메일템플릿 관련)
Diffstat (limited to 'lib/bidding/vendor')
-rw-r--r--lib/bidding/vendor/partners-bidding-attendance-dialog.tsx252
-rw-r--r--lib/bidding/vendor/partners-bidding-detail.tsx562
-rw-r--r--lib/bidding/vendor/partners-bidding-list-columns.tsx260
-rw-r--r--lib/bidding/vendor/partners-bidding-list.tsx156
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>
+ )
+}