summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/bidding/actions.ts3
-rw-r--r--lib/bidding/detail/service.ts7
-rw-r--r--lib/bidding/list/biddings-table-columns.tsx11
-rw-r--r--lib/bidding/pre-quote/service.ts87
-rw-r--r--lib/bidding/receive/biddings-receive-columns.tsx12
-rw-r--r--lib/bidding/receive/biddings-receive-table.tsx13
-rw-r--r--lib/bidding/selection/biddings-selection-columns.tsx9
-rw-r--r--lib/bidding/service.ts87
-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
-rw-r--r--lib/soap/ecc/mapper/bidding-and-pr-mapper.ts50
12 files changed, 440 insertions, 236 deletions
diff --git a/lib/bidding/actions.ts b/lib/bidding/actions.ts
index 0bf2af57..02501b27 100644
--- a/lib/bidding/actions.ts
+++ b/lib/bidding/actions.ts
@@ -683,7 +683,8 @@ export async function openBiddingAction(biddingId: number) {
}
const now = new Date()
- const isDeadlinePassed = bidding.submissionEndDate && now > bidding.submissionEndDate
+ const submissionEndDate = bidding.submissionEndDate ? new Date(bidding.submissionEndDate) : null
+ const isDeadlinePassed = submissionEndDate && now > submissionEndDate
// 2. 개찰 가능 여부 확인
if (!isDeadlinePassed) {
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts
index c9aaa66c..0b68eaa7 100644
--- a/lib/bidding/detail/service.ts
+++ b/lib/bidding/detail/service.ts
@@ -36,7 +36,7 @@ export interface BiddingDetailData {
}
// getBiddingById 함수 임포트 (기존 함수 재사용)
-import { getBiddingById } from '@/lib/bidding/service'
+import { getBiddingById, updateBiddingProjectInfo } from '@/lib/bidding/service'
// Promise.all을 사용하여 모든 데이터를 병렬로 조회 (캐시 적용)
export async function getBiddingDetailData(biddingId: number): Promise<BiddingDetailData> {
@@ -1375,6 +1375,9 @@ export async function updatePrItem(prItemId: number, input: Partial<typeof prIte
// PR 아이템 금액 합산하여 bidding 업데이트
await updateBiddingAmounts(biddingId)
+ // 프로젝트 정보 업데이트
+ await updateBiddingProjectInfo(biddingId)
+
// 캐시 무효화
revalidateTag(`bidding-${biddingId}`)
revalidateTag('pr-items')
@@ -1759,6 +1762,7 @@ export async function getBiddingDetailsForPartners(biddingId: number, companyId:
status: biddings.status,
isUrgent: biddings.isUrgent,
bidPicName: biddings.bidPicName,
+ bidPicPhone: users.phone,
supplyPicName: biddings.supplyPicName,
// 협력업체 특정 정보
@@ -1788,6 +1792,7 @@ export async function getBiddingDetailsForPartners(biddingId: number, companyId:
responseSubmittedAt: companyConditionResponses.submittedAt,
})
.from(biddings)
+ .leftJoin(users, eq(biddings.bidPicId, users.id))
.innerJoin(biddingCompanies, eq(biddings.id, biddingCompanies.biddingId))
.leftJoin(companyConditionResponses, eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId))
.where(and(
diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx
index 36abd03c..907115b1 100644
--- a/lib/bidding/list/biddings-table-columns.tsx
+++ b/lib/bidding/list/biddings-table-columns.tsx
@@ -256,10 +256,15 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
- const now = new Date()
- const isActive = now >= new Date(startDate) && now <= new Date(endDate)
- const isPast = now > new Date(endDate)
+ const now = new Date().toString()
+ console.log(now, "now")
+ const startIso = new Date(startDate).toISOString()
+ const endIso = new Date(endDate).toISOString()
+ const isActive = new Date(now) >= new Date(startIso) && new Date(now) <= new Date(endIso)
+ console.log(isActive, "isActive")
+ const isPast = new Date(now) > new Date(endIso)
+ console.log(isPast, "isPast")
return (
<div className="text-xs">
<div className={`${isActive ? 'text-green-600 font-medium' : isPast ? 'text-red-600' : 'text-gray-600'}`}>
diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts
index 1dd06b3c..0f938b24 100644
--- a/lib/bidding/pre-quote/service.ts
+++ b/lib/bidding/pre-quote/service.ts
@@ -840,37 +840,66 @@ export async function setPreQuoteParticipation(
}
// PR 아이템 조회 (입찰에 포함된 품목들)
-export async function getPrItemsForBidding(biddingId: number) {
+export async function getPrItemsForBidding(biddingId: number, companyId?: number) {
try {
- const prItems = await db
- .select({
- id: prItemsForBidding.id,
- biddingId: prItemsForBidding.biddingId,
- itemNumber: prItemsForBidding.itemNumber,
- projectId: prItemsForBidding.projectId,
- projectInfo: prItemsForBidding.projectInfo,
- itemInfo: prItemsForBidding.itemInfo,
- shi: prItemsForBidding.shi,
- materialGroupNumber: prItemsForBidding.materialGroupNumber,
- materialGroupInfo: prItemsForBidding.materialGroupInfo,
- materialNumber: prItemsForBidding.materialNumber,
- materialInfo: prItemsForBidding.materialInfo,
- requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate,
- annualUnitPrice: prItemsForBidding.annualUnitPrice,
- currency: prItemsForBidding.currency,
- quantity: prItemsForBidding.quantity,
- quantityUnit: prItemsForBidding.quantityUnit,
- totalWeight: prItemsForBidding.totalWeight,
- weightUnit: prItemsForBidding.weightUnit,
- priceUnit: prItemsForBidding.priceUnit,
- purchaseUnit: prItemsForBidding.purchaseUnit,
- materialWeight: prItemsForBidding.materialWeight,
- prNumber: prItemsForBidding.prNumber,
- hasSpecDocument: prItemsForBidding.hasSpecDocument,
- })
- .from(prItemsForBidding)
- .where(eq(prItemsForBidding.biddingId, biddingId))
+ const selectFields: any = {
+ id: prItemsForBidding.id,
+ biddingId: prItemsForBidding.biddingId,
+ itemNumber: prItemsForBidding.itemNumber,
+ projectId: prItemsForBidding.projectId,
+ projectInfo: prItemsForBidding.projectInfo,
+ itemInfo: prItemsForBidding.itemInfo,
+ shi: prItemsForBidding.shi,
+ materialGroupNumber: prItemsForBidding.materialGroupNumber,
+ materialGroupInfo: prItemsForBidding.materialGroupInfo,
+ materialNumber: prItemsForBidding.materialNumber,
+ materialInfo: prItemsForBidding.materialInfo,
+ requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate,
+ annualUnitPrice: prItemsForBidding.annualUnitPrice,
+ currency: prItemsForBidding.currency,
+ quantity: prItemsForBidding.quantity,
+ quantityUnit: prItemsForBidding.quantityUnit,
+ totalWeight: prItemsForBidding.totalWeight,
+ weightUnit: prItemsForBidding.weightUnit,
+ priceUnit: prItemsForBidding.priceUnit,
+ purchaseUnit: prItemsForBidding.purchaseUnit,
+ materialWeight: prItemsForBidding.materialWeight,
+ targetUnitPrice: prItemsForBidding.targetUnitPrice,
+ targetAmount: prItemsForBidding.targetAmount,
+ targetCurrency: prItemsForBidding.targetCurrency,
+ budgetAmount: prItemsForBidding.budgetAmount,
+ budgetCurrency: prItemsForBidding.budgetCurrency,
+ actualAmount: prItemsForBidding.actualAmount,
+ actualCurrency: prItemsForBidding.actualCurrency,
+ prNumber: prItemsForBidding.prNumber,
+ hasSpecDocument: prItemsForBidding.hasSpecDocument,
+ specification: prItemsForBidding.specification,
+ }
+
+ if (companyId) {
+ selectFields.bidUnitPrice = companyPrItemBids.bidUnitPrice
+ selectFields.bidAmount = companyPrItemBids.bidAmount
+ selectFields.proposedDeliveryDate = companyPrItemBids.proposedDeliveryDate
+ selectFields.technicalSpecification = companyPrItemBids.technicalSpecification
+ }
+
+ let query = db.select(selectFields).from(prItemsForBidding)
+
+ if (companyId) {
+ query = query
+ .leftJoin(biddingCompanies, and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.companyId, companyId)
+ ))
+ .leftJoin(companyPrItemBids, and(
+ eq(companyPrItemBids.prItemId, prItemsForBidding.id),
+ eq(companyPrItemBids.biddingCompanyId, biddingCompanies.id)
+ )) as any
+ }
+
+ query = query.where(eq(prItemsForBidding.biddingId, biddingId)).orderBy(prItemsForBidding.id) as any
+ const prItems = await query
return prItems
} catch (error) {
console.error('Failed to get PR items for bidding:', error)
diff --git a/lib/bidding/receive/biddings-receive-columns.tsx b/lib/bidding/receive/biddings-receive-columns.tsx
index ab2c0d02..4bde849c 100644
--- a/lib/bidding/receive/biddings-receive-columns.tsx
+++ b/lib/bidding/receive/biddings-receive-columns.tsx
@@ -196,13 +196,19 @@ export function getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): Co
if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
const now = new Date()
- const isActive = now >= new Date(startDate) && now <= new Date(endDate)
- const isPast = now > new Date(endDate)
+ const startObj = new Date(startDate)
+ const endObj = new Date(endDate)
+
+ const isActive = now >= startObj && now <= endObj
+ const isPast = now > endObj
+
+ // UI 표시용 KST 변환
+ const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
return (
<div className="text-xs">
<div className={`${isActive ? 'text-green-600 font-medium' : isPast ? 'text-red-600' : 'text-gray-600'}`}>
- {new Date(startDate).toISOString().slice(0, 16).replace('T', ' ')} ~ {new Date(endDate).toISOString().slice(0, 16).replace('T', ' ')}
+ {formatKst(startObj)} ~ {formatKst(endObj)}
</div>
{isActive && (
<Badge variant="default" className="text-xs mt-1">진행중</Badge>
diff --git a/lib/bidding/receive/biddings-receive-table.tsx b/lib/bidding/receive/biddings-receive-table.tsx
index 97d627ea..5bda921e 100644
--- a/lib/bidding/receive/biddings-receive-table.tsx
+++ b/lib/bidding/receive/biddings-receive-table.tsx
@@ -164,17 +164,6 @@ export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) {
setIsCompact(compact)
}, [])
- // const handleSpecMeetingDialogClose = React.useCallback(() => {
- // setSpecMeetingDialogOpen(false)
- // setRowAction(null)
- // setSelectedBidding(null)
- // }, [])
-
- // const handlePrDocumentsDialogClose = React.useCallback(() => {
- // setPrDocumentsDialogOpen(false)
- // setRowAction(null)
- // setSelectedBidding(null)
- // }, [])
// 선택된 행 가져오기
const selectedRows = table.getFilteredSelectedRowModel().rows
@@ -185,7 +174,7 @@ export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) {
if (!selectedBiddingForAction) return false
const now = new Date()
- const submissionEndDate = selectedBiddingForAction.submissionEndDate
+ const submissionEndDate = selectedBiddingForAction.submissionEndDate ? new Date(selectedBiddingForAction.submissionEndDate) : null
// 1. 입찰 마감일이 지났으면 무조건 가능
if (submissionEndDate && now > submissionEndDate) return true
diff --git a/lib/bidding/selection/biddings-selection-columns.tsx b/lib/bidding/selection/biddings-selection-columns.tsx
index 9efa849b..355d5aaa 100644
--- a/lib/bidding/selection/biddings-selection-columns.tsx
+++ b/lib/bidding/selection/biddings-selection-columns.tsx
@@ -176,13 +176,18 @@ export function getBiddingsSelectionColumns({ setRowAction }: GetColumnsProps):
if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
const now = new Date()
- const isPast = now > new Date(endDate)
+ const startObj = new Date(startDate)
+ const endObj = new Date(endDate)
+ const isPast = now > endObj
const isClosed = isPast
+ // UI 표시용 KST 변환
+ const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
+
return (
<div className="text-xs">
<div className={`${isClosed ? 'text-red-600' : 'text-gray-600'}`}>
- {formatDate(startDate, "KR")} ~ {formatDate(endDate, "KR")}
+ {formatKst(startObj)} ~ {formatKst(endObj)}
</div>
{isClosed && (
<Badge variant="destructive" className="text-xs mt-1">마감</Badge>
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index 489268c6..8fd1d368 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -797,6 +797,22 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
return null
}
}
+
+ // 담당자 정보 준비
+ let bidPicId = input.bidPicId ? parseInt(input.bidPicId.toString()) : null
+ let bidPicName = input.bidPicName || null
+
+ if (!bidPicId && input.bidPicCode) {
+ try {
+ const userInfo = await findUserInfoByEKGRP(input.bidPicCode)
+ if (userInfo) {
+ bidPicId = userInfo.userId
+ bidPicName = userInfo.userName
+ }
+ } catch (e) {
+ console.error('Failed to find user info by EKGRP:', e)
+ }
+ }
// 1. 입찰 생성
const [newBidding] = await tx
@@ -849,8 +865,8 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
purchasingOrganization: input.purchasingOrganization,
// 담당자 정보 (user FK)
- bidPicId: input.bidPicId ? parseInt(input.bidPicId.toString()) : null,
- bidPicName: input.bidPicName || null,
+ bidPicId,
+ bidPicName,
bidPicCode: input.bidPicCode || null,
supplyPicId: input.supplyPicId ? parseInt(input.supplyPicId.toString()) : null,
supplyPicName: input.supplyPicName || null,
@@ -1234,6 +1250,24 @@ export async function updateBidding(input: UpdateBiddingInput, userId: string) {
// 담당자 정보 (user FK)
if (input.bidPicId !== undefined) updateData.bidPicId = input.bidPicId
if (input.bidPicName !== undefined) updateData.bidPicName = input.bidPicName
+
+ // bidPicCode가 있으면 담당자 정보 자동 조회 및 업데이트
+ if (input.bidPicCode !== undefined) {
+ updateData.bidPicCode = input.bidPicCode
+ // bidPicId가 명시적으로 제공되지 않았고 코드가 있는 경우 자동 조회
+ if (!input.bidPicId && input.bidPicCode) {
+ try {
+ const userInfo = await findUserInfoByEKGRP(input.bidPicCode)
+ if (userInfo) {
+ updateData.bidPicId = userInfo.userId
+ updateData.bidPicName = userInfo.userName
+ }
+ } catch (e) {
+ console.error('Failed to find user info by EKGRP:', e)
+ }
+ }
+ }
+
if (input.supplyPicId !== undefined) updateData.supplyPicId = input.supplyPicId
if (input.supplyPicName !== undefined) updateData.supplyPicName = input.supplyPicName
@@ -1927,22 +1961,12 @@ export async function updateBiddingSchedule(
try {
const userName = await getUserNameById(userId)
- // 날짜 문자열을 Date 객체로 수동 변환
+ // 날짜 문자열을 Date 객체로 변환 (KST 기준)
const parseDate = (dateStr?: string) => {
if (!dateStr) return undefined
- // 'YYYY-MM-DDTHH:mm' 또는 'YYYY-MM-DD HH:mm' 등을 허용
- // 잘못된 포맷이면 undefined 반환
- const m = dateStr.match(
- /^(\d{4})-(\d{2})-(\d{2})[T ]?(\d{2}):(\d{2})(?::(\d{2}))?$/
- )
- if (!m) return undefined
- const year = parseInt(m[1], 10)
- const month = parseInt(m[2], 10) - 1 // JS month는 0부터
- const day = parseInt(m[3], 10)
- const hour = parseInt(m[4], 10)
- const min = parseInt(m[5], 10)
- const sec = m[6] ? parseInt(m[6], 10) : 0
- return new Date(Date.UTC(year, month, day, hour, min, sec))
+ // 'YYYY-MM-DDTHH:mm' 형식을 가정하고 KST(+09:00) 오프셋을 붙여서 파싱
+ // 초(:00)를 추가하여 ISO 8601 호환성 확보
+ return new Date(`${dateStr}:00+09:00`)
}
return await db.transaction(async (tx) => {
@@ -2178,6 +2202,34 @@ export async function removeBiddingItem(itemId: number) {
}
}
+// 입찰의 첫 번째 PR 아이템 프로젝트 정보로 bidding 업데이트
+export async function updateBiddingProjectInfo(biddingId: number) {
+ try {
+ const firstItem = await db
+ .select({
+ projectInfo: prItemsForBidding.projectInfo
+ })
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.biddingId, biddingId))
+ .orderBy(prItemsForBidding.id)
+ .limit(1)
+
+ if (firstItem.length > 0 && firstItem[0].projectInfo) {
+ await db
+ .update(biddings)
+ .set({
+ projectName: firstItem[0].projectInfo,
+ updatedAt: new Date()
+ })
+ .where(eq(biddings.id, biddingId))
+
+ console.log(`Bidding ${biddingId} project info updated to: ${firstItem[0].projectInfo}`)
+ }
+ } catch (error) {
+ console.error('Failed to update bidding project info:', error)
+ }
+}
+
// 입찰의 PR 아이템 금액 합산하여 bidding 업데이트
async function updateBiddingAmounts(biddingId: number) {
try {
@@ -2289,6 +2341,9 @@ export async function addPRItemForBidding(
// PR 아이템 금액 합산하여 bidding 업데이트
await updateBiddingAmounts(biddingId)
+ // 프로젝트 정보 업데이트
+ await updateBiddingProjectInfo(biddingId)
+
revalidatePath(`/evcp/bid/${biddingId}/info`)
revalidatePath(`/evcp/bid/${biddingId}`)
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>
+ )
+ },
}),
// 사양설명회
diff --git a/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts b/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts
index 1a5089eb..9bdd238d 100644
--- a/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts
+++ b/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts
@@ -215,6 +215,17 @@ export async function mapECCBiddingHeaderToBidding(
// 첫번째 PR Item 가져오기 (관련 아이템들 중 첫번째)
const firstItem = eccItems.find(item => item.ANFNR === eccHeader.ANFNR);
+
+ // 대표 PR Item 찾기 (ZCON_NO_PO와 BANFN이 같은 아이템 - RFQ 매퍼 로직 반영)
+ const representativeItem = eccItems.find(item =>
+ item.ANFNR === eccHeader.ANFNR &&
+ item.ZCON_NO_PO &&
+ item.ZCON_NO_PO.trim() &&
+ item.BANFN === item.ZCON_NO_PO.trim()
+ );
+
+ // 타겟 아이템 설정 (대표 아이템 우선, 없으면 첫번째 아이템)
+ const targetItem = representativeItem || firstItem;
// 날짜 파싱 (실패시 현재 Date 들어감)
const createdAt = parseSAPDateTime(eccHeader.ZRFQ_TRS_DT || null, eccHeader.ZRFQ_TRS_TM || null);
@@ -222,23 +233,27 @@ export async function mapECCBiddingHeaderToBidding(
// 담당자 찾기
const inChargeUserInfo = await findUserInfoByEKGRP(eccHeader.EKGRP || null);
- // 첫번째 PR Item 기반으로 projectName, itemName 설정
+ // 타겟 PR Item 기반으로 projectName, itemName 설정
let projectName: string | null = null;
let itemName: string | null = null;
let prNumber: string | null = null;
- if (firstItem) {
- // projectName: 첫번째 PR Item의 PSPID와 projects.code 매칭
- const projectInfo = await findProjectInfoByPSPID(firstItem.PSPID || null);
+ if (targetItem) {
+ // projectName: 타겟 PR Item의 PSPID와 projects.code 매칭
+ const projectInfo = await findProjectInfoByPSPID(targetItem.PSPID || null);
if (projectInfo) {
projectName = projectInfo.name;
}
- // itemName: 첫번째 PR Item의 MATNR로 MDG에서 ZZNAME 조회
- itemName = await findMaterialNameByMATNR(firstItem.MATNR || null);
+ // itemName: 타겟 PR Item의 TXZ01(내역) 우선 사용, 없으면 MATNR로 조회
+ if (targetItem.TXZ01) {
+ itemName = targetItem.TXZ01;
+ } else {
+ itemName = await findMaterialNameByMATNR(targetItem.MATNR || null);
+ }
- // prNumber: 첫번째 PR의 ZREQ_FN 값
- prNumber = firstItem.ZREQ_FN || null;
+ // prNumber: 대표 PR의 BANFN 또는 타겟 PR의 ZREQ_FN 값
+ prNumber = representativeItem?.BANFN || targetItem.ZREQ_FN || null;
}
// 매핑
@@ -246,9 +261,9 @@ export async function mapECCBiddingHeaderToBidding(
biddingNumber, // 생성된 Bidding 코드
originalBiddingNumber: eccHeader.ANFNR || null, // 원입찰번호
revision: 0, // 기본 리비전 0 (I/F 해서 가져온 건 보낸 적 없으므로 0 고정)
- projectName, // 첫번째 PR Item의 PSPID로 찾은 프로젝트 이름
- itemName, // 첫번째 PR Item의 MATNR로 조회한 자재명
- title: `${firstItem?.PSPID || ''}${itemName || ''}입찰`, // PSPID+자재그룹명+계약구분+입찰, 계약구분은 없으니까 제외했음
+ projectName, // 타겟 PR Item의 PSPID로 찾은 프로젝트 이름
+ itemName, // 타겟 PR Item 정보로 조회한 자재명/내역
+ title: `${targetItem?.PSPID || ''}${itemName || ''}입찰`, // PSPID+자재명+입찰
description: null,
// 계약 정보 - ECC에서 제공되지 않으므로 기본값 설정
@@ -269,7 +284,7 @@ export async function mapECCBiddingHeaderToBidding(
hasSpecificationMeeting: false, // 기본값 처리하고, 입찰관리상세에서 사용자가 관리
// 예산 및 가격 정보
- currency: firstItem?.WAERS1,
+ currency: targetItem?.WAERS1,
budget: null,
targetPrice: null,
targetPriceCalculationCriteria: null,
@@ -286,6 +301,7 @@ export async function mapECCBiddingHeaderToBidding(
// 담당자 정보 - EKGRP 기반으로 설정 (입찰담당자로 매핑)
bidPicId: inChargeUserInfo?.userId || null,
bidPicName: inChargeUserInfo?.userName || null,
+ bidPicCode: eccHeader.EKGRP || null,
// 메타 정보
remarks: `ECC ANFNR: ${eccHeader.ANFNR}`,
@@ -409,7 +425,15 @@ export async function mapAndSaveECCBiddingData(
// 2) 헤더별 관련 아이템 그룹핑 + 헤더 매핑을 병렬로 수행 - 유효한 헤더만 사용
const biddingGroups = await Promise.all(
validHeaders.map(async (eccHeader) => {
- const relatedItems = eccItems.filter((item) => item.ANFNR === eccHeader.ANFNR);
+ // 아이템 필터링 및 정렬 (ANFPS 오름차순)
+ const relatedItems = eccItems
+ .filter((item) => item.ANFNR === eccHeader.ANFNR)
+ .sort((a, b) => {
+ const anfpsA = parseInt(a.ANFPS || '0', 10);
+ const anfpsB = parseInt(b.ANFPS || '0', 10);
+ return anfpsA - anfpsB;
+ });
+
const biddingNumber = biddingCodeMap.get(eccHeader.ANFNR) || `BID${eccHeader.EKGRP || 'UNKNOWN'}00001`;
// 헤더 매핑 시 아이템 정보와 생성된 Bidding 코드 전달
const biddingData = await mapECCBiddingHeaderToBidding(eccHeader, relatedItems, biddingNumber);