summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--components/bidding/manage/bidding-schedule-editor.tsx340
-rw-r--r--lib/bidding/detail/service.ts97
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-columns.tsx78
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-table.tsx41
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx25
-rw-r--r--lib/bidding/detail/table/price-adjustment-dialog.tsx195
-rw-r--r--lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx324
-rw-r--r--lib/bidding/handlers.ts94
-rw-r--r--lib/bidding/receive/biddings-receive-columns.tsx808
-rw-r--r--lib/bidding/receive/biddings-receive-table.tsx593
-rw-r--r--lib/bidding/service.ts61
-rw-r--r--lib/bidding/vendor/components/pr-items-pricing-table.tsx125
12 files changed, 1922 insertions, 859 deletions
diff --git a/components/bidding/manage/bidding-schedule-editor.tsx b/components/bidding/manage/bidding-schedule-editor.tsx
index 49659ae7..32ce6940 100644
--- a/components/bidding/manage/bidding-schedule-editor.tsx
+++ b/components/bidding/manage/bidding-schedule-editor.tsx
@@ -21,8 +21,10 @@ import { registerBidding } from '@/lib/bidding/detail/service'
import { useToast } from '@/hooks/use-toast'
import { format } from 'date-fns'
interface BiddingSchedule {
- submissionStartDate?: string
- submissionEndDate?: string
+ submissionStartOffset?: number // 시작일 오프셋 (결재 후 n일)
+ submissionStartTime?: string // 시작 시간 (HH:MM)
+ submissionDurationDays?: number // 기간 (시작일 + n일)
+ submissionEndTime?: string // 마감 시간 (HH:MM)
remarks?: string
isUrgent?: boolean
hasSpecificationMeeting?: boolean
@@ -149,6 +151,44 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
return new Date(kstTime).toISOString().slice(0, 16)
}
+ // timestamp에서 시간(HH:MM) 추출 (KST 기준)
+ const extractTimeFromTimestamp = (date: string | Date | undefined | null): string => {
+ if (!date) return ''
+ const d = new Date(date)
+ // UTC 시간에 9시간을 더함
+ const kstTime = d.getTime() + (9 * 60 * 60 * 1000)
+ const kstDate = new Date(kstTime)
+ const hours = kstDate.getUTCHours().toString().padStart(2, '0')
+ const minutes = kstDate.getUTCMinutes().toString().padStart(2, '0')
+ return `${hours}:${minutes}`
+ }
+
+ // 예상 일정 계산 (오늘 기준 미리보기)
+ const getPreviewDates = () => {
+ const today = new Date()
+ today.setHours(0, 0, 0, 0)
+
+ const startOffset = schedule.submissionStartOffset ?? 0
+ const durationDays = schedule.submissionDurationDays ?? 7
+ const startTime = schedule.submissionStartTime || '09:00'
+ const endTime = schedule.submissionEndTime || '18:00'
+
+ // 시작일 계산
+ const startDate = new Date(today)
+ startDate.setDate(startDate.getDate() + startOffset)
+ const [startHour, startMinute] = startTime.split(':').map(Number)
+ startDate.setHours(startHour, startMinute, 0, 0)
+
+ // 마감일 계산
+ const endDate = new Date(startDate)
+ endDate.setHours(0, 0, 0, 0) // 시작일의 날짜만
+ endDate.setDate(endDate.getDate() + durationDays)
+ const [endHour, endMinute] = endTime.split(':').map(Number)
+ endDate.setHours(endHour, endMinute, 0, 0)
+
+ return { startDate, endDate }
+ }
+
// 데이터 로딩
React.useEffect(() => {
const loadSchedule = async () => {
@@ -165,36 +205,36 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
})
setSchedule({
- submissionStartDate: toKstInputValue(bidding.submissionStartDate),
- submissionEndDate: toKstInputValue(bidding.submissionEndDate),
+ submissionStartOffset: bidding.submissionStartOffset ?? 1,
+ submissionStartTime: extractTimeFromTimestamp(bidding.submissionStartDate) || '09:00',
+ submissionDurationDays: bidding.submissionDurationDays ?? 7,
+ submissionEndTime: extractTimeFromTimestamp(bidding.submissionEndDate) || '18:00',
remarks: bidding.remarks || '',
isUrgent: bidding.isUrgent || false,
hasSpecificationMeeting: bidding.hasSpecificationMeeting || false,
})
- // 사양설명회 정보 로드
- if (bidding.hasSpecificationMeeting) {
- try {
- const meetingDetails = await getSpecificationMeetingDetailsAction(biddingId)
- if (meetingDetails.success && meetingDetails.data) {
- const meeting = meetingDetails.data
- setSpecMeetingInfo({
- meetingDate: toKstInputValue(meeting.meetingDate),
- meetingTime: meeting.meetingTime || '',
- location: meeting.location || '',
- address: meeting.address || '',
- contactPerson: meeting.contactPerson || '',
- contactPhone: meeting.contactPhone || '',
- contactEmail: meeting.contactEmail || '',
- agenda: meeting.agenda || '',
- materials: meeting.materials || '',
- notes: meeting.notes || '',
- isRequired: meeting.isRequired || false,
- })
- }
- } catch (error) {
- console.error('Failed to load specification meeting details:', error)
+ // 사양설명회 정보 로드 (T/F 무관하게 기존 데이터가 있으면 로드)
+ try {
+ const meetingDetails = await getSpecificationMeetingDetailsAction(biddingId)
+ if (meetingDetails.success && meetingDetails.data) {
+ const meeting = meetingDetails.data
+ setSpecMeetingInfo({
+ meetingDate: toKstInputValue(meeting.meetingDate),
+ meetingTime: meeting.meetingTime || '',
+ location: meeting.location || '',
+ address: meeting.address || '',
+ contactPerson: meeting.contactPerson || '',
+ contactPhone: meeting.contactPhone || '',
+ contactEmail: meeting.contactEmail || '',
+ agenda: meeting.agenda || '',
+ materials: meeting.materials || '',
+ notes: meeting.notes || '',
+ isRequired: meeting.isRequired || false,
+ })
}
+ } catch (error) {
+ console.error('Failed to load specification meeting details:', error)
}
}
} catch (error) {
@@ -258,10 +298,18 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
const handleBiddingInvitationClick = async () => {
try {
// 1. 입찰서 제출기간 검증
- if (!schedule.submissionStartDate || !schedule.submissionEndDate) {
+ if (schedule.submissionStartOffset === undefined || schedule.submissionDurationDays === undefined) {
toast({
title: '입찰서 제출기간 미설정',
- description: '입찰서 제출 시작일시와 마감일시를 모두 설정해주세요.',
+ description: '입찰서 제출 시작일 오프셋과 기간을 모두 설정해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+ if (!schedule.submissionStartTime || !schedule.submissionEndTime) {
+ toast({
+ title: '입찰서 제출시간 미설정',
+ description: '입찰 시작 시간과 마감 시간을 모두 설정해주세요.',
variant: 'destructive',
})
return
@@ -484,10 +532,48 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
const userId = session?.user?.id?.toString() || '1'
// 입찰서 제출기간 필수 검증
- if (!schedule.submissionStartDate || !schedule.submissionEndDate) {
+ if (schedule.submissionStartOffset === undefined || schedule.submissionDurationDays === undefined) {
toast({
title: '입찰서 제출기간 미설정',
- description: '입찰서 제출 시작일시와 마감일시를 모두 설정해주세요.',
+ description: '입찰서 제출 시작일 오프셋과 기간을 모두 설정해주세요.',
+ variant: 'destructive',
+ })
+ setIsSubmitting(false)
+ return
+ }
+ if (!schedule.submissionStartTime || !schedule.submissionEndTime) {
+ toast({
+ title: '입찰서 제출시간 미설정',
+ description: '입찰 시작 시간과 마감 시간을 모두 설정해주세요.',
+ variant: 'destructive',
+ })
+ setIsSubmitting(false)
+ return
+ }
+ // 오프셋/기간 검증
+ if (schedule.submissionStartOffset < 0) {
+ toast({
+ title: '시작일 오프셋 오류',
+ description: '시작일 오프셋은 0 이상이어야 합니다.',
+ variant: 'destructive',
+ })
+ setIsSubmitting(false)
+ return
+ }
+ if (schedule.submissionDurationDays < 1) {
+ toast({
+ title: '기간 오류',
+ description: '입찰 기간은 최소 1일 이상이어야 합니다.',
+ variant: 'destructive',
+ })
+ setIsSubmitting(false)
+ return
+ }
+ // 긴급 입찰이 아닌 경우 당일 시작 불가 (오프셋 0)
+ if (!schedule.isUrgent && schedule.submissionStartOffset === 0) {
+ toast({
+ title: '시작일 오류',
+ description: '긴급 입찰이 아닌 경우 당일 시작(오프셋 0)은 불가능합니다.',
variant: 'destructive',
})
setIsSubmitting(false)
@@ -538,62 +624,55 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
}
}
- const handleScheduleChange = (field: keyof BiddingSchedule, value: string | boolean) => {
- // 마감일시 검증 - 현재일 이전 설정 불가
- if (field === 'submissionEndDate' && typeof value === 'string' && value) {
- const selectedDate = new Date(value)
- const now = new Date()
- now.setHours(0, 0, 0, 0) // 시간을 00:00:00으로 설정하여 날짜만 비교
-
- if (selectedDate < now) {
+ const handleScheduleChange = (field: keyof BiddingSchedule, value: string | boolean | number) => {
+ // 시작일 오프셋 검증
+ if (field === 'submissionStartOffset' && typeof value === 'number') {
+ if (value < 0) {
+ toast({
+ title: '시작일 오프셋 오류',
+ description: '시작일 오프셋은 0 이상이어야 합니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+ // 긴급 입찰이 아닌 경우 당일 시작(오프셋 0) 불가
+ if (!schedule.isUrgent && value === 0) {
toast({
- title: '마감일시 오류',
- description: '마감일시는 현재일 이전으로 설정할 수 없습니다.',
+ title: '시작일 오프셋 오류',
+ description: '긴급 입찰이 아닌 경우 당일 시작(오프셋 0)은 불가능합니다.',
variant: 'destructive',
})
- return // 변경을 적용하지 않음
+ return
}
}
- // 긴급여부 미선택 시 당일 제출시작 불가
- if (field === 'submissionStartDate' && typeof value === 'string' && value) {
- const selectedDate = new Date(value)
- const today = new Date()
- today.setHours(0, 0, 0, 0) // 시간을 00:00:00으로 설정
- selectedDate.setHours(0, 0, 0, 0)
-
- // 현재 긴급 여부 확인 (field가 'isUrgent'인 경우 value 사용, 아니면 기존 schedule 값)
- const isUrgent = field === 'isUrgent' ? (value as boolean) : schedule.isUrgent || false
+ // 기간 검증
+ if (field === 'submissionDurationDays' && typeof value === 'number') {
+ if (value < 1) {
+ toast({
+ title: '기간 오류',
+ description: '입찰 기간은 최소 1일 이상이어야 합니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+ }
- // 긴급이 아닌 경우 당일 시작 불가
- if (!isUrgent && selectedDate.getTime() === today.getTime()) {
+ // 시간 형식 검증 (HH:MM)
+ if ((field === 'submissionStartTime' || field === 'submissionEndTime') && typeof value === 'string') {
+ const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/
+ if (value && !timeRegex.test(value)) {
toast({
- title: '제출 시작일시 오류',
- description: '긴급 입찰이 아닌 경우 당일 제출 시작은 불가능합니다.',
+ title: '시간 형식 오류',
+ description: '시간은 HH:MM 형식으로 입력해주세요.',
variant: 'destructive',
})
- return // 변경을 적용하지 않음
+ return
}
}
setSchedule(prev => ({ ...prev, [field]: value }))
-
- // 사양설명회 실시 여부가 false로 변경되면 상세 정보 초기화
- if (field === 'hasSpecificationMeeting' && value === false) {
- setSpecMeetingInfo({
- meetingDate: '',
- meetingTime: '',
- location: '',
- address: '',
- contactPerson: '',
- contactPhone: '',
- contactEmail: '',
- agenda: '',
- materials: '',
- notes: '',
- isRequired: false,
- })
- }
+ // 사양설명회 실시 여부가 false로 변경되어도 상세 정보 초기화하지 않음 (기존 데이터 유지)
}
if (isLoading) {
@@ -624,40 +703,98 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
<Clock className="h-4 w-4" />
입찰서 제출 기간
</h3>
+ <p className="text-sm text-muted-foreground">
+ 입찰공고(결재 완료) 시점을 기준으로 일정이 자동 계산됩니다.
+ </p>
+
+ {/* 시작일 설정 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
- <Label htmlFor="submission-start">제출 시작일시 <span className="text-red-500">*</span></Label>
+ <Label htmlFor="submission-start-offset">시작일 (결재 후) <span className="text-red-500">*</span></Label>
+ <div className="flex items-center gap-2">
+ <Input
+ id="submission-start-offset"
+ type="number"
+ min={schedule.isUrgent ? 0 : 1}
+ value={schedule.submissionStartOffset ?? ''}
+ onChange={(e) => handleScheduleChange('submissionStartOffset', parseInt(e.target.value) || 0)}
+ className={schedule.submissionStartOffset === undefined ? 'border-red-200' : ''}
+ disabled={readonly}
+ placeholder="0"
+ />
+ <span className="text-sm text-muted-foreground whitespace-nowrap">일 후</span>
+ </div>
+ {schedule.submissionStartOffset === undefined && (
+ <p className="text-sm text-red-500">시작일 오프셋은 필수입니다</p>
+ )}
+ {!schedule.isUrgent && schedule.submissionStartOffset === 0 && (
+ <p className="text-sm text-amber-600">긴급 입찰만 당일 시작(0일) 가능</p>
+ )}
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="submission-start-time">시작 시간 <span className="text-red-500">*</span></Label>
<Input
- id="submission-start"
- type="datetime-local"
- value={schedule.submissionStartDate}
- onChange={(e) => handleScheduleChange('submissionStartDate', e.target.value)}
- className={!schedule.submissionStartDate ? 'border-red-200' : ''}
+ id="submission-start-time"
+ type="time"
+ value={schedule.submissionStartTime || ''}
+ onChange={(e) => handleScheduleChange('submissionStartTime', e.target.value)}
+ className={!schedule.submissionStartTime ? 'border-red-200' : ''}
disabled={readonly}
- min="1900-01-01T00:00"
- max="2100-12-31T23:59"
/>
- {!schedule.submissionStartDate && (
- <p className="text-sm text-red-500">제출 시작일시는 필수입니다</p>
+ {!schedule.submissionStartTime && (
+ <p className="text-sm text-red-500">시작 시간은 필수입니다</p>
+ )}
+ </div>
+ </div>
+
+ {/* 마감일 설정 */}
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="submission-duration">입찰 기간 (시작일 +) <span className="text-red-500">*</span></Label>
+ <div className="flex items-center gap-2">
+ <Input
+ id="submission-duration"
+ type="number"
+ min={1}
+ value={schedule.submissionDurationDays ?? ''}
+ onChange={(e) => handleScheduleChange('submissionDurationDays', parseInt(e.target.value) || 1)}
+ className={schedule.submissionDurationDays === undefined ? 'border-red-200' : ''}
+ disabled={readonly}
+ placeholder="7"
+ />
+ <span className="text-sm text-muted-foreground whitespace-nowrap">일간</span>
+ </div>
+ {schedule.submissionDurationDays === undefined && (
+ <p className="text-sm text-red-500">입찰 기간은 필수입니다</p>
)}
</div>
<div className="space-y-2">
- <Label htmlFor="submission-end">제출 마감일시 <span className="text-red-500">*</span></Label>
+ <Label htmlFor="submission-end-time">마감 시간 <span className="text-red-500">*</span></Label>
<Input
- id="submission-end"
- type="datetime-local"
- value={schedule.submissionEndDate}
- onChange={(e) => handleScheduleChange('submissionEndDate', e.target.value)}
- className={!schedule.submissionEndDate ? 'border-red-200' : ''}
+ id="submission-end-time"
+ type="time"
+ value={schedule.submissionEndTime || ''}
+ onChange={(e) => handleScheduleChange('submissionEndTime', e.target.value)}
+ className={!schedule.submissionEndTime ? 'border-red-200' : ''}
disabled={readonly}
- min="1900-01-01T00:00"
- max="2100-12-31T23:59"
/>
- {!schedule.submissionEndDate && (
- <p className="text-sm text-red-500">제출 마감일시는 필수입니다</p>
+ {!schedule.submissionEndTime && (
+ <p className="text-sm text-red-500">마감 시간은 필수입니다</p>
)}
</div>
</div>
+
+ {/* 예상 일정 미리보기 */}
+ {schedule.submissionStartOffset !== undefined && schedule.submissionDurationDays !== undefined && (
+ <div className="p-3 bg-blue-50 rounded-lg border border-blue-200">
+ <p className="text-sm font-medium text-blue-800 mb-1">📅 예상 일정 (오늘 공고 기준)</p>
+ <div className="text-sm text-blue-700">
+ <span>시작: {format(getPreviewDates().startDate, "yyyy-MM-dd HH:mm")}</span>
+ <span className="mx-2">~</span>
+ <span>마감: {format(getPreviewDates().endDate, "yyyy-MM-dd HH:mm")}</span>
+ </div>
+ </div>
+ )}
</div>
{/* 긴급 여부 */}
@@ -690,8 +827,8 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
/>
</div>
- {/* 사양설명회 상세 정보 */}
- {schedule.hasSpecificationMeeting && (
+ {/* 사양설명회 상세 정보 - T/F와 무관하게 기존 데이터가 있거나 hasSpecificationMeeting이 true면 표시 */}
+ {(schedule.hasSpecificationMeeting) && (
<div className="space-y-6 p-4 border rounded-lg bg-muted/50">
<div className="grid grid-cols-2 gap-4">
<div>
@@ -834,10 +971,19 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
<CardContent>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
- <span className="font-medium">입찰서 제출 기간:</span>
+ <span className="font-medium">시작일:</span>
+ <span>
+ {schedule.submissionStartOffset !== undefined
+ ? `결재 후 ${schedule.submissionStartOffset}일, ${schedule.submissionStartTime || '미설정'}`
+ : '미설정'
+ }
+ </span>
+ </div>
+ <div className="flex justify-between">
+ <span className="font-medium">마감일:</span>
<span>
- {schedule.submissionStartDate && schedule.submissionEndDate
- ? `${format(new Date(schedule.submissionStartDate), "yyyy-MM-dd HH:mm")} ~ ${format(new Date(schedule.submissionEndDate), "yyyy-MM-dd HH:mm")}`
+ {schedule.submissionDurationDays !== undefined
+ ? `시작일 + ${schedule.submissionDurationDays}일, ${schedule.submissionEndTime || '미설정'}`
: '미설정'
}
</span>
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts
index 17ea8f28..4ef48d33 100644
--- a/lib/bidding/detail/service.ts
+++ b/lib/bidding/detail/service.ts
@@ -64,6 +64,12 @@ export async function getBiddingDetailData(biddingId: number): Promise<BiddingDe
awardRatio: biddingCompanies.awardRatio,
isBiddingParticipated: biddingCompanies.isBiddingParticipated,
invitationStatus: biddingCompanies.invitationStatus,
+ // 연동제 관련 필드
+ isPriceAdjustmentApplicableQuestion: biddingCompanies.isPriceAdjustmentApplicableQuestion,
+ priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse, // 벤더가 응답한 연동제 적용 여부
+ shiPriceAdjustmentApplied: biddingCompanies.shiPriceAdjustmentApplied,
+ priceAdjustmentNote: biddingCompanies.priceAdjustmentNote,
+ hasChemicalSubstance: biddingCompanies.hasChemicalSubstance,
// Contact info from biddingCompaniesContacts
contactPerson: biddingCompaniesContacts.contactName,
contactEmail: biddingCompaniesContacts.contactEmail,
@@ -75,6 +81,10 @@ export async function getBiddingDetailData(biddingId: number): Promise<BiddingDe
eq(biddingCompaniesContacts.biddingId, biddingId),
eq(biddingCompaniesContacts.vendorId, biddingCompanies.companyId)
))
+ .leftJoin(companyConditionResponses, and(
+ eq(companyConditionResponses.biddingCompanyId, biddingCompanies.id),
+ eq(companyConditionResponses.isPreQuote, false) // 본입찰 데이터만
+ ))
.where(and(
eq(biddingCompanies.biddingId, biddingId),
eq(biddingCompanies.isBiddingParticipated, true)
@@ -102,6 +112,12 @@ export async function getBiddingDetailData(biddingId: number): Promise<BiddingDe
awardRatio: curr.awardRatio ? Number(curr.awardRatio) : null,
isBiddingParticipated: curr.isBiddingParticipated,
invitationStatus: curr.invitationStatus,
+ // 연동제 관련 필드
+ isPriceAdjustmentApplicableQuestion: curr.isPriceAdjustmentApplicableQuestion,
+ priceAdjustmentResponse: curr.priceAdjustmentResponse, // 벤더가 응답한 연동제 적용 여부
+ shiPriceAdjustmentApplied: curr.shiPriceAdjustmentApplied,
+ priceAdjustmentNote: curr.priceAdjustmentNote,
+ hasChemicalSubstance: curr.hasChemicalSubstance,
documents: [],
})
}
@@ -148,6 +164,12 @@ export interface QuotationVendor {
awardRatio: number | null // 발주비율
isBiddingParticipated: boolean | null // 본입찰 참여여부
invitationStatus: 'pending' | 'pre_quote_sent' | 'pre_quote_accepted' | 'pre_quote_declined' | 'pre_quote_submitted' | 'bidding_sent' | 'bidding_accepted' | 'bidding_declined' | 'bidding_cancelled' | 'bidding_submitted'
+ // 연동제 관련 필드
+ isPriceAdjustmentApplicableQuestion: boolean | null // SHI가 요청한 연동제 요청 여부
+ priceAdjustmentResponse: boolean | null // 벤더가 응답한 연동제 적용 여부 (companyConditionResponses.priceAdjustmentResponse)
+ shiPriceAdjustmentApplied: boolean | null // SHI 연동제 적용여부
+ priceAdjustmentNote: string | null // 연동제 Note
+ hasChemicalSubstance: boolean | null // 화학물질여부
documents: Array<{
id: number
fileName: string
@@ -818,11 +840,52 @@ export async function registerBidding(biddingId: number, userId: string) {
await db.transaction(async (tx) => {
debugLog('registerBidding: Transaction started')
- // 1. 입찰 상태를 오픈으로 변경
+
+ // 0. 입찰서 제출기간 계산 (오프셋 기반)
+ const { submissionStartOffset, submissionDurationDays, submissionStartDate, submissionEndDate } = bidding
+
+ let calculatedStartDate = bidding.submissionStartDate
+ let calculatedEndDate = bidding.submissionEndDate
+
+ // 오프셋 값이 있으면 날짜 계산
+ if (submissionStartOffset !== null && submissionDurationDays !== null) {
+ // 시간 추출 (기본값: 시작 09:00, 마감 18:00)
+ const startTime = submissionStartDate
+ ? { hours: submissionStartDate.getUTCHours(), minutes: submissionStartDate.getUTCMinutes() }
+ : { hours: 9, minutes: 0 }
+ const endTime = submissionEndDate
+ ? { hours: submissionEndDate.getUTCHours(), minutes: submissionEndDate.getUTCMinutes() }
+ : { hours: 18, minutes: 0 }
+
+ // baseDate = 현재일 날짜만 (00:00:00)
+ const baseDate = new Date()
+ baseDate.setHours(0, 0, 0, 0)
+
+ // 시작일 = baseDate + offset일 + 시작시간
+ calculatedStartDate = new Date(baseDate)
+ calculatedStartDate.setDate(calculatedStartDate.getDate() + submissionStartOffset)
+ calculatedStartDate.setHours(startTime.hours, startTime.minutes, 0, 0)
+
+ // 마감일 = 시작일(날짜만) + duration일 + 마감시간
+ calculatedEndDate = new Date(calculatedStartDate)
+ calculatedEndDate.setHours(0, 0, 0, 0)
+ calculatedEndDate.setDate(calculatedEndDate.getDate() + submissionDurationDays)
+ calculatedEndDate.setHours(endTime.hours, endTime.minutes, 0, 0)
+
+ debugLog('registerBidding: Submission dates calculated', {
+ baseDate: baseDate.toISOString(),
+ calculatedStartDate: calculatedStartDate.toISOString(),
+ calculatedEndDate: calculatedEndDate.toISOString(),
+ })
+ }
+
+ // 1. 입찰 상태를 오픈으로 변경 + 제출기간 업데이트
await tx
.update(biddings)
.set({
status: 'bidding_opened',
+ submissionStartDate: calculatedStartDate,
+ submissionEndDate: calculatedEndDate,
updatedBy: userName,
updatedAt: new Date()
})
@@ -2617,3 +2680,35 @@ export async function setSpecificationMeetingParticipation(biddingCompanyId: num
return { success: false, error: '사양설명회 참여상태 업데이트에 실패했습니다.' }
}
}
+
+// 연동제 정보 업데이트
+export async function updatePriceAdjustmentInfo(params: {
+ biddingCompanyId: number
+ shiPriceAdjustmentApplied: boolean | null
+ priceAdjustmentNote: string | null
+ hasChemicalSubstance: boolean | null
+}): Promise<{ success: boolean; error?: string }> {
+ try {
+ const result = await db.update(biddingCompanies)
+ .set({
+ shiPriceAdjustmentApplied: params.shiPriceAdjustmentApplied,
+ priceAdjustmentNote: params.priceAdjustmentNote,
+ hasChemicalSubstance: params.hasChemicalSubstance,
+ updatedAt: new Date(),
+ })
+ .where(eq(biddingCompanies.id, params.biddingCompanyId))
+ .returning({ biddingId: biddingCompanies.biddingId })
+
+ if (result.length > 0) {
+ const biddingId = result[0].biddingId
+ revalidateTag(`bidding-${biddingId}`)
+ revalidateTag('quotation-vendors')
+ revalidatePath(`/evcp/bid/${biddingId}`)
+ }
+
+ return { success: true }
+ } catch (error) {
+ console.error('Failed to update price adjustment info:', error)
+ return { success: false, error: '연동제 정보 업데이트에 실패했습니다.' }
+ }
+}
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
index 5368b287..05c1a93d 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
@@ -31,6 +31,7 @@ interface GetVendorColumnsProps {
}
export function getBiddingDetailVendorColumns({
+ onViewPriceAdjustment,
onViewItemDetails,
onSendBidding,
onUpdateParticipation,
@@ -239,6 +240,83 @@ export function getBiddingDetailVendorColumns({
),
},
{
+ accessorKey: 'priceAdjustmentResponse',
+ header: '연동제 응답',
+ cell: ({ row }) => {
+ const vendor = row.original
+ const response = vendor.priceAdjustmentResponse
+
+ // 버튼 형태로 표시, 클릭 시 상세 다이얼로그 열기
+ const getBadgeVariant = () => {
+ if (response === null || response === undefined) return 'outline'
+ return response ? 'default' : 'secondary'
+ }
+
+ const getBadgeClass = () => {
+ if (response === true) return 'bg-green-600 hover:bg-green-700 cursor-pointer'
+ if (response === false) return 'hover:bg-gray-300 cursor-pointer'
+ return ''
+ }
+
+ const getLabel = () => {
+ if (response === null || response === undefined) return '해당없음'
+ return response ? '예' : '아니오'
+ }
+
+ return (
+ <Badge
+ variant={getBadgeVariant()}
+ className={getBadgeClass()}
+ onClick={() => onViewPriceAdjustment?.(vendor)}
+ >
+ {getLabel()}
+ </Badge>
+ )
+ },
+ },
+ {
+ accessorKey: 'shiPriceAdjustmentApplied',
+ header: 'SHI연동제적용',
+ cell: ({ row }) => {
+ const applied = row.original.shiPriceAdjustmentApplied
+ if (applied === null || applied === undefined) {
+ return <Badge variant="outline">미정</Badge>
+ }
+ return (
+ <Badge variant={applied ? 'default' : 'secondary'} className={applied ? 'bg-green-600' : ''}>
+ {applied ? '적용' : '미적용'}
+ </Badge>
+ )
+ },
+ },
+ {
+ accessorKey: 'priceAdjustmentNote',
+ header: '연동제 Note',
+ cell: ({ row }) => {
+ const note = row.original.priceAdjustmentNote
+ return (
+ <div className="text-sm max-w-[150px] truncate" title={note || ''}>
+ {note || '-'}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: 'hasChemicalSubstance',
+ header: '화학물질',
+ cell: ({ row }) => {
+ const hasChemical = row.original.hasChemicalSubstance
+ if (hasChemical === null || hasChemical === undefined) {
+ return <Badge variant="outline">미정</Badge>
+ }
+ return (
+ <Badge variant={hasChemical ? 'destructive' : 'secondary'}>
+ {hasChemical ? '해당' : '해당없음'}
+ </Badge>
+ )
+ },
+ },
+ {
id: 'actions',
header: '작업',
cell: ({ row }) => {
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
index a6f64964..407cc51c 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
@@ -10,9 +10,9 @@ import { BiddingDetailVendorToolbarActions } from './bidding-detail-vendor-toolb
import { BiddingDetailVendorEditDialog } from './bidding-detail-vendor-edit-dialog'
import { BiddingAwardDialog } from './bidding-award-dialog'
import { getBiddingDetailVendorColumns } from './bidding-detail-vendor-columns'
-import { QuotationVendor, getPriceAdjustmentFormByBiddingCompanyId } from '@/lib/bidding/detail/service'
+import { QuotationVendor } from '@/lib/bidding/detail/service'
import { Bidding } from '@/db/schema'
-import { PriceAdjustmentDialog } from '@/components/bidding/price-adjustment-dialog'
+import { VendorPriceAdjustmentViewDialog } from './vendor-price-adjustment-view-dialog'
import { QuotationHistoryDialog } from './quotation-history-dialog'
import { ApprovalPreviewDialog } from '@/lib/approval/approval-preview-dialog'
import { ApplicationReasonDialog } from '@/lib/rfq-last/vendor/application-reason-dialog'
@@ -98,8 +98,7 @@ export function BiddingDetailVendorTableContent({
const [selectedVendor, setSelectedVendor] = React.useState<QuotationVendor | null>(null)
const [isAwardDialogOpen, setIsAwardDialogOpen] = React.useState(false)
const [isAwardRatioDialogOpen, setIsAwardRatioDialogOpen] = React.useState(false)
- const [priceAdjustmentData, setPriceAdjustmentData] = React.useState<any>(null)
- const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false)
+ const [isVendorPriceAdjustmentDialogOpen, setIsVendorPriceAdjustmentDialogOpen] = React.useState(false)
const [quotationHistoryData, setQuotationHistoryData] = React.useState<any>(null)
const [isQuotationHistoryDialogOpen, setIsQuotationHistoryDialogOpen] = React.useState(false)
const [approvalPreviewData, setApprovalPreviewData] = React.useState<{
@@ -116,28 +115,9 @@ export function BiddingDetailVendorTableContent({
} | null>(null)
const [isApprovalPreviewDialogOpen, setIsApprovalPreviewDialogOpen] = React.useState(false)
- const handleViewPriceAdjustment = async (vendor: QuotationVendor) => {
- try {
- const priceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(vendor.id)
- if (priceAdjustmentForm) {
- setPriceAdjustmentData(priceAdjustmentForm)
- setSelectedVendor(vendor)
- setIsPriceAdjustmentDialogOpen(true)
- } else {
- toast({
- title: '연동제 정보 없음',
- description: '해당 업체의 연동제 정보가 없습니다.',
- variant: 'default',
- })
- }
- } catch (error) {
- console.error('Failed to load price adjustment form:', error)
- toast({
- title: '오류',
- description: '연동제 정보를 불러오는데 실패했습니다.',
- variant: 'destructive',
- })
- }
+ const handleViewPriceAdjustment = (vendor: QuotationVendor) => {
+ setSelectedVendor(vendor)
+ setIsVendorPriceAdjustmentDialogOpen(true)
}
const handleViewQuotationHistory = async (vendor: QuotationVendor) => {
@@ -299,11 +279,12 @@ export function BiddingDetailVendorTableContent({
}}
/>
- <PriceAdjustmentDialog
- open={isPriceAdjustmentDialogOpen}
- onOpenChange={setIsPriceAdjustmentDialogOpen}
- data={priceAdjustmentData}
+ <VendorPriceAdjustmentViewDialog
+ open={isVendorPriceAdjustmentDialogOpen}
+ onOpenChange={setIsVendorPriceAdjustmentDialogOpen}
vendorName={selectedVendor?.vendorName || ''}
+ priceAdjustmentResponse={selectedVendor?.priceAdjustmentResponse ?? null}
+ biddingCompanyId={selectedVendor?.id || 0}
/>
<QuotationHistoryDialog
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
index 7e571a23..d3df141a 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
@@ -5,13 +5,14 @@ import { useRouter } from "next/navigation"
import { useTransition } from "react"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
-import { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign, RotateCw } from "lucide-react"
+import { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign, RotateCw, Link2 } from "lucide-react"
import { registerBidding, markAsDisposal, cancelAwardRatio } from "@/lib/bidding/detail/service"
import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from "@/lib/bidding/pre-quote/service"
import { increaseRoundOrRebid } from "@/lib/bidding/service"
import { BiddingDetailVendorCreateDialog } from "../../../../components/bidding/manage/bidding-detail-vendor-create-dialog"
import { BiddingDocumentUploadDialog } from "./bidding-document-upload-dialog"
+import { PriceAdjustmentDialog } from "./price-adjustment-dialog"
import { Bidding } from "@/db/schema"
import { useToast } from "@/hooks/use-toast"
import { QuotationVendor } from "@/lib/bidding/detail/service"
@@ -49,6 +50,7 @@ export function BiddingDetailVendorToolbarActions({
const [selectedVendors, setSelectedVendors] = React.useState<any[]>([])
const [isRoundIncreaseDialogOpen, setIsRoundIncreaseDialogOpen] = React.useState(false)
const [isCancelAwardDialogOpen, setIsCancelAwardDialogOpen] = React.useState(false)
+ const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false)
// 본입찰 초대 다이얼로그가 열릴 때 선정된 업체들 조회
React.useEffect(() => {
@@ -196,6 +198,19 @@ export function BiddingDetailVendorToolbarActions({
</Button>
)}
+ {/* 연동제 적용여부: single select 시에만 활성화 */}
+ {(bidding.status === 'evaluation_of_bidding') && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsPriceAdjustmentDialogOpen(true)}
+ disabled={!singleSelectedVendor || isPending || singleSelectedVendor.isBiddingParticipated !== true}
+ >
+ <Link2 className="mr-2 h-4 w-4" />
+ 연동제 적용
+ </Button>
+ )}
+
{/* 유찰/낙찰: 입찰공고 또는 입찰평가중 상태에서만 */}
{(bidding.status === 'bidding_opened' || bidding.status === 'evaluation_of_bidding') && (
<>
@@ -331,6 +346,14 @@ export function BiddingDetailVendorToolbarActions({
</DialogContent>
</Dialog>
+ {/* 연동제 적용여부 다이얼로그 */}
+ <PriceAdjustmentDialog
+ open={isPriceAdjustmentDialogOpen}
+ onOpenChange={setIsPriceAdjustmentDialogOpen}
+ vendor={singleSelectedVendor || null}
+ onSuccess={onSuccess}
+ />
+
</>
)
}
diff --git a/lib/bidding/detail/table/price-adjustment-dialog.tsx b/lib/bidding/detail/table/price-adjustment-dialog.tsx
new file mode 100644
index 00000000..14bbd843
--- /dev/null
+++ b/lib/bidding/detail/table/price-adjustment-dialog.tsx
@@ -0,0 +1,195 @@
+"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 { Textarea } from "@/components/ui/textarea"
+import { Switch } from "@/components/ui/switch"
+import { useToast } from "@/hooks/use-toast"
+import { updatePriceAdjustmentInfo } from "@/lib/bidding/detail/service"
+import { QuotationVendor } from "@/lib/bidding/detail/service"
+import { Loader2 } from "lucide-react"
+
+interface PriceAdjustmentDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ vendor: QuotationVendor | null
+ onSuccess: () => void
+}
+
+export function PriceAdjustmentDialog({
+ open,
+ onOpenChange,
+ vendor,
+ onSuccess,
+}: PriceAdjustmentDialogProps) {
+ const { toast } = useToast()
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+
+ // 폼 상태
+ const [shiPriceAdjustmentApplied, setSHIPriceAdjustmentApplied] = React.useState<boolean | null>(null)
+ const [priceAdjustmentNote, setPriceAdjustmentNote] = React.useState("")
+ const [hasChemicalSubstance, setHasChemicalSubstance] = React.useState<boolean | null>(null)
+
+ // 다이얼로그가 열릴 때 벤더 정보로 폼 초기화
+ React.useEffect(() => {
+ if (open && vendor) {
+ setSHIPriceAdjustmentApplied(vendor.shiPriceAdjustmentApplied ?? null)
+ setPriceAdjustmentNote(vendor.priceAdjustmentNote || "")
+ setHasChemicalSubstance(vendor.hasChemicalSubstance ?? null)
+ }
+ }, [open, vendor])
+
+ const handleSubmit = async () => {
+ if (!vendor) return
+
+ setIsSubmitting(true)
+ try {
+ const result = await updatePriceAdjustmentInfo({
+ biddingCompanyId: vendor.id,
+ shiPriceAdjustmentApplied,
+ priceAdjustmentNote: priceAdjustmentNote || null,
+ hasChemicalSubstance,
+ })
+
+ if (result.success) {
+ toast({
+ title: "저장 완료",
+ description: "연동제 정보가 저장되었습니다.",
+ })
+ onOpenChange(false)
+ onSuccess()
+ } else {
+ toast({
+ title: "오류",
+ description: result.error || "저장 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ } catch (error) {
+ console.error("연동제 정보 저장 오류:", error)
+ toast({
+ title: "오류",
+ description: "저장 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ if (!vendor) return null
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle>연동제 적용 설정</DialogTitle>
+ <DialogDescription>
+ <span className="font-semibold text-primary">{vendor.vendorName}</span> 업체의 연동제 적용 여부 및 화학물질 정보를 설정합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-6 py-4">
+ {/* 업체가 제출한 연동제 요청 여부 (읽기 전용) */}
+ <div className="flex flex-row items-center justify-between rounded-lg border p-4 bg-muted/50">
+ <div className="space-y-0.5">
+ <Label className="text-base">업체 연동제 요청</Label>
+ <p className="text-sm text-muted-foreground">
+ 업체가 제출한 연동제 적용 요청 여부입니다.
+ </p>
+ </div>
+ <span className={`font-medium ${vendor.isPriceAdjustmentApplicableQuestion ? 'text-green-600' : 'text-gray-500'}`}>
+ {vendor.isPriceAdjustmentApplicableQuestion === null ? '미정' : vendor.isPriceAdjustmentApplicableQuestion ? '예' : '아니오'}
+ </span>
+ </div>
+
+ {/* SHI 연동제 적용여부 */}
+ <div className="flex flex-row items-center justify-between rounded-lg border p-4">
+ <div className="space-y-0.5">
+ <Label className="text-base">SHI 연동제 적용</Label>
+ <p className="text-sm text-muted-foreground">
+ 해당 업체에 연동제를 적용할지 결정합니다.
+ </p>
+ </div>
+ <div className="flex items-center gap-3">
+ <span className={`text-sm ${shiPriceAdjustmentApplied === false ? 'font-medium' : 'text-muted-foreground'}`}>
+ 미적용
+ </span>
+ <Switch
+ checked={shiPriceAdjustmentApplied === true}
+ onCheckedChange={(checked) => setSHIPriceAdjustmentApplied(checked)}
+ />
+ <span className={`text-sm ${shiPriceAdjustmentApplied === true ? 'font-medium' : 'text-muted-foreground'}`}>
+ 적용
+ </span>
+ </div>
+ </div>
+
+ {/* 연동제 Note */}
+ <div className="space-y-2">
+ <Label htmlFor="price-adjustment-note">연동제 Note</Label>
+ <Textarea
+ id="price-adjustment-note"
+ placeholder="연동제 관련 추가 사항을 입력하세요"
+ value={priceAdjustmentNote}
+ onChange={(e) => setPriceAdjustmentNote(e.target.value)}
+ rows={4}
+ />
+ </div>
+
+ {/* 화학물질 여부 */}
+ <div className="flex flex-row items-center justify-between rounded-lg border p-4">
+ <div className="space-y-0.5">
+ <Label className="text-base">화학물질 해당여부</Label>
+ <p className="text-sm text-muted-foreground">
+ 해당 업체가 화학물질 취급 대상인지 여부입니다.
+ </p>
+ </div>
+ <div className="flex items-center gap-3">
+ <span className={`text-sm ${hasChemicalSubstance === false ? 'font-medium' : 'text-muted-foreground'}`}>
+ 해당없음
+ </span>
+ <Switch
+ checked={hasChemicalSubstance === true}
+ onCheckedChange={(checked) => setHasChemicalSubstance(checked)}
+ />
+ <span className={`text-sm ${hasChemicalSubstance === true ? 'font-medium text-red-600' : 'text-muted-foreground'}`}>
+ 해당
+ </span>
+ </div>
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button onClick={handleSubmit} disabled={isSubmitting}>
+ {isSubmitting ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 저장 중...
+ </>
+ ) : (
+ "저장"
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
+
diff --git a/lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx b/lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx
new file mode 100644
index 00000000..f31caf5e
--- /dev/null
+++ b/lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx
@@ -0,0 +1,324 @@
+'use client'
+
+import React from 'react'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Badge } from '@/components/ui/badge'
+import { Separator } from '@/components/ui/separator'
+import { format } from 'date-fns'
+import { ko } from 'date-fns/locale'
+import { Loader2 } from 'lucide-react'
+
+interface PriceAdjustmentData {
+ id: number
+ itemName?: string | null
+ adjustmentReflectionPoint?: string | null
+ majorApplicableRawMaterial?: string | null
+ adjustmentFormula?: string | null
+ rawMaterialPriceIndex?: string | null
+ referenceDate?: Date | string | null
+ comparisonDate?: Date | string | null
+ adjustmentRatio?: string | null
+ notes?: string | null
+ adjustmentConditions?: string | null
+ majorNonApplicableRawMaterial?: string | null
+ adjustmentPeriod?: string | null
+ contractorWriter?: string | null
+ adjustmentDate?: Date | string | null
+ nonApplicableReason?: string | null
+ createdAt: Date | string
+ updatedAt: Date | string
+}
+
+interface VendorPriceAdjustmentViewDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ vendorName: string
+ priceAdjustmentResponse: boolean | null // 벤더가 응답한 연동제 적용 여부
+ biddingCompanyId: number
+}
+
+export function VendorPriceAdjustmentViewDialog({
+ open,
+ onOpenChange,
+ vendorName,
+ priceAdjustmentResponse,
+ biddingCompanyId,
+}: VendorPriceAdjustmentViewDialogProps) {
+ const [data, setData] = React.useState<PriceAdjustmentData | null>(null)
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [error, setError] = React.useState<string | null>(null)
+
+ // 다이얼로그가 열릴 때 데이터 로드
+ React.useEffect(() => {
+ if (open && biddingCompanyId) {
+ loadPriceAdjustmentData()
+ }
+ }, [open, biddingCompanyId])
+
+ const loadPriceAdjustmentData = async () => {
+ setIsLoading(true)
+ setError(null)
+ try {
+ // 서버에서 연동제 폼 데이터 조회
+ const { getPriceAdjustmentFormByBiddingCompanyId } = await import('@/lib/bidding/detail/service')
+ const formData = await getPriceAdjustmentFormByBiddingCompanyId(biddingCompanyId)
+ setData(formData)
+ } catch (err) {
+ console.error('Failed to load price adjustment data:', err)
+ setError('연동제 정보를 불러오는데 실패했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 날짜 포맷팅 헬퍼
+ const formatDateValue = (date: Date | string | null | undefined) => {
+ if (!date) return '-'
+ try {
+ const dateObj = typeof date === 'string' ? new Date(date) : date
+ return format(dateObj, 'yyyy-MM-dd', { locale: ko })
+ } catch {
+ return '-'
+ }
+ }
+
+ // 연동제 적용 여부 판단
+ const isApplied = priceAdjustmentResponse === true
+ const isNotApplied = priceAdjustmentResponse === false
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <span>하도급대금등 연동표</span>
+ <Badge variant="secondary">{vendorName}</Badge>
+ {isApplied && (
+ <Badge variant="default" className="bg-green-600 hover:bg-green-700">
+ 연동제 적용
+ </Badge>
+ )}
+ {isNotApplied && (
+ <Badge variant="outline" className="border-red-500 text-red-600">
+ 연동제 미적용
+ </Badge>
+ )}
+ {priceAdjustmentResponse === null && (
+ <Badge variant="outline">해당없음</Badge>
+ )}
+ </DialogTitle>
+ <DialogDescription>
+ 협력업체가 제출한 연동제 적용 정보입니다.
+ {isApplied && " (연동제 적용)"}
+ {isNotApplied && " (연동제 미적용)"}
+ </DialogDescription>
+ </DialogHeader>
+
+ {isLoading ? (
+ <div className="flex items-center justify-center py-12">
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
+ <span className="ml-2 text-muted-foreground">연동제 정보를 불러오는 중...</span>
+ </div>
+ ) : error ? (
+ <div className="py-8 text-center text-red-600">{error}</div>
+ ) : !data && priceAdjustmentResponse !== null ? (
+ <div className="py-8 text-center text-muted-foreground">연동제 상세 정보가 없습니다.</div>
+ ) : priceAdjustmentResponse === null ? (
+ <div className="py-8 text-center text-muted-foreground">해당 업체는 연동제 관련 응답을 하지 않았습니다.</div>
+ ) : (
+ <div className="space-y-6">
+ {/* 기본 정보 */}
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">기본 정보</h3>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="text-xs text-gray-500">물품등의 명칭</label>
+ <p className="text-sm font-medium">{data?.itemName || '-'}</p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">연동제 적용 여부</label>
+ <div className="mt-1">
+ {isApplied && (
+ <Badge variant="default" className="bg-green-600 hover:bg-green-700">
+ 예 (연동제 적용)
+ </Badge>
+ )}
+ {isNotApplied && (
+ <Badge variant="outline" className="border-red-500 text-red-600">
+ 아니오 (연동제 미적용)
+ </Badge>
+ )}
+ </div>
+ </div>
+ {isApplied && (
+ <div>
+ <label className="text-xs text-gray-500">조정대금 반영시점</label>
+ <p className="text-sm font-medium">{data?.adjustmentReflectionPoint || '-'}</p>
+ </div>
+ )}
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 원재료 정보 */}
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">원재료 정보</h3>
+ <div className="space-y-4">
+ {isApplied && (
+ <div>
+ <label className="text-xs text-gray-500">연동대상 주요 원재료</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data?.majorApplicableRawMaterial || '-'}
+ </p>
+ </div>
+ )}
+ {isNotApplied && (
+ <>
+ <div>
+ <label className="text-xs text-gray-500">연동 미적용 주요 원재료</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data?.majorNonApplicableRawMaterial || '-'}
+ </p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">연동 미적용 사유</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data?.nonApplicableReason || '-'}
+ </p>
+ </div>
+ </>
+ )}
+ </div>
+ </div>
+
+ {isApplied && data && (
+ <>
+ <Separator />
+
+ {/* 연동 공식 및 지표 */}
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">연동 공식 및 지표</h3>
+ <div className="space-y-4">
+ <div>
+ <label className="text-xs text-gray-500">하도급대금등 연동 산식</label>
+ <div className="p-3 bg-gray-50 rounded-md">
+ <p className="text-sm font-mono whitespace-pre-wrap">
+ {data.adjustmentFormula || '-'}
+ </p>
+ </div>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">원재료 가격 기준지표</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.rawMaterialPriceIndex || '-'}
+ </p>
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="text-xs text-gray-500">원재료 기준 가격의 변동률 산정을 위한 기준시점</label>
+ <p className="text-sm font-medium">{data.referenceDate ? formatDateValue(data.referenceDate) : '-'}</p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">원재료 기준 가격의 변동률 산정을 위한 비교시점</label>
+ <p className="text-sm font-medium">{data.comparisonDate ? formatDateValue(data.comparisonDate) : '-'}</p>
+ </div>
+ </div>
+ {data.adjustmentRatio && (
+ <div>
+ <label className="text-xs text-gray-500">반영비율</label>
+ <p className="text-sm font-medium">
+ {data.adjustmentRatio}%
+ </p>
+ </div>
+ )}
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 조정 조건 및 기타 */}
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">조정 조건 및 기타</h3>
+ <div className="space-y-4">
+ <div>
+ <label className="text-xs text-gray-500">조정요건</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.adjustmentConditions || '-'}
+ </p>
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="text-xs text-gray-500">조정주기</label>
+ <p className="text-sm font-medium">{data.adjustmentPeriod || '-'}</p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">조정일</label>
+ <p className="text-sm font-medium">{data.adjustmentDate ? formatDateValue(data.adjustmentDate) : '-'}</p>
+ </div>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">수탁기업(협력사)작성자</label>
+ <p className="text-sm font-medium">{data.contractorWriter || '-'}</p>
+ </div>
+ {data.notes && (
+ <div>
+ <label className="text-xs text-gray-500">기타사항</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.notes}
+ </p>
+ </div>
+ )}
+ </div>
+ </div>
+ </>
+ )}
+
+ {isNotApplied && data && (
+ <>
+ <Separator />
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">작성자 정보</h3>
+ <div>
+ <label className="text-xs text-gray-500">수탁기업(협력사) 작성자</label>
+ <p className="text-sm font-medium">{data.contractorWriter || '-'}</p>
+ </div>
+ </div>
+ </>
+ )}
+
+ {data && (
+ <>
+ <Separator />
+
+ {/* 메타 정보 */}
+ <div className="text-xs text-gray-500 space-y-1">
+ <p>작성일: {formatDateValue(data.createdAt)}</p>
+ <p>수정일: {formatDateValue(data.updatedAt)}</p>
+ </div>
+ </>
+ )}
+
+ <Separator />
+
+ {/* 참고 경고문 */}
+ <div className="text-xs text-red-600 space-y-2 bg-red-50 p-3 rounded-md border border-red-200">
+ <p className="font-medium">※ 참고사항</p>
+ <div className="space-y-1">
+ <p>• 납품대금의 10% 이상을 차지하는 주요 원재료가 있는 경우 모든 주요 원재료에 대해서 적용 또는 미적용에 대한 연동표를 작성해야 한다.</p>
+ <p>• 납품대급연동표를 허위로 작성하거나 근거자료를 허위로 제출할 경우 본 계약이 체결되지 않을 수 있으며, 본 계약이 체결되었더라도 계약의 전부 또는 일부를 해제 또는 해지할 수 있다.</p>
+ </div>
+ </div>
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+}
+
diff --git a/lib/bidding/handlers.ts b/lib/bidding/handlers.ts
index 03a85bb6..b422118d 100644
--- a/lib/bidding/handlers.ts
+++ b/lib/bidding/handlers.ts
@@ -10,6 +10,96 @@
import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils';
/**
+ * 결재 완료 시점을 기준으로 입찰서 제출기간 계산 및 업데이트
+ *
+ * 계산 로직:
+ * - baseDate = 결재완료일 날짜만 (00:00:00)
+ * - 시작일 = baseDate + submissionStartOffset일 + submissionStartDate의 시:분
+ * - 마감일 = 시작일(날짜만) + submissionDurationDays일 + submissionEndDate의 시:분
+ */
+async function calculateAndUpdateSubmissionDates(biddingId: number) {
+ const { default: db } = await import('@/db/db');
+ const { biddings } = await import('@/db/schema');
+ const { eq } = await import('drizzle-orm');
+
+ // 현재 입찰 정보 조회
+ const biddingInfo = await db
+ .select({
+ submissionStartOffset: biddings.submissionStartOffset,
+ submissionDurationDays: biddings.submissionDurationDays,
+ submissionStartDate: biddings.submissionStartDate, // 시간만 저장된 상태 (1970-01-01 HH:MM:00)
+ submissionEndDate: biddings.submissionEndDate, // 시간만 저장된 상태 (1970-01-01 HH:MM:00)
+ })
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
+ .limit(1);
+
+ if (biddingInfo.length === 0) {
+ debugError('[calculateAndUpdateSubmissionDates] 입찰 정보를 찾을 수 없음', { biddingId });
+ throw new Error('입찰 정보를 찾을 수 없습니다.');
+ }
+
+ const { submissionStartOffset, submissionDurationDays, submissionStartDate, submissionEndDate } = biddingInfo[0];
+
+ // 필수 값 검증
+ if (submissionStartOffset === null || submissionDurationDays === null) {
+ debugError('[calculateAndUpdateSubmissionDates] 오프셋 값이 설정되지 않음', { submissionStartOffset, submissionDurationDays });
+ throw new Error('입찰서 제출기간 오프셋이 설정되지 않았습니다.');
+ }
+
+ // 시간 추출 (기본값: 시작 09:00, 마감 18:00)
+ const startTime = submissionStartDate
+ ? { hours: submissionStartDate.getUTCHours(), minutes: submissionStartDate.getUTCMinutes() }
+ : { hours: 9, minutes: 0 };
+ const endTime = submissionEndDate
+ ? { hours: submissionEndDate.getUTCHours(), minutes: submissionEndDate.getUTCMinutes() }
+ : { hours: 18, minutes: 0 };
+
+ // 1. baseDate = 결재완료일 날짜만 (KST 기준 00:00:00)
+ const now = new Date();
+ const baseDate = new Date(now);
+ // KST 기준으로 날짜만 추출 (시간은 00:00:00)
+ baseDate.setHours(0, 0, 0, 0);
+
+ // 2. 시작일 = baseDate + offset일 + 시작시간
+ const calculatedStartDate = new Date(baseDate);
+ calculatedStartDate.setDate(calculatedStartDate.getDate() + submissionStartOffset);
+ calculatedStartDate.setHours(startTime.hours, startTime.minutes, 0, 0);
+
+ // 3. 마감일 = 시작일(날짜만) + duration일 + 마감시간
+ const calculatedEndDate = new Date(calculatedStartDate);
+ calculatedEndDate.setHours(0, 0, 0, 0); // 시작일의 날짜만
+ calculatedEndDate.setDate(calculatedEndDate.getDate() + submissionDurationDays);
+ calculatedEndDate.setHours(endTime.hours, endTime.minutes, 0, 0);
+
+ debugLog('[calculateAndUpdateSubmissionDates] 입찰서 제출기간 계산 완료', {
+ biddingId,
+ baseDate: baseDate.toISOString(),
+ submissionStartOffset,
+ submissionDurationDays,
+ startTime,
+ endTime,
+ calculatedStartDate: calculatedStartDate.toISOString(),
+ calculatedEndDate: calculatedEndDate.toISOString(),
+ });
+
+ // DB 업데이트
+ await db
+ .update(biddings)
+ .set({
+ submissionStartDate: calculatedStartDate,
+ submissionEndDate: calculatedEndDate,
+ updatedAt: new Date(),
+ })
+ .where(eq(biddings.id, biddingId));
+
+ return {
+ startDate: calculatedStartDate,
+ endDate: calculatedEndDate,
+ };
+}
+
+/**
* 입찰초대 핸들러 (결재 승인 후 실행됨)
*
* ✅ Internal 함수: 결재 워크플로우에서 자동 호출됨 (직접 호출 금지)
@@ -52,7 +142,7 @@ export async function requestBiddingInvitationInternal(payload: {
try {
// 1. 기본계약 발송
const { sendBiddingBasicContracts } = await import('@/lib/bidding/pre-quote/service');
-
+
const vendorDataForContract = payload.vendors.map(vendor => ({
vendorId: vendor.vendorId,
vendorName: vendor.vendorName,
@@ -86,7 +176,7 @@ export async function requestBiddingInvitationInternal(payload: {
debugLog('[BiddingInvitationHandler] 기본계약 발송 완료');
- // 2. 입찰 등록 진행 (상태를 bidding_opened로 변경)
+ // 2. 입찰 등록 진행 (상태를 bidding_opened로 변경, 입찰서 제출기간 자동 계산)
const { registerBidding } = await import('@/lib/bidding/detail/service');
const registerResult = await registerBidding(payload.biddingId, payload.currentUserId.toString());
diff --git a/lib/bidding/receive/biddings-receive-columns.tsx b/lib/bidding/receive/biddings-receive-columns.tsx
index 9650574a..f2e2df17 100644
--- a/lib/bidding/receive/biddings-receive-columns.tsx
+++ b/lib/bidding/receive/biddings-receive-columns.tsx
@@ -1,404 +1,404 @@
-"use client"
-
-import * as React from "react"
-import { type ColumnDef } from "@tanstack/react-table"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- Eye, Calendar, Users, CheckCircle, XCircle, Clock, AlertTriangle
-} from "lucide-react"
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import {
- biddingStatusLabels,
- contractTypeLabels,
-} from "@/db/schema"
-import { formatDate } from "@/lib/utils"
-import { DataTableRowAction } from "@/types/table"
-
-type BiddingReceiveItem = {
- id: number
- biddingNumber: string
- originalBiddingNumber: string | null
- title: string
- status: string
- contractType: string
- prNumber: string | null
- submissionStartDate: Date | null
- submissionEndDate: Date | null
- bidPicName: string | null
- supplyPicName: string | null
- createdBy: string | null
- createdAt: Date | null
- updatedAt: Date | null
-
- // 참여 현황
- participantExpected: number
- participantParticipated: number
- participantDeclined: number
- participantPending: number
-
- // 개찰 정보
- openedAt: Date | null
- openedBy: string | null
-}
-
-interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BiddingReceiveItem> | null>>
- onParticipantClick?: (biddingId: number, participantType: 'expected' | 'participated' | 'declined' | 'pending') => void
-}
-
-// 상태별 배지 색상
-const getStatusBadgeVariant = (status: string) => {
- switch (status) {
- case 'received_quotation':
- return 'secondary'
- case 'bidding_opened':
- return 'default'
- case 'bidding_closed':
- return 'outline'
- default:
- return 'outline'
- }
-}
-
-// 금액 포맷팅
-const formatCurrency = (amount: string | number | null, currency = 'KRW') => {
- if (!amount) return '-'
-
- const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
- if (isNaN(numAmount)) return '-'
-
- return new Intl.NumberFormat('ko-KR', {
- style: 'currency',
- currency: currency,
- minimumFractionDigits: 0,
- maximumFractionDigits: 0,
- }).format(numAmount)
-}
-
-export function getBiddingsReceiveColumns({ setRowAction, onParticipantClick }: GetColumnsProps): ColumnDef<BiddingReceiveItem>[] {
-
- return [
- // ░░░ 선택 ░░░
- {
- id: "select",
- header: "",
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => {
- // single select 모드에서는 다른 행들의 선택을 해제
- row.toggleSelected(!!value)
- }}
- aria-label="행 선택"
- />
- ),
- size: 50,
- enableSorting: false,
- enableHiding: false,
- },
-
- // ░░░ 입찰번호 ░░░
- {
- accessorKey: "biddingNumber",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰번호" />,
- cell: ({ row }) => (
- <div className="font-mono text-sm">
- {row.original.biddingNumber}
- </div>
- ),
- size: 120,
- meta: { excelHeader: "입찰번호" },
- },
-
- // ░░░ 입찰명 ░░░
- {
- accessorKey: "title",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰명" />,
- cell: ({ row }) => (
- <div className="truncate max-w-[200px]" title={row.original.title}>
- {/* <Button
- variant="link"
- className="p-0 h-auto text-left justify-start font-bold underline"
- onClick={() => setRowAction({ row, type: "view" })}
- >
- <div className="whitespace-pre-line">
- {row.original.title}
- </div>
- </Button> */}
- {row.original.title}
- </div>
- ),
- size: 200,
- meta: { excelHeader: "입찰명" },
- },
-
- // ░░░ 원입찰번호 ░░░
- {
- accessorKey: "originalBiddingNumber",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="원입찰번호" />,
- cell: ({ row }) => (
- <div className="font-mono text-sm">
- {row.original.originalBiddingNumber || '-'}
- </div>
- ),
- size: 120,
- meta: { excelHeader: "원입찰번호" },
- },
-
- // ░░░ 진행상태 ░░░
- {
- accessorKey: "status",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="진행상태" />,
- cell: ({ row }) => (
- <Badge variant={getStatusBadgeVariant(row.original.status)}>
- {biddingStatusLabels[row.original.status]}
- </Badge>
- ),
- size: 120,
- meta: { excelHeader: "진행상태" },
- },
-
- // ░░░ 계약구분 ░░░
- {
- accessorKey: "contractType",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약구분" />,
- cell: ({ row }) => (
- <Badge variant="outline">
- {contractTypeLabels[row.original.contractType]}
- </Badge>
- ),
- size: 100,
- meta: { excelHeader: "계약구분" },
- },
-
- // ░░░ 입찰서제출기간 ░░░
- {
- id: "submissionPeriod",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰서제출기간" />,
- cell: ({ row }) => {
- const startDate = row.original.submissionStartDate
- const endDate = row.original.submissionEndDate
-
- if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
-
- const startObj = new Date(startDate)
- const endObj = new Date(endDate)
-
- // 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>
- {formatKst(startObj)} ~ {formatKst(endObj)}
- </div>
- </div>
- )
- },
- size: 140,
- meta: { excelHeader: "입찰서제출기간" },
- },
-
- // ░░░ P/R번호 ░░░
- {
- accessorKey: "prNumber",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="P/R번호" />,
- cell: ({ row }) => (
- <span className="font-mono text-sm">{row.original.prNumber || '-'}</span>
- ),
- size: 100,
- meta: { excelHeader: "P/R번호" },
- },
-
- // ░░░ 입찰담당자 ░░░
- {
- accessorKey: "bidPicName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰담당자" />,
- cell: ({ row }) => {
- const bidPic = row.original.bidPicName
- const supplyPic = row.original.supplyPicName
-
- const displayName = bidPic || supplyPic || "-"
- return <span className="text-sm">{displayName}</span>
- },
- size: 100,
- meta: { excelHeader: "입찰담당자" },
- },
-
- // ░░░ 참여예정협력사 ░░░
- {
- id: "participantExpected",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여예정협력사" />,
- cell: ({ row }) => (
- <Button
- variant="ghost"
- size="sm"
- className="h-auto p-1 hover:bg-blue-50"
- onClick={() => onParticipantClick?.(row.original.id, 'expected')}
- disabled={row.original.participantExpected === 0}
- >
- <div className="flex items-center gap-1">
- <Users className="h-4 w-4 text-blue-500" />
- <span className="text-sm font-medium">{row.original.participantExpected}</span>
- </div>
- </Button>
- ),
- size: 100,
- meta: { excelHeader: "참여예정협력사" },
- },
-
- // ░░░ 참여협력사 ░░░
- {
- id: "participantParticipated",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여협력사" />,
- cell: ({ row }) => (
- <Button
- variant="ghost"
- size="sm"
- className="h-auto p-1 hover:bg-green-50"
- onClick={() => onParticipantClick?.(row.original.id, 'participated')}
- disabled={row.original.participantParticipated === 0}
- >
- <div className="flex items-center gap-1">
- <CheckCircle className="h-4 w-4 text-green-500" />
- <span className="text-sm font-medium">{row.original.participantParticipated}</span>
- </div>
- </Button>
- ),
- size: 100,
- meta: { excelHeader: "참여협력사" },
- },
-
- // ░░░ 포기협력사 ░░░
- {
- id: "participantDeclined",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="포기협력사" />,
- cell: ({ row }) => (
- <Button
- variant="ghost"
- size="sm"
- className="h-auto p-1 hover:bg-red-50"
- onClick={() => onParticipantClick?.(row.original.id, 'declined')}
- disabled={row.original.participantDeclined === 0}
- >
- <div className="flex items-center gap-1">
- <XCircle className="h-4 w-4 text-red-500" />
- <span className="text-sm font-medium">{row.original.participantDeclined}</span>
- </div>
- </Button>
- ),
- size: 100,
- meta: { excelHeader: "포기협력사" },
- },
-
- // ░░░ 미제출협력사 ░░░
- {
- id: "participantPending",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="미제출협력사" />,
- cell: ({ row }) => (
- <Button
- variant="ghost"
- size="sm"
- className="h-auto p-1 hover:bg-yellow-50"
- onClick={() => onParticipantClick?.(row.original.id, 'pending')}
- disabled={row.original.participantPending === 0}
- >
- <div className="flex items-center gap-1">
- <Clock className="h-4 w-4 text-yellow-500" />
- <span className="text-sm font-medium">{row.original.participantPending}</span>
- </div>
- </Button>
- ),
- size: 100,
- meta: { excelHeader: "미제출협력사" },
- },
-
- // ░░░ 개찰자명 ░░░
- {
- id: "openedBy",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="개찰자명" />,
- cell: ({ row }) => {
- const openedBy = row.original.openedBy
- return <span className="text-sm">{openedBy || '-'}</span>
- },
- size: 100,
- meta: { excelHeader: "개찰자명" },
- },
-
- // ░░░ 개찰일 ░░░
- {
- id: "openedAt",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="개찰일" />,
- cell: ({ row }) => {
- const openedAt = row.original.openedAt
- return <span className="text-sm">{openedAt ? formatDate(openedAt, "KR") : '-'}</span>
- },
- size: 100,
- meta: { excelHeader: "개찰일" },
- },
-
- // ░░░ 등록자 ░░░
- {
- accessorKey: "createdBy",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록자" />,
- cell: ({ row }) => (
- <span className="text-sm">{row.original.createdBy || '-'}</span>
- ),
- size: 100,
- meta: { excelHeader: "등록자" },
- },
-
- // ░░░ 등록일시 ░░░
- {
- accessorKey: "createdAt",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록일시" />,
- cell: ({ row }) => (
- <span className="text-sm">{row.original.createdAt ? formatDate(row.original.createdAt, "KR") : '-'}</span>
- ),
- size: 100,
- meta: { excelHeader: "등록일시" },
- },
-
- // ═══════════════════════════════════════════════════════════════
- // 액션
- // ═══════════════════════════════════════════════════════════════
- // {
- // id: "actions",
- // header: "액션",
- // cell: ({ row }) => (
- // <DropdownMenu>
- // <DropdownMenuTrigger asChild>
- // <Button variant="ghost" className="h-8 w-8 p-0">
- // <span className="sr-only">메뉴 열기</span>
- // <AlertTriangle className="h-4 w-4" />
- // </Button>
- // </DropdownMenuTrigger>
- // <DropdownMenuContent align="end">
- // <DropdownMenuItem onClick={() => setRowAction({ row, type: "view" })}>
- // <Eye className="mr-2 h-4 w-4" />
- // 상세보기
- // </DropdownMenuItem>
- // </DropdownMenuContent>
- // </DropdownMenu>
- // ),
- // size: 50,
- // enableSorting: false,
- // enableHiding: false,
- // },
- ]
-}
+"use client"
+
+import * as React from "react"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ Eye, Calendar, Users, CheckCircle, XCircle, Clock, AlertTriangle
+} from "lucide-react"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import {
+ biddingStatusLabels,
+ contractTypeLabels,
+} from "@/db/schema"
+import { formatDate } from "@/lib/utils"
+import { DataTableRowAction } from "@/types/table"
+
+type BiddingReceiveItem = {
+ id: number
+ biddingNumber: string
+ originalBiddingNumber: string | null
+ title: string
+ status: string
+ contractType: string
+ prNumber: string | null
+ submissionStartDate: Date | null
+ submissionEndDate: Date | null
+ bidPicName: string | null
+ supplyPicName: string | null
+ createdBy: string | null
+ createdAt: Date | null
+ updatedAt: Date | null
+
+ // 참여 현황
+ participantExpected: number
+ participantParticipated: number
+ participantDeclined: number
+ participantPending: number
+
+ // 개찰 정보
+ openedAt: Date | null
+ openedBy: string | null
+}
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BiddingReceiveItem> | null>>
+ onParticipantClick?: (biddingId: number, participantType: 'expected' | 'participated' | 'declined' | 'pending') => void
+}
+
+// 상태별 배지 색상
+const getStatusBadgeVariant = (status: string) => {
+ switch (status) {
+ case 'received_quotation':
+ return 'secondary'
+ case 'bidding_opened':
+ return 'default'
+ case 'bidding_closed':
+ return 'outline'
+ default:
+ return 'outline'
+ }
+}
+
+// 금액 포맷팅
+const formatCurrency = (amount: string | number | null, currency = 'KRW') => {
+ if (!amount) return '-'
+
+ const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
+ if (isNaN(numAmount)) return '-'
+
+ return new Intl.NumberFormat('ko-KR', {
+ style: 'currency',
+ currency: currency,
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ }).format(numAmount)
+}
+
+export function getBiddingsReceiveColumns({ setRowAction, onParticipantClick }: GetColumnsProps): ColumnDef<BiddingReceiveItem>[] {
+
+ return [
+ // ░░░ 선택 ░░░
+ {
+ id: "select",
+ header: "",
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => {
+ // single select 모드에서는 다른 행들의 선택을 해제
+ row.toggleSelected(!!value)
+ }}
+ aria-label="행 선택"
+ />
+ ),
+ size: 50,
+ enableSorting: false,
+ enableHiding: false,
+ },
+
+ // ░░░ 입찰번호 ░░░
+ {
+ accessorKey: "biddingNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰번호" />,
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">
+ {row.original.biddingNumber}
+ </div>
+ ),
+ size: 120,
+ meta: { excelHeader: "입찰번호" },
+ },
+
+ // ░░░ 입찰명 ░░░
+ {
+ accessorKey: "title",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰명" />,
+ cell: ({ row }) => (
+ <div className="truncate max-w-[200px]" title={row.original.title}>
+ {/* <Button
+ variant="link"
+ className="p-0 h-auto text-left justify-start font-bold underline"
+ onClick={() => setRowAction({ row, type: "view" })}
+ >
+ <div className="whitespace-pre-line">
+ {row.original.title}
+ </div>
+ </Button> */}
+ {row.original.title}
+ </div>
+ ),
+ size: 200,
+ meta: { excelHeader: "입찰명" },
+ },
+
+ // ░░░ 원입찰번호 ░░░
+ {
+ accessorKey: "originalBiddingNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="원입찰번호" />,
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">
+ {row.original.originalBiddingNumber || '-'}
+ </div>
+ ),
+ size: 120,
+ meta: { excelHeader: "원입찰번호" },
+ },
+
+ // ░░░ 진행상태 ░░░
+ {
+ accessorKey: "status",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="진행상태" />,
+ cell: ({ row }) => (
+ <Badge variant={getStatusBadgeVariant(row.original.status)}>
+ {biddingStatusLabels[row.original.status]}
+ </Badge>
+ ),
+ size: 120,
+ meta: { excelHeader: "진행상태" },
+ },
+
+ // ░░░ 계약구분 ░░░
+ {
+ accessorKey: "contractType",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약구분" />,
+ cell: ({ row }) => (
+ <Badge variant="outline">
+ {contractTypeLabels[row.original.contractType]}
+ </Badge>
+ ),
+ size: 100,
+ meta: { excelHeader: "계약구분" },
+ },
+
+ // ░░░ 입찰서제출기간 ░░░
+ {
+ id: "submissionPeriod",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰서제출기간" />,
+ cell: ({ row }) => {
+ const startDate = row.original.submissionStartDate
+ const endDate = row.original.submissionEndDate
+
+ if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
+
+ const startObj = new Date(startDate)
+ const endObj = new Date(endDate)
+
+ // 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>
+ {formatKst(startObj)} ~ {formatKst(endObj)}
+ </div>
+ </div>
+ )
+ },
+ size: 140,
+ meta: { excelHeader: "입찰서제출기간" },
+ },
+
+ // ░░░ P/R번호 ░░░
+ {
+ accessorKey: "prNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="P/R번호" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.prNumber || '-'}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "P/R번호" },
+ },
+
+ // ░░░ 입찰담당자 ░░░
+ {
+ accessorKey: "bidPicName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰담당자" />,
+ cell: ({ row }) => {
+ const bidPic = row.original.bidPicName
+ const supplyPic = row.original.supplyPicName
+
+ const displayName = bidPic || supplyPic || "-"
+ return <span className="text-sm">{displayName}</span>
+ },
+ size: 100,
+ meta: { excelHeader: "입찰담당자" },
+ },
+
+ // ░░░ 참여예정협력사 ░░░
+ {
+ id: "participantExpected",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여예정협력사" />,
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-auto p-1 hover:bg-blue-50"
+ onClick={() => onParticipantClick?.(row.original.id, 'expected')}
+ disabled={row.original.participantExpected === 0}
+ >
+ <div className="flex items-center gap-1">
+ <Users className="h-4 w-4 text-blue-500" />
+ <span className="text-sm font-medium">{row.original.participantExpected}</span>
+ </div>
+ </Button>
+ ),
+ size: 100,
+ meta: { excelHeader: "참여예정협력사" },
+ },
+
+ // ░░░ 참여협력사 ░░░
+ {
+ id: "participantParticipated",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여협력사" />,
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-auto p-1 hover:bg-green-50"
+ onClick={() => onParticipantClick?.(row.original.id, 'participated')}
+ disabled={row.original.participantParticipated === 0}
+ >
+ <div className="flex items-center gap-1">
+ <CheckCircle className="h-4 w-4 text-green-500" />
+ <span className="text-sm font-medium">{row.original.participantParticipated}</span>
+ </div>
+ </Button>
+ ),
+ size: 100,
+ meta: { excelHeader: "참여협력사" },
+ },
+
+ // ░░░ 포기협력사 ░░░
+ {
+ id: "participantDeclined",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="포기협력사" />,
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-auto p-1 hover:bg-red-50"
+ onClick={() => onParticipantClick?.(row.original.id, 'declined')}
+ disabled={row.original.participantDeclined === 0}
+ >
+ <div className="flex items-center gap-1">
+ <XCircle className="h-4 w-4 text-red-500" />
+ <span className="text-sm font-medium">{row.original.participantDeclined}</span>
+ </div>
+ </Button>
+ ),
+ size: 100,
+ meta: { excelHeader: "포기협력사" },
+ },
+
+ // ░░░ 미제출협력사 ░░░
+ {
+ id: "participantPending",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="미제출협력사" />,
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-auto p-1 hover:bg-yellow-50"
+ onClick={() => onParticipantClick?.(row.original.id, 'pending')}
+ disabled={row.original.participantPending === 0}
+ >
+ <div className="flex items-center gap-1">
+ <Clock className="h-4 w-4 text-yellow-500" />
+ <span className="text-sm font-medium">{row.original.participantPending}</span>
+ </div>
+ </Button>
+ ),
+ size: 100,
+ meta: { excelHeader: "미제출협력사" },
+ },
+
+ // ░░░ 개찰자명 ░░░
+ {
+ id: "openedBy",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="개찰자명" />,
+ cell: ({ row }) => {
+ const openedBy = row.original.openedBy
+ return <span className="text-sm">{openedBy || '-'}</span>
+ },
+ size: 100,
+ meta: { excelHeader: "개찰자명" },
+ },
+
+ // ░░░ 개찰일 ░░░
+ {
+ id: "openedAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="개찰일" />,
+ cell: ({ row }) => {
+ const openedAt = row.original.openedAt
+ return <span className="text-sm">{openedAt ? formatDate(openedAt, "KR") : '-'}</span>
+ },
+ size: 100,
+ meta: { excelHeader: "개찰일" },
+ },
+
+ // ░░░ 등록자 ░░░
+ {
+ accessorKey: "createdBy",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록자" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{row.original.createdBy || '-'}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "등록자" },
+ },
+
+ // ░░░ 등록일시 ░░░
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록일시" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{row.original.createdAt ? formatDate(row.original.createdAt, "KR") : '-'}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "등록일시" },
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 액션
+ // ═══════════════════════════════════════════════════════════════
+ // {
+ // id: "actions",
+ // header: "액션",
+ // cell: ({ row }) => (
+ // <DropdownMenu>
+ // <DropdownMenuTrigger asChild>
+ // <Button variant="ghost" className="h-8 w-8 p-0">
+ // <span className="sr-only">메뉴 열기</span>
+ // <AlertTriangle className="h-4 w-4" />
+ // </Button>
+ // </DropdownMenuTrigger>
+ // <DropdownMenuContent align="end">
+ // <DropdownMenuItem onClick={() => setRowAction({ row, type: "view" })}>
+ // <Eye className="mr-2 h-4 w-4" />
+ // 상세보기
+ // </DropdownMenuItem>
+ // </DropdownMenuContent>
+ // </DropdownMenu>
+ // ),
+ // size: 50,
+ // enableSorting: false,
+ // enableHiding: false,
+ // },
+ ]
+}
diff --git a/lib/bidding/receive/biddings-receive-table.tsx b/lib/bidding/receive/biddings-receive-table.tsx
index 2b141d5e..6a48fa79 100644
--- a/lib/bidding/receive/biddings-receive-table.tsx
+++ b/lib/bidding/receive/biddings-receive-table.tsx
@@ -1,296 +1,297 @@
-"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import { useSession } from "next-auth/react"
-import type {
- DataTableAdvancedFilterField,
- DataTableFilterField,
- DataTableRowAction,
-} from "@/types/table"
-import { Button } from "@/components/ui/button"
-import { Loader2 } from "lucide-react"
-import { toast } from "sonner"
-
-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 { getBiddingsReceiveColumns } from "./biddings-receive-columns"
-import { getBiddingsForReceive } from "@/lib/bidding/service"
-import {
- biddingStatusLabels,
- contractTypeLabels,
-} from "@/db/schema"
-// import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs"
-import { openBiddingAction } from "@/lib/bidding/actions"
-import { BiddingParticipantsDialog } from "@/components/bidding/receive/bidding-participants-dialog"
-import { getAllBiddingCompanies } from "@/lib/bidding/detail/service"
-
-type BiddingReceiveItem = {
- id: number
- biddingNumber: string
- originalBiddingNumber: string | null
- title: string
- status: string
- contractType: string
- prNumber: string | null
- submissionStartDate: Date | null
- submissionEndDate: Date | null
- bidPicName: string | null
- supplyPicName: string | null
- createdBy: string | null
- createdAt: Date | null
- updatedAt: Date | null
-
- // 참여 현황
- participantExpected: number
- participantParticipated: number
- participantDeclined: number
- participantPending: number
- participantFinalSubmitted: number
-
- // 개찰 정보
- openedAt: Date | null
- openedBy: string | null
-}
-
-interface BiddingsReceiveTableProps {
- promises: Promise<
- [
- Awaited<ReturnType<typeof getBiddingsForReceive>>
- ]
- >
-}
-
-export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) {
- const [biddingsResult] = React.use(promises)
-
- // biddingsResult에서 data와 pageCount 추출
- const { data, pageCount } = biddingsResult
-
- const [isCompact, setIsCompact] = React.useState<boolean>(false)
- // const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
- // const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
- const [selectedBidding, setSelectedBidding] = React.useState<BiddingReceiveItem | null>(null)
-
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingReceiveItem> | null>(null)
- const [isOpeningBidding, setIsOpeningBidding] = React.useState(false)
-
- // 협력사 다이얼로그 관련 상태
- const [participantsDialogOpen, setParticipantsDialogOpen] = React.useState(false)
- const [selectedParticipantType, setSelectedParticipantType] = React.useState<'expected' | 'participated' | 'declined' | 'pending' | null>(null)
- const [selectedBiddingId, setSelectedBiddingId] = React.useState<number | null>(null)
- const [participantCompanies, setParticipantCompanies] = React.useState<any[]>([])
- const [isLoadingParticipants, setIsLoadingParticipants] = React.useState(false)
-
- const router = useRouter()
- const { data: session } = useSession()
-
- // 협력사 클릭 핸들러
- const handleParticipantClick = React.useCallback(async (biddingId: number, participantType: 'expected' | 'participated' | 'declined' | 'pending') => {
- setSelectedBiddingId(biddingId)
- setSelectedParticipantType(participantType)
- setIsLoadingParticipants(true)
- setParticipantsDialogOpen(true)
-
- try {
- // 협력사 데이터 로드 (모든 초대된 협력사)
- const companies = await getAllBiddingCompanies(biddingId)
-
- console.log('Loaded companies:', companies)
-
- // 필터링 없이 모든 데이터 그대로 표시
- // invitationStatus가 그대로 다이얼로그에 표시됨
- setParticipantCompanies(companies)
- } catch (error) {
- console.error('Failed to load participant companies:', error)
- toast.error('협력사 목록을 불러오는데 실패했습니다.')
- setParticipantCompanies([])
- } finally {
- setIsLoadingParticipants(false)
- }
- }, [])
-
- const columns = React.useMemo(
- () => getBiddingsReceiveColumns({ setRowAction, onParticipantClick: handleParticipantClick }),
- [setRowAction, handleParticipantClick]
- )
-
- // rowAction 변경 감지하여 해당 다이얼로그 열기
- React.useEffect(() => {
- if (rowAction) {
- setSelectedBidding(rowAction.row.original)
-
- switch (rowAction.type) {
- case "view":
- // 상세 페이지로 이동
- router.push(`/evcp/bid/${rowAction.row.original.id}`)
- break
- default:
- break
- }
- }
- }, [rowAction, router])
-
- const filterFields: DataTableFilterField<BiddingReceiveItem>[] = [
- {
- id: "biddingNumber",
- label: "입찰번호",
- placeholder: "입찰번호를 입력하세요",
- },
- {
- id: "prNumber",
- label: "P/R번호",
- placeholder: "P/R번호를 입력하세요",
- },
- {
- id: "title",
- label: "입찰명",
- placeholder: "입찰명을 입력하세요",
- },
- ]
-
- const advancedFilterFields: DataTableAdvancedFilterField<BiddingReceiveItem>[] = [
- { id: "title", label: "입찰명", type: "text" },
- { id: "biddingNumber", label: "입찰번호", type: "text" },
- { id: "bidPicName", label: "입찰담당자", type: "text" },
- {
- id: "status",
- label: "진행상태",
- type: "multi-select",
- options: Object.entries(biddingStatusLabels).map(([value, label]) => ({
- label,
- value,
- })),
- },
- {
- id: "contractType",
- label: "계약구분",
- type: "select",
- options: Object.entries(contractTypeLabels).map(([value, label]) => ({
- label,
- value,
- })),
- },
- { id: "createdAt", label: "등록일", type: "date" },
- { id: "submissionStartDate", label: "제출시작일", type: "date" },
- { id: "submissionEndDate", label: "제출마감일", type: "date" },
- ]
-
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- enableRowSelection: true,
- initialState: {
- sorting: [{ id: "createdAt", desc: true }],
- columnPinning: { right: ["actions"] },
- },
- getRowId: (originalRow) => String(originalRow.id),
- shallow: false,
- clearOnDefault: true,
- })
-
- const handleCompactChange = React.useCallback((compact: boolean) => {
- setIsCompact(compact)
- }, [])
-
-
- // 선택된 행 가져오기
- const selectedRows = table.getFilteredSelectedRowModel().rows
- const selectedBiddingForAction = selectedRows.length > 0 ? selectedRows[0].original : null
-
- // 개찰 가능 여부 확인
- const canOpen = React.useMemo(() => {
- if (!selectedBiddingForAction) return false
-
- const now = new Date()
- const submissionEndDate = selectedBiddingForAction.submissionEndDate ? new Date(selectedBiddingForAction.submissionEndDate) : null
-
- // 1. 입찰 마감일이 지났으면 무조건 가능
- if (submissionEndDate && now > submissionEndDate) return true
-
- // 2. 입찰 기간 내 조기개찰 조건 확인
- // - 미제출 협력사가 0이어야 함 (참여예정 = 최종제출 + 포기)
- const participatedOrDeclined = selectedBiddingForAction.participantFinalSubmitted + selectedBiddingForAction.participantDeclined
- const isEarlyOpenPossible = participatedOrDeclined === selectedBiddingForAction.participantExpected
-
- return isEarlyOpenPossible
- }, [selectedBiddingForAction])
-
- const handleOpenBidding = React.useCallback(async () => {
- if (!selectedBiddingForAction) return
-
- setIsOpeningBidding(true)
- try {
- const result = await openBiddingAction(selectedBiddingForAction.id)
- if (result.success) {
- toast.success("개찰이 완료되었습니다.")
- // 데이터 리프레시
- window.location.reload()
- } else {
- toast.error(result.message || "개찰에 실패했습니다.")
- }
- } catch (error) {
- toast.error("개찰 중 오류가 발생했습니다.")
- } finally {
- setIsOpeningBidding(false)
- }
- }, [selectedBiddingForAction])
-
- return (
- <>
- <DataTable
- table={table}
- compact={isCompact}
- >
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- enableCompactToggle={true}
- compactStorageKey="biddingsReceiveTableCompact"
- onCompactChange={handleCompactChange}
- >
- <div className="flex items-center gap-2">
- <Button
- variant="outline"
- size="sm"
- onClick={handleOpenBidding}
- disabled={!selectedBiddingForAction || !canOpen || isOpeningBidding}
- >
- {isOpeningBidding && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- 개찰
- </Button>
- </div>
- </DataTableAdvancedToolbar>
- </DataTable>
-
- {/* 사양설명회 다이얼로그 */}
- {/* <SpecificationMeetingDialog
- open={specMeetingDialogOpen}
- onOpenChange={handleSpecMeetingDialogClose}
- bidding={selectedBidding}
- /> */}
-
- {/* PR 문서 다이얼로그 */}
- {/* <PrDocumentsDialog
- open={prDocumentsDialogOpen}
- onOpenChange={handlePrDocumentsDialogClose}
- bidding={selectedBidding}
- /> */}
-
- {/* 참여 협력사 다이얼로그 */}
- <BiddingParticipantsDialog
- open={participantsDialogOpen}
- onOpenChange={setParticipantsDialogOpen}
- biddingId={selectedBiddingId}
- participantType={selectedParticipantType}
- companies={participantCompanies}
- />
- </>
- )
-}
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { useSession } from "next-auth/react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import { Button } from "@/components/ui/button"
+import { Loader2 } from "lucide-react"
+import { toast } from "sonner"
+
+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 { getBiddingsReceiveColumns } from "./biddings-receive-columns"
+import { getBiddingsForReceive } from "@/lib/bidding/service"
+import {
+ biddingStatusLabels,
+ contractTypeLabels,
+} from "@/db/schema"
+// import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs"
+import { openBiddingAction } from "@/lib/bidding/actions"
+import { BiddingParticipantsDialog } from "@/components/bidding/receive/bidding-participants-dialog"
+import { getAllBiddingCompanies } from "@/lib/bidding/detail/service"
+
+type BiddingReceiveItem = {
+ id: number
+ biddingNumber: string
+ originalBiddingNumber: string | null
+ title: string
+ status: string
+ contractType: string
+ prNumber: string | null
+ submissionStartDate: Date | null
+ submissionEndDate: Date | null
+ bidPicName: string | null
+ supplyPicName: string | null
+ createdBy: string | null
+ createdAt: Date | null
+ updatedAt: Date | null
+
+ // 참여 현황
+ participantExpected: number
+ participantParticipated: number
+ participantDeclined: number
+ participantPending: number
+ participantFinalSubmitted: number
+
+ // 개찰 정보
+ openedAt: Date | null
+ openedBy: string | null
+}
+
+interface BiddingsReceiveTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getBiddingsForReceive>>
+ ]
+ >
+}
+
+export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) {
+ const [biddingsResult] = React.use(promises)
+
+ // biddingsResult에서 data와 pageCount 추출
+ const { data, pageCount } = biddingsResult
+
+ const [isCompact, setIsCompact] = React.useState<boolean>(false)
+ // const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
+ // const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
+ const [selectedBidding, setSelectedBidding] = React.useState<BiddingReceiveItem | null>(null)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingReceiveItem> | null>(null)
+ const [isOpeningBidding, setIsOpeningBidding] = React.useState(false)
+
+ // 협력사 다이얼로그 관련 상태
+ const [participantsDialogOpen, setParticipantsDialogOpen] = React.useState(false)
+ const [selectedParticipantType, setSelectedParticipantType] = React.useState<'expected' | 'participated' | 'declined' | 'pending' | null>(null)
+ const [selectedBiddingId, setSelectedBiddingId] = React.useState<number | null>(null)
+ const [participantCompanies, setParticipantCompanies] = React.useState<any[]>([])
+ const [isLoadingParticipants, setIsLoadingParticipants] = React.useState(false)
+
+ const router = useRouter()
+ const { data: session } = useSession()
+
+ // 협력사 클릭 핸들러
+ const handleParticipantClick = React.useCallback(async (biddingId: number, participantType: 'expected' | 'participated' | 'declined' | 'pending') => {
+ setSelectedBiddingId(biddingId)
+ setSelectedParticipantType(participantType)
+ setIsLoadingParticipants(true)
+ setParticipantsDialogOpen(true)
+
+ try {
+ // 협력사 데이터 로드 (모든 초대된 협력사)
+ const companies = await getAllBiddingCompanies(biddingId)
+
+ console.log('Loaded companies:', companies)
+
+ // 필터링 없이 모든 데이터 그대로 표시
+ // invitationStatus가 그대로 다이얼로그에 표시됨
+ setParticipantCompanies(companies)
+ } catch (error) {
+ console.error('Failed to load participant companies:', error)
+ toast.error('협력사 목록을 불러오는데 실패했습니다.')
+ setParticipantCompanies([])
+ } finally {
+ setIsLoadingParticipants(false)
+ }
+ }, [])
+
+ const columns = React.useMemo(
+ () => getBiddingsReceiveColumns({ setRowAction, onParticipantClick: handleParticipantClick }),
+ [setRowAction, handleParticipantClick]
+ )
+
+ // rowAction 변경 감지하여 해당 다이얼로그 열기
+ React.useEffect(() => {
+ if (rowAction) {
+ setSelectedBidding(rowAction.row.original)
+
+ switch (rowAction.type) {
+ case "view":
+ // 상세 페이지로 이동
+ router.push(`/evcp/bid/${rowAction.row.original.id}`)
+ break
+ default:
+ break
+ }
+ }
+ }, [rowAction, router])
+
+ const filterFields: DataTableFilterField<BiddingReceiveItem>[] = [
+ {
+ id: "biddingNumber",
+ label: "입찰번호",
+ placeholder: "입찰번호를 입력하세요",
+ },
+ {
+ id: "prNumber",
+ label: "P/R번호",
+ placeholder: "P/R번호를 입력하세요",
+ },
+ {
+ id: "title",
+ label: "입찰명",
+ placeholder: "입찰명을 입력하세요",
+ },
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<BiddingReceiveItem>[] = [
+ { id: "title", label: "입찰명", type: "text" },
+ { id: "biddingNumber", label: "입찰번호", type: "text" },
+ { id: "bidPicName", label: "입찰담당자", type: "text" },
+ {
+ id: "status",
+ label: "진행상태",
+ type: "multi-select",
+ options: Object.entries(biddingStatusLabels).map(([value, label]) => ({
+ label,
+ value,
+ })),
+ },
+ {
+ id: "contractType",
+ label: "계약구분",
+ type: "select",
+ options: Object.entries(contractTypeLabels).map(([value, label]) => ({
+ label,
+ value,
+ })),
+ },
+ { id: "createdAt", label: "등록일", type: "date" },
+ { id: "submissionStartDate", label: "제출시작일", type: "date" },
+ { id: "submissionEndDate", label: "제출마감일", type: "date" },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ enableRowSelection: true,
+ enableMultiRowSelection: false, // 단일 선택만 가능
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ const handleCompactChange = React.useCallback((compact: boolean) => {
+ setIsCompact(compact)
+ }, [])
+
+
+ // 선택된 행 가져오기
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const selectedBiddingForAction = selectedRows.length > 0 ? selectedRows[0].original : null
+
+ // 개찰 가능 여부 확인
+ const canOpen = React.useMemo(() => {
+ if (!selectedBiddingForAction) return false
+
+ const now = new Date()
+ const submissionEndDate = selectedBiddingForAction.submissionEndDate ? new Date(selectedBiddingForAction.submissionEndDate) : null
+
+ // 1. 입찰 마감일이 지났으면 무조건 가능
+ if (submissionEndDate && now > submissionEndDate) return true
+
+ // 2. 입찰 기간 내 조기개찰 조건 확인
+ // - 미제출 협력사가 0이어야 함 (참여예정 = 최종제출 + 포기)
+ const participatedOrDeclined = selectedBiddingForAction.participantFinalSubmitted + selectedBiddingForAction.participantDeclined
+ const isEarlyOpenPossible = participatedOrDeclined === selectedBiddingForAction.participantExpected
+
+ return isEarlyOpenPossible
+ }, [selectedBiddingForAction])
+
+ const handleOpenBidding = React.useCallback(async () => {
+ if (!selectedBiddingForAction) return
+
+ setIsOpeningBidding(true)
+ try {
+ const result = await openBiddingAction(selectedBiddingForAction.id)
+ if (result.success) {
+ toast.success("개찰이 완료되었습니다.")
+ // 데이터 리프레시
+ window.location.reload()
+ } else {
+ toast.error(result.message || "개찰에 실패했습니다.")
+ }
+ } catch (error) {
+ toast.error("개찰 중 오류가 발생했습니다.")
+ } finally {
+ setIsOpeningBidding(false)
+ }
+ }, [selectedBiddingForAction])
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ compact={isCompact}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ enableCompactToggle={true}
+ compactStorageKey="biddingsReceiveTableCompact"
+ onCompactChange={handleCompactChange}
+ >
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleOpenBidding}
+ disabled={!selectedBiddingForAction || !canOpen || isOpeningBidding}
+ >
+ {isOpeningBidding && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ 개찰
+ </Button>
+ </div>
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* 사양설명회 다이얼로그 */}
+ {/* <SpecificationMeetingDialog
+ open={specMeetingDialogOpen}
+ onOpenChange={handleSpecMeetingDialogClose}
+ bidding={selectedBidding}
+ /> */}
+
+ {/* PR 문서 다이얼로그 */}
+ {/* <PrDocumentsDialog
+ open={prDocumentsDialogOpen}
+ onOpenChange={handlePrDocumentsDialogClose}
+ bidding={selectedBidding}
+ /> */}
+
+ {/* 참여 협력사 다이얼로그 */}
+ <BiddingParticipantsDialog
+ open={participantsDialogOpen}
+ onOpenChange={setParticipantsDialogOpen}
+ biddingId={selectedBiddingId}
+ participantType={selectedParticipantType}
+ companies={participantCompanies}
+ />
+ </>
+ )
+}
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index 76cd31f7..d45e9286 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -40,7 +40,8 @@ import {
import { revalidatePath } from 'next/cache'
import { filterColumns } from '@/lib/filter-columns'
import { GetBiddingsSchema, CreateBiddingSchema } from './validation'
-import { saveFile } from '../file-stroage'
+import { saveFile, saveBuffer } from '../file-stroage'
+import { decryptBufferWithServerAction } from '@/components/drm/drmUtils'
import { getVendorPricesForBidding } from './detail/service'
import { getPrItemsForBidding } from './pre-quote/service'
@@ -1913,12 +1914,14 @@ export async function updateBiddingBasicInfo(
}
}
-// 입찰 일정 업데이트
+// 입찰 일정 업데이트 (오프셋 기반)
export async function updateBiddingSchedule(
biddingId: number,
schedule: {
- submissionStartDate?: string
- submissionEndDate?: string
+ submissionStartOffset?: number // 시작일 오프셋 (결재 후 n일)
+ submissionStartTime?: string // 시작 시간 (HH:MM)
+ submissionDurationDays?: number // 기간 (시작일 + n일)
+ submissionEndTime?: string // 마감 시간 (HH:MM)
remarks?: string
isUrgent?: boolean
hasSpecificationMeeting?: boolean
@@ -1949,14 +1952,28 @@ export async function updateBiddingSchedule(
return new Date(`${dateStr}:00+09:00`)
}
+ // 시간 문자열(HH:MM)을 임시 timestamp로 변환 (1970-01-01 HH:MM:00 UTC)
+ // 결재 완료 시 실제 날짜로 계산됨
+ const timeToTimestamp = (timeStr?: string): Date | null => {
+ if (!timeStr) return null
+ const [hours, minutes] = timeStr.split(':').map(Number)
+ const date = new Date(0) // 1970-01-01 00:00:00 UTC
+ date.setUTCHours(hours, minutes, 0, 0)
+ return date
+ }
+
return await db.transaction(async (tx) => {
const updateData: any = {
updatedAt: new Date(),
updatedBy: userName,
}
- if (schedule.submissionStartDate !== undefined) updateData.submissionStartDate = parseDate(schedule.submissionStartDate) || null
- if (schedule.submissionEndDate !== undefined) updateData.submissionEndDate = parseDate(schedule.submissionEndDate) || null
+ // 오프셋 기반 필드 저장
+ if (schedule.submissionStartOffset !== undefined) updateData.submissionStartOffset = schedule.submissionStartOffset
+ if (schedule.submissionDurationDays !== undefined) updateData.submissionDurationDays = schedule.submissionDurationDays
+ // 시간은 timestamp 필드에 임시 저장 (1970-01-01 HH:MM:00)
+ if (schedule.submissionStartTime !== undefined) updateData.submissionStartDate = timeToTimestamp(schedule.submissionStartTime)
+ if (schedule.submissionEndTime !== undefined) updateData.submissionEndDate = timeToTimestamp(schedule.submissionEndTime)
if (schedule.remarks !== undefined) updateData.remarks = schedule.remarks
if (schedule.isUrgent !== undefined) updateData.isUrgent = schedule.isUrgent
if (schedule.hasSpecificationMeeting !== undefined) updateData.hasSpecificationMeeting = schedule.hasSpecificationMeeting
@@ -3240,32 +3257,34 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
if (existingDocuments.length > 0) {
for (const doc of existingDocuments) {
try {
- // 기존 파일을 Buffer로 읽어서 File 객체 생성
- const { readFileSync, existsSync } = await import('fs')
+ // 기존 파일 경로 확인 및 Buffer로 읽기
+ const { readFile, access, constants } = await import('fs/promises')
const { join } = await import('path')
+ // 파일 경로 정규화
const oldFilePath = doc.filePath.startsWith('/uploads/')
? join(process.cwd(), 'public', doc.filePath)
+ : doc.filePath.startsWith('/')
+ ? join(process.cwd(), 'public', doc.filePath)
: doc.filePath
- if (!existsSync(oldFilePath)) {
- console.warn(`원본 파일이 존재하지 않음: ${oldFilePath}`)
+ // 파일 존재 여부 확인
+ try {
+ await access(oldFilePath, constants.R_OK)
+ } catch {
+ console.warn(`원본 파일이 존재하지 않거나 읽을 수 없음: ${oldFilePath}`)
continue
}
- // 파일 내용을 읽어서 Buffer 생성
- const fileBuffer = readFileSync(oldFilePath)
-
- // Buffer를 File 객체로 변환 (브라우저 File API 시뮬레이션)
- const file = new File([fileBuffer], doc.fileName, {
- type: doc.mimeType || 'application/octet-stream'
- })
+ // 파일 내용을 Buffer로 읽기
+ const fileBuffer = await readFile(oldFilePath)
- // saveFile을 사용하여 새 파일 저장
- const saveResult = await saveFile({
- file,
+ // saveBuffer를 사용하여 새 파일 저장 (File 객체 변환 없이 직접 저장)
+ const saveResult = await saveBuffer({
+ buffer: fileBuffer,
+ fileName: doc.fileName,
directory: `biddings/${newBidding.id}/attachments/${doc.documentType === 'evaluation_doc' ? 'shi' : 'vendor'}`,
- originalName: `copied_${Date.now()}_${doc.fileName}`,
+ originalName: doc.originalFileName || doc.fileName,
userId: userName
})
diff --git a/lib/bidding/vendor/components/pr-items-pricing-table.tsx b/lib/bidding/vendor/components/pr-items-pricing-table.tsx
index 5afb2b67..6910e360 100644
--- a/lib/bidding/vendor/components/pr-items-pricing-table.tsx
+++ b/lib/bidding/vendor/components/pr-items-pricing-table.tsx
@@ -4,7 +4,17 @@ import * as React from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
-
+import { Button } from '@/components/ui/button'
+import { Calendar } from '@/components/ui/calendar'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import {
Table,
@@ -16,10 +26,12 @@ import {
} from '@/components/ui/table'
import {
Package,
-
Download,
- Calculator
+ Calculator,
+ CalendarIcon
} from 'lucide-react'
+import { format } from 'date-fns'
+import { cn } from '@/lib/utils'
import { formatDate } from '@/lib/utils'
import { downloadFile, formatFileSize, getFileInfo } from '@/lib/file-download'
import { getSpecDocumentsForPrItem } from '../../pre-quote/service'
@@ -186,6 +198,8 @@ export function PrItemsPricingTable({
}: PrItemsPricingTableProps) {
const [quotations, setQuotations] = React.useState<PrItemQuotation[]>([])
const [specDocuments, setSpecDocuments] = React.useState<Record<number, SpecDocument[]>>({})
+ const [showBulkDateDialog, setShowBulkDateDialog] = React.useState(false)
+ const [bulkDeliveryDate, setBulkDeliveryDate] = React.useState<Date | undefined>(undefined)
// 초기 견적 데이터 설정 및 SPEC 문서 로드
React.useEffect(() => {
@@ -279,6 +293,21 @@ export function PrItemsPricingTable({
onTotalAmountChange(totalAmount)
}
+ // 일괄 납기일 적용
+ const applyBulkDeliveryDate = () => {
+ if (bulkDeliveryDate && quotations.length > 0) {
+ const formattedDate = format(bulkDeliveryDate, 'yyyy-MM-dd')
+ const updatedQuotations = quotations.map(q => ({
+ ...q,
+ proposedDeliveryDate: formattedDate
+ }))
+
+ setQuotations(updatedQuotations)
+ onQuotationsChange(updatedQuotations)
+ setShowBulkDateDialog(false)
+ setBulkDeliveryDate(undefined)
+ }
+ }
// 통화 포맷팅
const formatCurrency = (amount: number) => {
@@ -292,12 +321,26 @@ export function PrItemsPricingTable({
const totalAmount = quotations.reduce((sum, q) => sum + q.bidAmount, 0)
return (
+ <>
<Card>
<CardHeader>
- <CardTitle className="flex items-center gap-2">
- <Package className="w-5 h-5" />
- 품목별 입찰 작성
- </CardTitle>
+ <div className="flex items-center justify-between">
+ <CardTitle className="flex items-center gap-2">
+ <Package className="w-5 h-5" />
+ 품목별 입찰 작성
+ </CardTitle>
+ {!readOnly && (
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => setShowBulkDateDialog(true)}
+ >
+ <CalendarIcon className="h-4 w-4 mr-1" />
+ 전체 납품예정일 설정
+ </Button>
+ )}
+ </div>
</CardHeader>
<CardContent>
<div className="space-y-4">
@@ -467,5 +510,73 @@ export function PrItemsPricingTable({
</div>
</CardContent>
</Card>
+
+ {/* 일괄 납품예정일 설정 다이얼로그 */}
+ <Dialog open={showBulkDateDialog} onOpenChange={setShowBulkDateDialog}>
+ <DialogContent className="sm:max-w-md">
+ <DialogHeader>
+ <DialogTitle>전체 납품예정일 설정</DialogTitle>
+ <DialogDescription>
+ 모든 PR 아이템에 동일한 납품예정일을 적용합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div className="space-y-2">
+ <Label>납품예정일 선택</Label>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full justify-start text-left font-normal",
+ !bulkDeliveryDate && "text-muted-foreground"
+ )}
+ >
+ <CalendarIcon className="mr-2 h-4 w-4" />
+ {bulkDeliveryDate ? format(bulkDeliveryDate, "yyyy-MM-dd") : "날짜 선택"}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={bulkDeliveryDate}
+ onSelect={setBulkDeliveryDate}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ </div>
+
+ <div className="bg-muted/50 rounded-lg p-3">
+ <p className="text-sm text-muted-foreground">
+ 선택된 날짜가 <strong>{prItems.length}개</strong>의 모든 PR 아이템에 적용됩니다.
+ 기존에 설정된 납품예정일은 모두 교체됩니다.
+ </p>
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => {
+ setShowBulkDateDialog(false)
+ setBulkDeliveryDate(undefined)
+ }}
+ >
+ 취소
+ </Button>
+ <Button
+ type="button"
+ onClick={applyBulkDeliveryDate}
+ disabled={!bulkDeliveryDate}
+ >
+ 전체 적용
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </>
)
}