diff options
Diffstat (limited to 'lib/bidding/vendor')
| -rw-r--r-- | lib/bidding/vendor/components/pr-items-pricing-table.tsx | 140 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-detail.tsx | 205 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-list-columns.tsx | 52 |
3 files changed, 241 insertions, 156 deletions
diff --git a/lib/bidding/vendor/components/pr-items-pricing-table.tsx b/lib/bidding/vendor/components/pr-items-pricing-table.tsx index a0230478..7dd8384e 100644 --- a/lib/bidding/vendor/components/pr-items-pricing-table.tsx +++ b/lib/bidding/vendor/components/pr-items-pricing-table.tsx @@ -42,7 +42,7 @@ interface PrItem { materialGroupInfo: string | null materialNumber: string | null materialInfo: string | null - requestedDeliveryDate: Date | null + requestedDeliveryDate: Date | string | null annualUnitPrice: string | null currency: string | null quantity: string | null @@ -54,6 +54,11 @@ interface PrItem { materialWeight: string | null prNumber: string | null hasSpecDocument: boolean | null + specification: string | null + bidUnitPrice?: string | number | null + bidAmount?: string | number | null + proposedDeliveryDate?: string | Date | null + technicalSpecification?: string | null } interface PrItemQuotation { @@ -189,6 +194,18 @@ export function PrItemsPricingTable({ if (existing) { return existing } + + // prItems 자체에 견적 정보가 있는 경우 활용 + if (item.bidUnitPrice !== undefined || item.bidAmount !== undefined) { + return { + prItemId: item.id, + bidUnitPrice: item.bidUnitPrice ? Number(item.bidUnitPrice) : 0, + bidAmount: item.bidAmount ? Number(item.bidAmount) : 0, + proposedDeliveryDate: item.proposedDeliveryDate ? (item.proposedDeliveryDate instanceof Date ? item.proposedDeliveryDate.toISOString().split('T')[0] : String(item.proposedDeliveryDate)) : '', + technicalSpecification: item.technicalSpecification || '' + } + } + return { prItemId: item.id, bidUnitPrice: 0, @@ -288,22 +305,22 @@ export function PrItemsPricingTable({ <Table> <TableHeader> <TableRow> - <TableHead>아이템번호</TableHead> - <TableHead>PR번호</TableHead> - <TableHead>품목정보</TableHead> - <TableHead>자재내역</TableHead> + <TableHead>자재번호</TableHead> + <TableHead>자재명</TableHead> + <TableHead>SHI 납품예정일</TableHead> + <TableHead>업체 납품예정일</TableHead> <TableHead>수량</TableHead> - <TableHead>단위</TableHead> + <TableHead>구매단위</TableHead> <TableHead>가격단위</TableHead> - <TableHead>중량</TableHead> - <TableHead>중량단위</TableHead> <TableHead>구매단위</TableHead> - <TableHead>SHI 납품요청일</TableHead> + <TableHead>총중량</TableHead> + <TableHead>중량단위</TableHead> <TableHead>입찰단가</TableHead> <TableHead>입찰금액</TableHead> - <TableHead>납품예정일</TableHead> - {/* <TableHead>기술사양</TableHead> */} - <TableHead>SPEC</TableHead> + <TableHead>업체 통화</TableHead> + <TableHead>자재내역상세</TableHead> + <TableHead>스팩</TableHead> + <TableHead>P/R번호</TableHead> </TableRow> </TableHeader> <TableBody> @@ -318,35 +335,46 @@ export function PrItemsPricingTable({ return ( <TableRow key={item.id}> - <TableCell className="font-medium"> - {item.itemNumber || '-'} - </TableCell> - <TableCell>{item.prNumber || '-'}</TableCell> <TableCell> - <div className="max-w-32 truncate" title={item.itemInfo || ''}> - {item.itemInfo || '-'} - </div> + {item.materialNumber || '-'} </TableCell> <TableCell> <div className="max-w-32 truncate" title={item.materialInfo || ''}> {item.materialInfo || '-'} </div> </TableCell> + <TableCell> + {item.requestedDeliveryDate ? + formatDate(new Date(item.requestedDeliveryDate), 'KR') : '-' + } + </TableCell> + <TableCell> + {readOnly ? ( + quotation.proposedDeliveryDate ? + formatDate(quotation.proposedDeliveryDate, 'KR') : '-' + ) : ( + <Input + type="date" + value={quotation.proposedDeliveryDate} + onChange={(e) => updateQuotation( + item.id, + 'proposedDeliveryDate', + e.target.value + )} + className="w-40" + /> + )} + </TableCell> <TableCell className="text-right"> {item.quantity ? parseFloat(item.quantity).toLocaleString() : '-'} </TableCell> <TableCell>{item.quantityUnit || '-'}</TableCell> <TableCell>{item.priceUnit || '-'}</TableCell> + <TableCell>{item.purchaseUnit || '-'}</TableCell> <TableCell className="text-right"> {item.totalWeight ? parseFloat(item.totalWeight).toLocaleString() : '-'} </TableCell> <TableCell>{item.weightUnit || '-'}</TableCell> - <TableCell>{item.purchaseUnit || '-'}</TableCell> - <TableCell> - {item.requestedDeliveryDate ? - formatDate(item.requestedDeliveryDate, 'KR') : '-' - } - </TableCell> <TableCell> {readOnly ? ( <span className="font-medium"> @@ -355,12 +383,23 @@ export function PrItemsPricingTable({ ) : ( <Input type="number" - value={quotation.bidUnitPrice} - onChange={(e) => updateQuotation( - item.id, - 'bidUnitPrice', - parseFloat(e.target.value) || 0 - )} + inputMode="decimal" + min={0} + pattern="^(0|[1-9][0-9]*)(\.[0-9]+)?$" + value={quotation.bidUnitPrice === 0 ? '' : quotation.bidUnitPrice} + onChange={(e) => { + let value = e.target.value + if (/^0[0-9]+/.test(value)) { + value = value.replace(/^0+/, '') + if (value === '') value = '0' + } + const numericValue = parseFloat(value) + updateQuotation( + item.id, + 'bidUnitPrice', + isNaN(numericValue) ? 0 : numericValue + ) + }} className="w-32 text-right" placeholder="단가" /> @@ -371,42 +410,12 @@ export function PrItemsPricingTable({ {formatCurrency(quotation.bidAmount)} </div> </TableCell> + <TableCell>{currency}</TableCell> <TableCell> - {readOnly ? ( - quotation.proposedDeliveryDate ? - formatDate(quotation.proposedDeliveryDate, 'KR') : '-' - ) : ( - <Input - type="date" - value={quotation.proposedDeliveryDate} - onChange={(e) => updateQuotation( - item.id, - 'proposedDeliveryDate', - e.target.value - )} - className="w-40" - /> - )} + <div className="max-w-48 truncate" title={item.specification || ''}> + {item.specification || '-'} + </div> </TableCell> - {/* <TableCell> - {readOnly ? ( - <div className="max-w-32 truncate" title={quotation.technicalSpecification || ''}> - {quotation.technicalSpecification || '-'} - </div> - ) : ( - <Textarea - value={quotation.technicalSpecification} - onChange={(e) => updateQuotation( - item.id, - 'technicalSpecification', - e.target.value - )} - placeholder="기술사양 입력" - className="w-48 min-h-[60px]" - rows={2} - /> - )} - </TableCell> */} <TableCell> {item.hasSpecDocument ? ( <div className="space-y-1"> @@ -435,6 +444,7 @@ export function PrItemsPricingTable({ <Badge variant="outline">SPEC 없음</Badge> )} </TableCell> + <TableCell>{item.prNumber || '-'}</TableCell> </TableRow> ) })} diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx index 03429cca..bf76de62 100644 --- a/lib/bidding/vendor/partners-bidding-detail.tsx +++ b/lib/bidding/vendor/partners-bidding-detail.tsx @@ -81,6 +81,7 @@ interface BiddingDetail { targetPrice: number | null status: string bidPicName: string | null // 입찰담당자 + bidPicPhone?: string | null // 입찰담당자 전화번호 supplyPicName: string | null // 조달담당자 biddingCompanyId: number biddingId: number @@ -122,6 +123,11 @@ interface PrItem { materialWeight: string | null prNumber: string | null hasSpecDocument: boolean | null + specification: string | null + bidUnitPrice?: string | number | null + bidAmount?: string | number | null + proposedDeliveryDate?: string | Date | null + technicalSpecification?: string | null } interface BiddingPrItemQuotation { @@ -239,7 +245,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD console.error('Failed to get bidding details:', error) return null }), - getPrItemsForBidding(biddingId).catch(error => { + getPrItemsForBidding(biddingId, companyId).catch(error => { console.error('Failed to get PR items:', error) return [] }), @@ -302,50 +308,33 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD // PR 아이템 설정 setPrItems(prItemsResult) + // PR 아이템 결과로부터 견적 정보 추출 및 설정 + if (Array.isArray(prItemsResult) && prItemsResult.length > 0) { + const initialQuotations = prItemsResult.map((item: any) => ({ + prItemId: item.id, + bidUnitPrice: item.bidUnitPrice ? Number(item.bidUnitPrice) : 0, + bidAmount: item.bidAmount ? Number(item.bidAmount) : 0, + proposedDeliveryDate: item.proposedDeliveryDate ? (item.proposedDeliveryDate instanceof Date ? item.proposedDeliveryDate.toISOString().split('T')[0] : item.proposedDeliveryDate) : undefined, + technicalSpecification: item.technicalSpecification || undefined + })); + setPrItemQuotations(initialQuotations); + + // 총 금액 계산 + const total = initialQuotations.reduce((sum: number, q: any) => sum + q.bidAmount, 0); + setTotalQuotationAmount(total); + + // 응찰 확정 시 총 금액 설정 + if (total > 0 && result?.isBiddingParticipated === true) { + setResponseData(prev => ({ + ...prev, + finalQuoteAmount: total.toString() + })); + } + } + // 입찰 데이터를 본입찰용으로 로드 (응찰 확정 시 또는 입찰이 있는 경우) if (result?.biddingCompanyId) { try { - // 입찰 데이터를 가져와서 본입찰용으로 변환 - const preQuoteData = await getPartnerBiddingItemQuotations(result.biddingCompanyId) - - if (preQuoteData && Array.isArray(preQuoteData) && preQuoteData.length > 0) { - console.log('입찰 데이터:', preQuoteData) - - // 입찰 데이터를 본입찰 포맷으로 변환 - const convertedQuotations = preQuoteData - .filter(item => item && typeof item === 'object' && item.prItemId) - .map(item => ({ - prItemId: item.prItemId, - bidUnitPrice: item.bidUnitPrice, - bidAmount: item.bidAmount, - proposedDeliveryDate: item.proposedDeliveryDate || undefined, - technicalSpecification: item.technicalSpecification || undefined - })) - - console.log('변환된 입찰 데이터:', convertedQuotations) - - if (Array.isArray(convertedQuotations) && convertedQuotations.length > 0) { - setPrItemQuotations(convertedQuotations) - - // 총 금액 계산 - const total = convertedQuotations.reduce((sum, q) => { - const amount = Number(q.bidAmount) || 0 - return sum + amount - }, 0) - setTotalQuotationAmount(total) - console.log('계산된 총 금액:', total) - - // 응찰 확정 시에만 입찰 금액을 finalQuoteAmount로 설정 - if (total > 0 && result?.isBiddingParticipated === true) { - console.log('응찰 확정됨, 입찰 금액 설정:', total) - console.log('입찰 금액을 finalQuoteAmount로 설정:', total) - setResponseData(prev => ({ - ...prev, - finalQuoteAmount: total.toString() - })) - } - } - } // 연동제 데이터 로드 (입찰에서 답변했으면 로드, 아니면 입찰 조건 확인) if (result.priceAdjustmentResponse !== null) { @@ -833,9 +822,16 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD </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.bidPicName || '미설정'}</span> + <div className="flex flex-col mt-1"> + <div className="flex items-center gap-2"> + <User className="w-4 h-4" /> + <span>{biddingDetail.bidPicName || '미설정'}</span> + </div> + {biddingDetail.bidPicPhone && ( + <div className="text-xs text-muted-foreground ml-6"> + {biddingDetail.bidPicPhone} + </div> + )} </div> </div> <div> @@ -868,11 +864,12 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD <Label className="text-sm font-medium text-muted-foreground mb-2 block">제출 마감 정보</Label> {(() => { const now = new Date() - const deadline = new Date(biddingDetail.submissionEndDate.toISOString().slice(0, 16).replace('T', ' ')) + const deadline = new Date(biddingDetail.submissionEndDate) // isExpired 상태 사용 const timeLeft = deadline.getTime() - now.getTime() const daysLeft = Math.floor(timeLeft / (1000 * 60 * 60 * 24)) const hoursLeft = Math.floor((timeLeft % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) + const kstDeadline = new Date(deadline.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ') return ( <div className={`p-3 rounded-lg border-2 ${ @@ -887,7 +884,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD <Calendar className="w-5 h-5" /> <span className="font-medium">제출 마감일:</span> <span className="text-lg font-semibold"> - {biddingDetail.submissionEndDate.toISOString().slice(0, 16).replace('T', ' ')} + {kstDeadline} </span> </div> {isExpired ? ( @@ -921,7 +918,13 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD <div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm"> {biddingDetail.submissionStartDate && biddingDetail.submissionEndDate && ( <div> - <span className="font-medium">입찰서 제출기간:</span> {new Date(biddingDetail.submissionStartDate).toISOString().slice(0, 16).replace('T', ' ')} ~ {new Date(biddingDetail.submissionEndDate).toISOString().slice(0, 16).replace('T', ' ')} + <span className="font-medium">입찰서 제출기간:</span> {(() => { + const start = new Date(biddingDetail.submissionStartDate!) + const end = new Date(biddingDetail.submissionEndDate!) + const kstStart = new Date(start.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ') + const kstEnd = new Date(end.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ') + return `${kstStart} ~ ${kstEnd}` + })()} </div> )} {biddingDetail.evaluationDate && ( @@ -1080,45 +1083,75 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD </CardContent> </Card> ) : biddingDetail.isBiddingParticipated === null ? ( - /* 참여 의사 확인 섹션 */ - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - <Users className="w-5 h-5" /> - 입찰 참여 의사 확인 - </CardTitle> - </CardHeader> - <CardContent> - <div className="text-center py-8"> - <div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4"> - <Users className="w-8 h-8 text-primary" /> - </div> - <h3 className="text-lg font-semibold mb-2">이 입찰에 참여하시겠습니까?</h3> - <p className="text-muted-foreground mb-6"> - 참여를 선택하시면 입찰 작성 및 제출이 가능합니다. - </p> - <div className="flex justify-center gap-4"> - <Button - onClick={() => handleParticipationDecision(true)} - disabled={isUpdatingParticipation || isExpired} - className="min-w-[120px]" - > - <CheckCircle className="w-4 h-4 mr-2" /> - {isExpired ? '마감됨' : '참여하기'} - </Button> - <Button - onClick={() => handleParticipationDecision(false)} - disabled={isUpdatingParticipation || isExpired} - variant="destructive" - className="min-w-[120px]" - > - <XCircle className="w-4 h-4 mr-2" /> - 미참여 - </Button> + <> + {/* 품목 정보 확인 (Read Only) */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Package className="w-5 h-5" /> + 입찰 품목 정보 + </CardTitle> + </CardHeader> + <CardContent> + {prItems.length > 0 ? ( + <PrItemsPricingTable + prItems={prItems} + initialQuotations={prItemQuotations} + currency={biddingDetail?.currency || 'KRW'} + onQuotationsChange={() => {}} + onTotalAmountChange={() => {}} + readOnly={true} + /> + ) : ( + <div className="border rounded-lg p-4 bg-muted/20"> + <p className="text-sm text-muted-foreground text-center py-4"> + 등록된 품목이 없습니다. + </p> + </div> + )} + </CardContent> + </Card> + + {/* 참여 의사 확인 섹션 */} + <Card className="mt-6"> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Users className="w-5 h-5" /> + 입찰 참여 의사 확인 + </CardTitle> + </CardHeader> + <CardContent> + <div className="text-center py-8"> + <div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4"> + <Users className="w-8 h-8 text-primary" /> + </div> + <h3 className="text-lg font-semibold mb-2">이 입찰에 참여하시겠습니까?</h3> + <p className="text-muted-foreground mb-6"> + 참여를 선택하시면 입찰 작성 및 제출이 가능합니다. + </p> + <div className="flex justify-center gap-4"> + <Button + onClick={() => handleParticipationDecision(true)} + disabled={isUpdatingParticipation || isExpired} + className="min-w-[120px]" + > + <CheckCircle className="w-4 h-4 mr-2" /> + {isExpired ? '마감됨' : '참여하기'} + </Button> + <Button + onClick={() => handleParticipationDecision(false)} + disabled={isUpdatingParticipation || isExpired} + variant="destructive" + className="min-w-[120px]" + > + <XCircle className="w-4 h-4 mr-2" /> + 미참여 + </Button> + </div> </div> - </div> - </CardContent> - </Card> + </CardContent> + </Card> + </> ) : biddingDetail.isBiddingParticipated === true ? ( /* 응찰 폼 섹션 */ <Card> diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx index a090c3fe..64b4bebf 100644 --- a/lib/bidding/vendor/partners-bidding-list-columns.tsx +++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx @@ -230,11 +230,53 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL // 입찰명 columnHelper.accessor('title', { header: '입찰명', - cell: ({ row }) => ( - <div className="max-w-48 truncate" title={row.original.title}> - {row.original.title} - </div> - ), + cell: ({ row }) => { + const handleTitleClick = (e: React.MouseEvent) => { + e.stopPropagation() + + // 사양설명회 참석여부 체크 + const hasSpecMeeting = row.original.hasSpecificationMeeting + const isAttending = row.original.isAttendingMeeting + + // 사양설명회가 있고, 참석여부가 아직 설정되지 않은 경우 + if (hasSpecMeeting && isAttending === null) { + toast.warning('사양설명회 참석여부 필요', { + description: '사전견적 또는 입찰을 진행하기 전에 사양설명회 참석여부를 먼저 설정해주세요.', + duration: 5000, + }) + return + } + + // 입찰기간 체크 (현 시간 기준으로 입찰기간 시작 전이면 접근 불가) + const now = new Date() + const startDate = row.original.submissionStartDate ? new Date(row.original.submissionStartDate) : null + + if (startDate && now < startDate) { + toast.warning('입찰기간 전 접근 제한', { + description: `입찰기간이 아직 시작되지 않았습니다`, + duration: 5000, + }) + return + } + + if (setRowAction) { + setRowAction({ + type: 'view', + row: { original: row.original } + }) + } + } + + return ( + <div + className="max-w-48 truncate cursor-pointer underline font-bold hover:text-blue-600" + title={row.original.title} + onClick={handleTitleClick} + > + {row.original.title} + </div> + ) + }, }), // 사양설명회 |
