summaryrefslogtreecommitdiff
path: root/lib/bidding/vendor
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-11-27 09:43:55 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-11-27 09:43:55 +0000
commitdaabc02e9ae54f216ada77aa826b349f37c3281a (patch)
tree74c6f94e0c66ee31dfeac2f355c5156431cd42e3 /lib/bidding/vendor
parent5870b73785715d1585531e655c06d8c068eb64ac (diff)
(최겸) 구매 입찰 피드백 반영(80%완)
Diffstat (limited to 'lib/bidding/vendor')
-rw-r--r--lib/bidding/vendor/components/pr-items-pricing-table.tsx140
-rw-r--r--lib/bidding/vendor/partners-bidding-detail.tsx205
-rw-r--r--lib/bidding/vendor/partners-bidding-list-columns.tsx52
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>
+ )
+ },
}),
// 사양설명회