diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-27 03:08:50 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-27 03:08:50 +0000 |
| commit | 79cfa7ea8f21ae227dbb2843ae536fe876ba7c55 (patch) | |
| tree | f12efae72c62286c1a2e9a3f31d695ca22d83b6e | |
| parent | e1da84ac863989b9f63b089c09aaa2bbcdc3d6cd (diff) | |
(최겸) 구매 입찰 수정
22 files changed, 627 insertions, 997 deletions
diff --git a/components/bidding/create/bidding-create-dialog.tsx b/components/bidding/create/bidding-create-dialog.tsx index cf662cd1..f298721b 100644 --- a/components/bidding/create/bidding-create-dialog.tsx +++ b/components/bidding/create/bidding-create-dialog.tsx @@ -36,10 +36,6 @@ import { getPlaceOfDestinationForSelection,
} from '@/lib/procurement-select/service'
import { TAX_CONDITIONS } from '@/lib/tax-conditions/types'
-import { getBiddingNoticeTemplate } from '@/lib/bidding/service'
-import TiptapEditor from '@/components/qna/tiptap-editor'
-
-// Dropzone components
import {
Dropzone,
DropzoneDescription,
@@ -101,10 +97,6 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp const [selectedBidPic, setSelectedBidPic] = React.useState<PurchaseGroupCodeWithUser | undefined>(undefined)
const [selectedSupplyPic, setSelectedSupplyPic] = React.useState<ProcurementManagerWithUser | undefined>(undefined)
- // 입찰공고 템플릿 관련 상태
- const [noticeTemplate, setNoticeTemplate] = React.useState<string>('')
- const [isLoadingTemplate, setIsLoadingTemplate] = React.useState(false)
-
// -- 데이터 로딩 및 상태 동기화 로직
const loadPaymentTerms = React.useCallback(async () => {
try {
@@ -174,59 +166,9 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp setBiddingConditions((prev) => ({ ...prev, taxConditions: 'V1' }))
form.setValue('biddingConditions.taxConditions', 'V1')
}
-
- // 초기 표준 템플릿 로드
- const loadInitialTemplate = async () => {
- try {
- const standardTemplate = await getBiddingNoticeTemplate('standard')
- if (standardTemplate) {
- console.log('standardTemplate', standardTemplate)
- setNoticeTemplate(standardTemplate.content)
- form.setValue('content', standardTemplate.content)
- }
- } catch (error) {
- console.error('Failed to load initial template:', error)
- toast.error('기본 템플릿을 불러오는데 실패했습니다.')
- }
- }
- loadInitialTemplate()
}
}, [isOpen, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces, form])
- // 입찰공고 템플릿 로딩
- const noticeTypeValue = form.watch('noticeType')
- const selectedNoticeType = React.useMemo(() => noticeTypeValue, [noticeTypeValue])
-
- React.useEffect(() => {
- const loadNoticeTemplate = async () => {
- setIsLoadingTemplate(true)
- try {
- // 처음 로드할 때는 무조건 standard 템플릿 사용
- const templateType = selectedNoticeType || 'standard'
- const template = await getBiddingNoticeTemplate(templateType)
- if (template) {
- setNoticeTemplate(template.content)
- // 폼의 content 필드도 업데이트
- form.setValue('content', template.content)
- } else {
- // 템플릿이 없으면 표준 템플릿 사용
- const defaultTemplate = await getBiddingNoticeTemplate('standard')
- if (defaultTemplate) {
- setNoticeTemplate(defaultTemplate.content)
- form.setValue('content', defaultTemplate.content)
- }
- }
- } catch (error) {
- console.error('Failed to load notice template:', error)
- toast.error('입찰공고 템플릿을 불러오는데 실패했습니다.')
- } finally {
- setIsLoadingTemplate(false)
- }
- }
-
- loadNoticeTemplate()
- }, [selectedNoticeType, form])
-
// SHI용 파일 첨부 핸들러
const handleShiFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || [])
@@ -313,7 +255,6 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp setVendorAttachmentFiles([])
setSelectedBidPic(undefined)
setSelectedSupplyPic(undefined)
- setNoticeTemplate('')
if (onSuccess) {
onSuccess()
}
@@ -337,7 +278,6 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp setVendorAttachmentFiles([])
setSelectedBidPic(undefined)
setSelectedSupplyPic(undefined)
- setNoticeTemplate('')
setBiddingConditions({
paymentTerms: '',
taxConditions: 'V1',
@@ -1097,7 +1037,7 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp </Card>
{/* 입찰공고 내용 */}
- <Card>
+ {/* <Card>
<CardHeader>
<CardTitle>입찰공고 내용</CardTitle>
<p className="text-sm text-muted-foreground">
@@ -1127,14 +1067,14 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp )}
/>
- {/* {isLoadingTemplate && (
+ {isLoadingTemplate && (
<div className="flex items-center justify-center p-4 text-sm text-muted-foreground">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900 mr-2"></div>
입찰공고 템플릿을 불러오는 중...
</div>
- )} */}
+ )}
</CardContent>
- </Card>
+ </Card> */}
{/* SHI용 첨부파일 */}
<Card>
@@ -1162,18 +1102,16 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp })
}}
>
- {() => (
- <DropzoneTrigger asChild>
- <DropzoneZone className="flex justify-center h-32">
- <div className="flex items-center gap-6">
- <DropzoneUploadIcon />
- <div className="grid gap-0.5">
- <DropzoneTitle>파일을 드래그하여 업로드</DropzoneTitle>
- </div>
- </div>
- </DropzoneZone>
- </DropzoneTrigger>
- )}
+ <DropzoneZone>
+ <DropzoneUploadIcon className="mx-auto h-12 w-12 text-muted-foreground" />
+ <DropzoneTitle className="text-lg font-medium">
+ 파일을 드래그하거나 클릭하여 업로드
+ </DropzoneTitle>
+ <DropzoneDescription className="text-sm text-muted-foreground">
+ PDF, Word, Excel, 이미지 파일 (최대 600MB)
+ </DropzoneDescription>
+ </DropzoneZone>
+ <DropzoneInput />
</Dropzone>
{shiAttachmentFiles.length > 0 && (
@@ -1236,18 +1174,16 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp })
}}
>
- {() => (
- <DropzoneTrigger asChild>
- <DropzoneZone className="flex justify-center h-32">
- <div className="flex items-center gap-6">
- <DropzoneUploadIcon />
- <div className="grid gap-0.5">
- <DropzoneTitle>파일을 드래그하여 업로드</DropzoneTitle>
- </div>
- </div>
- </DropzoneZone>
- </DropzoneTrigger>
- )}
+ <DropzoneZone>
+ <DropzoneUploadIcon className="mx-auto h-12 w-12 text-muted-foreground" />
+ <DropzoneTitle className="text-lg font-medium">
+ 파일을 드래그하거나 클릭하여 업로드
+ </DropzoneTitle>
+ <DropzoneDescription className="text-sm text-muted-foreground">
+ 협력업체용 문서나 파일을 업로드 하세요. (최대 600MB)
+ </DropzoneDescription>
+ </DropzoneZone>
+ <DropzoneInput />
</Dropzone>
{vendorAttachmentFiles.length > 0 && (
diff --git a/components/bidding/manage/bidding-basic-info-editor.tsx b/components/bidding/manage/bidding-basic-info-editor.tsx index e2d888ff..90923825 100644 --- a/components/bidding/manage/bidding-basic-info-editor.tsx +++ b/components/bidding/manage/bidding-basic-info-editor.tsx @@ -1141,18 +1141,16 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB }) }} > - {() => ( - <DropzoneTrigger asChild> - <DropzoneZone className="flex justify-center h-32"> - <div className="flex items-center gap-6"> - <DropzoneUploadIcon /> - <div className="grid gap-0.5"> - <DropzoneTitle>파일을 드래그하여 업로드</DropzoneTitle> - </div> - </div> - </DropzoneZone> - </DropzoneTrigger> - )} + <DropzoneZone> + <DropzoneUploadIcon className="mx-auto h-12 w-12 text-muted-foreground" /> + <DropzoneTitle className="text-lg font-medium"> + 파일을 드래그하거나 클릭하여 업로드 + </DropzoneTitle> + <DropzoneDescription className="text-sm text-muted-foreground"> + PDF, Word, Excel, 이미지 파일 (최대 600MB) + </DropzoneDescription> + </DropzoneZone> + <DropzoneInput /> </Dropzone> @@ -1235,18 +1233,16 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB }) }} > - {() => ( - <DropzoneTrigger asChild> - <DropzoneZone className="flex justify-center h-32"> - <div className="flex items-center gap-6"> - <DropzoneUploadIcon /> - <div className="grid gap-0.5"> - <DropzoneTitle>파일을 드래그하여 업로드</DropzoneTitle> - </div> - </div> - </DropzoneZone> - </DropzoneTrigger> - )} + <DropzoneZone> + <DropzoneUploadIcon className="mx-auto h-12 w-12 text-muted-foreground" /> + <DropzoneTitle className="text-lg font-medium"> + 파일을 드래그하거나 클릭하여 업로드 + </DropzoneTitle> + <DropzoneDescription className="text-sm text-muted-foreground"> + 협력업체용 문서나 파일을 업로드 하세요. (최대 600MB) + </DropzoneDescription> + </DropzoneZone> + <DropzoneInput /> </Dropzone> diff --git a/components/bidding/manage/bidding-companies-editor.tsx b/components/bidding/manage/bidding-companies-editor.tsx index da566c82..f6b3a3f0 100644 --- a/components/bidding/manage/bidding-companies-editor.tsx +++ b/components/bidding/manage/bidding-companies-editor.tsx @@ -271,6 +271,12 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC return } + // 전화번호 형식 검증 (국제 표준) + if (newContact.contactNumber && !newContact.contactNumber.startsWith('+')) { + toast.error('전화번호는 국제 표준 형식(+)으로 시작해야 합니다. (예: +821012345678)') + return + } + try { const result = await createBiddingCompanyContact( biddingId, @@ -703,8 +709,11 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC id="contactNumber" value={newContact.contactNumber} onChange={(e) => setNewContact(prev => ({ ...prev, contactNumber: e.target.value }))} - placeholder="010-1234-5678" + placeholder="+821012345678" /> + <p className="text-[0.8rem] text-muted-foreground"> + * SMS 발송을 위해 국제 표준 형식으로 입력해주세요. (예: +821012345678) + </p> </div> </div> diff --git a/components/bidding/manage/bidding-items-editor.tsx b/components/bidding/manage/bidding-items-editor.tsx index 208cf040..f61b3960 100644 --- a/components/bidding/manage/bidding-items-editor.tsx +++ b/components/bidding/manage/bidding-items-editor.tsx @@ -227,6 +227,57 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems const userId = session?.user?.id?.toString() || '1' let hasError = false + // 필수값 검증 + for (let i = 0; i < items.length; i++) { + const item = items[i]; + + // 필수값: 자재그룹코드, 자재그룹명 + if (!item.materialGroupNumber || !item.materialGroupInfo) { + toast.error(`${i + 1}번 품목의 자재그룹 정보를 입력해주세요.`); + setIsSubmitting(false); + return; + } + + // 필수값: 수량 또는 중량 + if (quantityWeightMode === 'quantity') { + if (!item.quantity || parseFloat(item.quantity) <= 0) { + toast.error(`${i + 1}번 품목의 수량을 입력해주세요.`); + setIsSubmitting(false); + return; + } + if (!item.quantityUnit) { + toast.error(`${i + 1}번 품목의 수량 단위를 선택해주세요.`); + setIsSubmitting(false); + return; + } + } else { + if (!item.totalWeight || parseFloat(item.totalWeight) <= 0) { + toast.error(`${i + 1}번 품목의 중량을 입력해주세요.`); + setIsSubmitting(false); + return; + } + if (!item.weightUnit) { + toast.error(`${i + 1}번 품목의 중량 단위를 선택해주세요.`); + setIsSubmitting(false); + return; + } + } + + // 필수값: 납품요청일 + if (!item.requestedDeliveryDate) { + toast.error(`${i + 1}번 품목의 납품요청일을 입력해주세요.`); + setIsSubmitting(false); + return; + } + + // 필수값: 내정단가 (사용자 요청) + if (!item.targetUnitPrice || parseFloat(item.targetUnitPrice.replace(/,/g, '')) <= 0) { + toast.error(`${i + 1}번 품목의 내정단가를 입력해주세요.`); + setIsSubmitting(false); + return; + } + } + // 모든 아이템을 upsert 처리 (id가 있으면 update, 없으면 insert) for (const item of items) { const targetAmount = calculateTargetAmount(item) @@ -1111,10 +1162,8 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems if (!open) setSelectedItemForWbs(null) }} selectedCode={item.wbsCode ? { - PROJ_NO: '', WBS_ELMT: item.wbsCode, WBS_ELMT_NM: item.wbsName || '', - WBS_LVL: '' } : undefined} onCodeSelect={(wbsCode) => { updatePRItem(item.id, { @@ -1167,15 +1216,12 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems }} selectedCode={item.costCenterCode ? { KOSTL: item.costCenterCode, - KTEXT: '', - LTEXT: item.costCenterName || '', - DATAB: '', - DATBI: '' + KTEXT: item.costCenterName || '', } : undefined} onCodeSelect={(costCenter) => { updatePRItem(item.id, { costCenterCode: costCenter.KOSTL, - costCenterName: costCenter.LTEXT + costCenterName: costCenter.KTEXT }) setCostCenterDialogOpen(false) setSelectedItemForCostCenter(null) @@ -1223,7 +1269,6 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems }} selectedCode={item.glAccountCode ? { SAKNR: item.glAccountCode, - FIPEX: '', TEXT1: item.glAccountName || '' } : undefined} onCodeSelect={(glAccount) => { diff --git a/components/bidding/manage/bidding-schedule-editor.tsx b/components/bidding/manage/bidding-schedule-editor.tsx index b5f4aaf0..ca4643ff 100644 --- a/components/bidding/manage/bidding-schedule-editor.tsx +++ b/components/bidding/manage/bidding-schedule-editor.tsx @@ -324,11 +324,23 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc vendors: selectedVendors, message: invitationData.message || '', currentUser: { - id: session.user.id, + id: Number(session.user.id), epId: session.user.epId, email: session.user.email || undefined, }, approvers, + specificationMeeting: schedule.hasSpecificationMeeting ? { + meetingDate: specMeetingInfo.meetingDate, + meetingTime: specMeetingInfo.meetingTime, + location: specMeetingInfo.location, + address: specMeetingInfo.address, + contactPerson: specMeetingInfo.contactPerson, + contactPhone: specMeetingInfo.contactPhone, + contactEmail: specMeetingInfo.contactEmail, + agenda: specMeetingInfo.agenda, + materials: specMeetingInfo.materials, + notes: specMeetingInfo.notes, + } : undefined, }) if (result.status === 'pending_approval') { @@ -428,6 +440,18 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc biddingId, vendors, message: data.message || '', + specificationMeeting: schedule.hasSpecificationMeeting ? { + meetingDate: specMeetingInfo.meetingDate, + meetingTime: specMeetingInfo.meetingTime, + location: specMeetingInfo.location, + address: specMeetingInfo.address, + contactPerson: specMeetingInfo.contactPerson, + contactPhone: specMeetingInfo.contactPhone, + contactEmail: specMeetingInfo.contactEmail, + agenda: specMeetingInfo.agenda, + materials: specMeetingInfo.materials, + notes: specMeetingInfo.notes, + } : undefined, }) // 결재 준비 완료 - invitationData와 결재 데이터 저장 및 결재 다이얼로그 열기 diff --git a/components/common/selectors/cost-center/cost-center-selector.tsx b/components/common/selectors/cost-center/cost-center-selector.tsx index f87b6928..8c733cd0 100644 --- a/components/common/selectors/cost-center/cost-center-selector.tsx +++ b/components/common/selectors/cost-center/cost-center-selector.tsx @@ -55,10 +55,6 @@ export interface CostCenterSelectorProps { export interface CostCenterItem {
kostl: string // Cost Center
ktext: string // 단축명
- ltext: string // 설명
- datab: string // 시작일
- datbi: string // 종료일
- displayText: string // 표시용 텍스트
}
export function CostCenterSelector({
diff --git a/db/schema/bidding.ts b/db/schema/bidding.ts index 8f9ec5fe..99a14475 100644 --- a/db/schema/bidding.ts +++ b/db/schema/bidding.ts @@ -664,7 +664,7 @@ export type BiddingListItem = Bidding & { biddingPending: number // 입찰 초대 발송 biddingAccepted: number // 입찰 참여 biddingDeclined: number // 입찰 미참여 - biddingCancelled: number // 응찰 취소 + biddingCancelled: number // 응찰 포기 biddingSubmitted: number // 응찰 완료 // 호환성을 위한 기존 필드 (deprecated) @@ -691,7 +691,7 @@ export const biddingStatusLabels = { bidding_opened: '입찰공고', bidding_closed: '입찰마감', approval_pending: '결재 진행중', - evaluation_of_bidding: '입찰평가중', + evaluation_of_bidding: '입찰평가중(개찰완료)', bidding_disposal: '유찰', vendor_selected: '업체선정', bid_opening: '개찰', diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts index 6ae3c237..6f4e5d53 100644 --- a/lib/basic-contract/service.ts +++ b/lib/basic-contract/service.ts @@ -639,7 +639,7 @@ export async function requestBasicContractInfo({ const host = headersList.get('host') || 'localhost:3000'; // 로그인 또는 서명 페이지 URL 생성 const baseUrl = `http://${host}` - const loginUrl = `${baseUrl}/partners/basic-contract`; + const loginUrl = `${process.env.NEXT_PUBLIC_BASE_URL}/partners/basic-contract`; // 사용자 언어 설정 (기본값은 한국어) const userLang = "ko"; diff --git a/lib/bidding/actions.ts b/lib/bidding/actions.ts index 78f07219..0bf2af57 100644 --- a/lib/bidding/actions.ts +++ b/lib/bidding/actions.ts @@ -655,111 +655,7 @@ async function getUserNameById(userId: string): Promise<string> { } } -// 조기개찰 액션 -export async function earlyOpenBiddingAction(biddingId: number) { - try { - const session = await getServerSession(authOptions) - if (!session?.user?.name) { - return { success: false, message: '인증이 필요합니다.' } - } - - const userName = session.user.name - - return await db.transaction(async (tx) => { - // 1. 입찰 정보 확인 - const [bidding] = await tx - .select({ - id: biddings.id, - status: biddings.status, - submissionEndDate: biddings.submissionEndDate, - title: biddings.title - }) - .from(biddings) - .where(eq(biddings.id, biddingId)) - .limit(1) - - if (!bidding) { - return { success: false, message: '입찰 정보를 찾을 수 없습니다.' } - } - - // 2. 입찰서 제출기간 내인지 확인 - const now = new Date() - if (bidding.submissionEndDate && now > bidding.submissionEndDate) { - return { success: false, message: '입찰서 제출기간이 종료되었습니다.' } - } - - // 3. 참여 현황 확인 - const [participationStats] = await tx - .select({ - participantExpected: db.$count(biddingCompanies), - participantParticipated: db.$count(biddingCompanies, eq(biddingCompanies.invitationStatus, 'bidding_submitted')), - participantDeclined: db.$count(biddingCompanies, and( - eq(biddingCompanies.invitationStatus, 'bidding_declined'), - eq(biddingCompanies.biddingId, biddingId) - )), - participantPending: db.$count(biddingCompanies, and( - eq(biddingCompanies.invitationStatus, 'pending'), - eq(biddingCompanies.biddingId, biddingId) - )), - }) - .from(biddingCompanies) - .where(eq(biddingCompanies.biddingId, biddingId)) - - // 실제 SQL 쿼리로 변경 - const [stats] = await tx - .select({ - participantExpected: sql<number>`COUNT(*)`.as('participant_expected'), - participantParticipated: sql<number>`COUNT(CASE WHEN invitation_status = 'bidding_submitted' THEN 1 END)`.as('participant_participated'), - participantDeclined: sql<number>`COUNT(CASE WHEN invitation_status IN ('bidding_declined', 'bidding_cancelled') THEN 1 END)`.as('participant_declined'), - participantPending: sql<number>`COUNT(CASE WHEN invitation_status IN ('pending', 'bidding_sent', 'bidding_accepted') THEN 1 END)`.as('participant_pending'), - }) - .from(biddingCompanies) - .where(eq(biddingCompanies.biddingId, biddingId)) - - const participantExpected = Number(stats.participantExpected) || 0 - const participantParticipated = Number(stats.participantParticipated) || 0 - const participantDeclined = Number(stats.participantDeclined) || 0 - const participantPending = Number(stats.participantPending) || 0 - - // 4. 조기개찰 조건 검증 - // - 미제출 협력사 = 0 - if (participantPending > 0) { - return { success: false, message: `미제출 협력사가 ${participantPending}명 있어 조기개찰이 불가능합니다.` } - } - - // - 참여협력사 + 포기협력사 = 참여예정협력사 - if (participantParticipated + participantDeclined !== participantExpected) { - return { success: false, message: '모든 협력사가 참여 또는 포기하지 않아 조기개찰이 불가능합니다.' } - } - - // 5. 참여협력사 중 최종응찰 버튼을 클릭한 업체들만 있는지 검증 - // bidding_submitted 상태인 업체들이 있는지 확인 (이미 위에서 검증됨) - - // 6. 입찰평가중 상태로 변경 - await tx - .update(biddings) - .set({ - status: 'evaluation_of_bidding', - openedAt: new Date(), - openedBy: userName, - updatedAt: new Date(), - updatedBy: userName, - }) - .where(eq(biddings.id, biddingId)) - - return { success: true, message: '조기개찰이 완료되었습니다.' } - }) - - } catch (error) { - console.error('조기개찰 실패:', error) - return { - success: false, - message: error instanceof Error ? error.message : '조기개찰 중 오류가 발생했습니다.' - } - } -} - -// 개찰 액션 +// 개찰 액션 (조기개찰 포함) export async function openBiddingAction(biddingId: number) { try { const session = await getServerSession(authOptions) @@ -786,10 +682,35 @@ export async function openBiddingAction(biddingId: number) { return { success: false, message: '입찰 정보를 찾을 수 없습니다.' } } - // 2. 입찰서 제출기간이 종료되었는지 확인 const now = new Date() - if (bidding.submissionEndDate && now <= bidding.submissionEndDate) { - return { success: false, message: '입찰서 제출기간이 아직 종료되지 않았습니다.' } + const isDeadlinePassed = bidding.submissionEndDate && now > bidding.submissionEndDate + + // 2. 개찰 가능 여부 확인 + if (!isDeadlinePassed) { + // 마감일이 지나지 않았으면 조기개찰 조건 확인 + // 조기개찰 조건: 모든 대상 업체가 응찰(최종제출)했거나 포기했는지 확인 (미제출 0) + + const [stats] = await tx + .select({ + participantExpected: sql<number>`COUNT(*)`.as('participant_expected'), + participantFinalSubmitted: sql<number>`COUNT(CASE WHEN invitation_status = 'bidding_submitted' THEN 1 END)`.as('participant_final_submitted'), + participantDeclined: sql<number>`COUNT(CASE WHEN invitation_status IN ('bidding_declined', 'bidding_cancelled') THEN 1 END)`.as('participant_declined'), + }) + .from(biddingCompanies) + .where(eq(biddingCompanies.biddingId, biddingId)) + + const participantExpected = Number(stats.participantExpected) || 0 + const participantFinalSubmitted = Number(stats.participantFinalSubmitted) || 0 + const participantDeclined = Number(stats.participantDeclined) || 0 + + // 조건: 전체 대상 = 최종제출 + 포기 + if (participantExpected !== participantFinalSubmitted + participantDeclined) { + const pending = participantExpected - (participantFinalSubmitted + participantDeclined); + return { + success: false, + message: `입찰서 제출기간이 종료되지 않았으며, 최종제출하지 않은 업체가 ${pending}곳 있어 조기개찰할 수 없습니다.` + } + } } // 3. 입찰평가중 상태로 변경 @@ -804,7 +725,7 @@ export async function openBiddingAction(biddingId: number) { }) .where(eq(biddings.id, biddingId)) - return { success: true, message: '개찰이 완료되었습니다.' } + return { success: true, message: isDeadlinePassed ? '개찰이 완료되었습니다.' : '조기개찰이 완료되었습니다.' } }) } catch (error) { diff --git a/lib/bidding/approval-actions.ts b/lib/bidding/approval-actions.ts index dd88164d..3d07d49c 100644 --- a/lib/bidding/approval-actions.ts +++ b/lib/bidding/approval-actions.ts @@ -54,6 +54,18 @@ export async function prepareBiddingApprovalData(data: { biddingId: number; }>; message?: string; + specificationMeeting?: { + meetingDate: string | null; + meetingTime: string | null; + location: string | null; + address: string | null; + contactPerson: string | null; + contactPhone: string | null; + contactEmail: string | null; + agenda: string | null; + materials: string | null; + notes: string | null; + }; }) { // 1. 입찰 정보 조회 (템플릿 변수용) debugLog('[BiddingInvitationApproval] 입찰 정보 조회 시작'); @@ -121,11 +133,68 @@ export async function prepareBiddingApprovalData(data: { debugLog('[BiddingInvitationApproval] 템플릿 변수 매핑 시작'); const requestedAt = new Date(); const { mapBiddingInvitationToTemplateVariables } = await import('./handlers'); + + // 사양설명회 정보가 전달되지 않았는데 입찰에 사양설명회가 있는 경우 DB에서 조회 + let specMeetingInfo = data.specificationMeeting; + if (!specMeetingInfo && bidding.hasSpecificationMeeting) { + const { specificationMeetings } = await import('@/db/schema'); + const meetings = await db + .select() + .from(specificationMeetings) + .where(eq(specificationMeetings.biddingId, data.biddingId)) + .limit(1); + + if (meetings.length > 0) { + const m = meetings[0]; + specMeetingInfo = { + meetingDate: m.meetingDate ? m.meetingDate.toISOString() : null, + meetingTime: m.meetingTime, + location: m.location, + address: m.address, + contactPerson: m.contactPerson, + contactPhone: m.contactPhone, + contactEmail: m.contactEmail, + agenda: m.agenda, + materials: m.materials, + notes: m.notes + }; + } + } + const variables = await mapBiddingInvitationToTemplateVariables({ - bidding, - biddingItems: biddingItemsInfo, + bidding: { + ...bidding, + projectName: bidding.projectName || undefined, + itemName: bidding.itemName || undefined, + bidPicName: bidding.bidPicName || undefined, + supplyPicName: bidding.supplyPicName || undefined, + targetPrice: bidding.targetPrice ? Number(bidding.targetPrice) : undefined, + remarks: bidding.remarks || undefined, + submissionStartDate: bidding.submissionStartDate || undefined, + submissionEndDate: bidding.submissionEndDate || undefined, + hasSpecificationMeeting: bidding.hasSpecificationMeeting || undefined, + isUrgent: bidding.isUrgent || undefined, + }, + biddingItems: biddingItemsInfo.map(item => ({ + ...item, + projectName: item.projectName || undefined, + materialGroup: item.materialGroup || undefined, + materialGroupName: item.materialGroupName || undefined, + materialCode: item.materialCode || undefined, + materialCodeName: item.materialCodeName || undefined, + quantity: item.quantity ? Number(item.quantity) : undefined, + purchasingUnit: item.purchasingUnit || undefined, + targetUnitPrice: item.targetUnitPrice ? Number(item.targetUnitPrice) : undefined, + quantityUnit: item.quantityUnit || undefined, + totalWeight: item.totalWeight ? Number(item.totalWeight) : undefined, + weightUnit: item.weightUnit || undefined, + budget: item.budget ? Number(item.budget) : undefined, + targetAmount: item.targetAmount ? Number(item.targetAmount) : undefined, + currency: item.currency || undefined, + })), vendors: data.vendors, message: data.message, + specificationMeeting: specMeetingInfo, requestedAt, }); debugLog('[BiddingInvitationApproval] 템플릿 변수 매핑 완료', { @@ -159,6 +228,18 @@ export async function requestBiddingInvitationWithApproval(data: { message?: string; currentUser: { id: number; epId: string | null; email?: string }; approvers?: string[]; // Knox EP ID 배열 (결재선) + specificationMeeting?: { + meetingDate: string | null; + meetingTime: string | null; + location: string | null; + address: string | null; + contactPerson: string | null; + contactPhone: string | null; + contactEmail: string | null; + agenda: string | null; + materials: string | null; + notes: string | null; + }; }) { debugLog('[BiddingInvitationApproval] 입찰초대 결재 서버 액션 시작', { biddingId: data.biddingId, @@ -188,7 +269,7 @@ export async function requestBiddingInvitationWithApproval(data: { .update(biddings) .set({ status: 'approval_pending', // 결재 진행중 상태 - updatedBy: Number(data.currentUser.epId), + updatedBy: String(data.currentUser.id), // id를 string으로 변환 updatedAt: new Date() }) .where(eq(biddings.id, data.biddingId)); @@ -203,6 +284,7 @@ export async function requestBiddingInvitationWithApproval(data: { biddingId: data.biddingId, vendors: data.vendors, message: data.message, + specificationMeeting: data.specificationMeeting, }); // 4. 결재 워크플로우 시작 (Saga 패턴) diff --git a/lib/bidding/detail/bidding-actions.ts b/lib/bidding/detail/bidding-actions.ts index fb659039..4140ec72 100644 --- a/lib/bidding/detail/bidding-actions.ts +++ b/lib/bidding/detail/bidding-actions.ts @@ -22,7 +22,7 @@ async function getUserNameById(userId: string): Promise<string> { }
}
-// 응찰 취소 서버 액션 (최종제출이 아닌 경우만 가능)
+// 응찰 포기 서버 액션 (최종제출이 아닌 경우만 가능)
export async function cancelBiddingResponse(
biddingCompanyId: number,
userId: string
@@ -63,7 +63,7 @@ export async function cancelBiddingResponse( finalQuoteAmount: null,
finalQuoteSubmittedAt: null,
isFinalSubmission: false,
- invitationStatus: 'bidding_cancelled', // 응찰 취소 상태
+ invitationStatus: 'bidding_cancelled', // 응찰 포기 상태
updatedAt: new Date()
})
.where(eq(biddingCompanies.id, biddingCompanyId))
@@ -86,142 +86,14 @@ export async function cancelBiddingResponse( return {
success: true,
- message: '응찰이 취소되었습니다.'
+ message: '응찰이 포기되었습니다.'
}
})
} catch (error) {
console.error('Failed to cancel bidding response:', error)
return {
success: false,
- error: error instanceof Error ? error.message : '응찰 취소에 실패했습니다.'
+ error: error instanceof Error ? error.message : '응찰 포기에 실패했습니다.'
}
}
}
-
-// 모든 벤더가 최종제출했는지 확인
-export async function checkAllVendorsFinalSubmitted(biddingId: number) {
- try {
- const companies = await db
- .select({
- id: biddingCompanies.id,
- isFinalSubmission: biddingCompanies.isFinalSubmission,
- invitationStatus: biddingCompanies.invitationStatus,
- })
- .from(biddingCompanies)
- .where(
- and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.isBiddingInvited, true) // 본입찰 초대된 업체만
- )
- )
-
- // 초대된 업체가 없으면 false
- if (companies.length === 0) {
- return {
- allSubmitted: false,
- totalCompanies: 0,
- submittedCompanies: 0
- }
- }
-
- // 모든 업체가 최종제출했는지 확인
- const submittedCompanies = companies.filter(c => c.isFinalSubmission).length
- const allSubmitted = companies.every(c => c.isFinalSubmission)
-
- return {
- allSubmitted,
- totalCompanies: companies.length,
- submittedCompanies
- }
- } catch (error) {
- console.error('Failed to check all vendors final submitted:', error)
- return {
- allSubmitted: false,
- totalCompanies: 0,
- submittedCompanies: 0
- }
- }
-}
-
-// // 개찰 서버 액션 (조기개찰/개찰 구분)
-// export async function performBidOpening(
-// biddingId: number,
-// userId: string,
-// isEarly: boolean = false // 조기개찰 여부
-// ) {
-// try {
-// const userName = await getUserNameById(userId)
-
-// return await db.transaction(async (tx) => {
-// // 1. 입찰 정보 조회
-// const [bidding] = await tx
-// .select({
-// id: biddings.id,
-// status: biddings.status,
-// submissionEndDate: biddings.submissionEndDate,
-// })
-// .from(biddings)
-// .where(eq(biddings.id, biddingId))
-// .limit(1)
-
-// if (!bidding) {
-// return {
-// success: false,
-// error: '입찰 정보를 찾을 수 없습니다.'
-// }
-// }
-
-// // 2. 개찰 가능 여부 확인 (evaluation_of_bidding 상태에서만)
-// if (bidding.status !== 'evaluation_of_bidding') {
-// return {
-// success: false,
-// error: '입찰평가중 상태에서만 개찰할 수 있습니다.'
-// }
-// }
-
-// // 3. 모든 벤더가 최종제출했는지 확인
-// const checkResult = await checkAllVendorsFinalSubmitted(biddingId)
-// if (!checkResult.allSubmitted) {
-// return {
-// success: false,
-// error: `모든 벤더가 최종 제출해야 개찰할 수 있습니다. (${checkResult.submittedCompanies}/${checkResult.totalCompanies})`
-// }
-// }
-
-// // 4. 조기개찰 여부 결정
-// const now = new Date()
-// const submissionEndDate = bidding.submissionEndDate ? new Date(bidding.submissionEndDate) : null
-// const isBeforeDeadline = submissionEndDate && now < submissionEndDate
-
-// // 마감일 전이면 조기개찰, 마감일 후면 일반 개찰
-// const newStatus = (isEarly || isBeforeDeadline) ? 'early_bid_opening' : 'bid_opening'
-
-// // 5. 입찰 상태 변경
-// await tx
-// .update(biddings)
-// .set({
-// status: newStatus,
-// updatedAt: new Date()
-// })
-// .where(eq(biddings.id, biddingId))
-
-// // 캐시 무효화
-// revalidateTag(`bidding-${biddingId}`)
-// revalidateTag('bidding-detail')
-// revalidatePath(`/evcp/bid/${biddingId}`)
-
-// return {
-// success: true,
-// message: `${newStatus === 'early_bid_opening' ? '조기개찰' : '개찰'}이 완료되었습니다.`,
-// status: newStatus
-// }
-// })
-// } catch (error) {
-// console.error('Failed to perform bid opening:', error)
-// return {
-// success: false,
-// error: error instanceof Error ? error.message : '개찰에 실패했습니다.'
-// }
-// }
-// }
-
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index 8f9bf018..c9aaa66c 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -854,6 +854,7 @@ export async function registerBidding(biddingId: number, userId: string) { // 3. 선정된 업체들에게 본입찰 초대 메일 발송 debugLog('registerBidding: Sending emails...') for (const company of selectedCompanies) { + // 벤더 메인 이메일로 발송 if (company.contactEmail) { try { await sendEmail({ @@ -879,6 +880,51 @@ export async function registerBidding(biddingId: number, userId: string) { debugError(`Failed to send bidding invitation email to ${company.contactEmail}:`, emailError) } } + + // 추가 담당자들에게도 이메일 발송 + try { + const contactInfos = await db + .select({ + contactName: biddingCompaniesContacts.contactName, + contactEmail: biddingCompaniesContacts.contactEmail + }) + .from(biddingCompaniesContacts) + .where(and( + eq(biddingCompaniesContacts.biddingId, biddingId), + eq(biddingCompaniesContacts.vendorId, company.companyId) + )); + + for (const contact of contactInfos) { + // 벤더 메인 이메일과 중복되지 않는 경우에만 발송 + if (contact.contactEmail && contact.contactEmail !== company.contactEmail) { + try { + await sendEmail({ + to: contact.contactEmail, + template: 'bidding-invitation', + context: { + companyName: company.companyName, + biddingNumber: bidding.biddingNumber, + title: bidding.title, + projectName: bidding.projectName, + itemName: bidding.itemName, + biddingType: bidding.biddingType, + submissionStartDate: bidding.submissionStartDate, + submissionEndDate: bidding.submissionEndDate, + biddingUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/partners/bid/${biddingId}`, + bidPicName: bidding.bidPicName, + supplyPicName: bidding.supplyPicName, + language: 'ko' + } + }) + debugLog(`registerBidding: Email sent to contact ${contact.contactEmail}`) + } catch (emailError) { + debugError(`Failed to send bidding invitation email to contact ${contact.contactEmail}:`, emailError) + } + } + } + } catch (contactError) { + debugError('Failed to fetch contact emails:', contactError) + } } // 4. 입찰 공고 SMS 알림 전송 debugLog('registerBidding: Sending SMS...') @@ -1467,6 +1513,41 @@ export async function saveBiddingDraft( } } +// 본입찰용 품목별 견적 조회 (협력업체용) +export async function getPartnerBiddingItemQuotations(biddingCompanyId: number) { + try { + const savedQuotations = await db + .select({ + prItemId: companyPrItemBids.prItemId, + bidUnitPrice: companyPrItemBids.bidUnitPrice, + bidAmount: companyPrItemBids.bidAmount, + proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate, + technicalSpecification: companyPrItemBids.technicalSpecification, + currency: companyPrItemBids.currency + }) + .from(companyPrItemBids) + .where( + and( + eq(companyPrItemBids.biddingCompanyId, biddingCompanyId), + eq(companyPrItemBids.isPreQuote, false) // 본입찰 데이터 + ) + ) + + // Decimal 타입을 number로 변환 + return savedQuotations.map(item => ({ + prItemId: item.prItemId, + bidUnitPrice: parseFloat(item.bidUnitPrice || '0'), + bidAmount: parseFloat(item.bidAmount || '0'), + proposedDeliveryDate: item.proposedDeliveryDate, + technicalSpecification: item.technicalSpecification, + currency: item.currency + })) + } catch (error) { + console.error('Failed to get partner bidding item quotations:', error) + return [] + } +} + // ================================================= // 협력업체 페이지용 함수들 (Partners) // ================================================= @@ -1839,14 +1920,14 @@ export async function submitPartnerResponse( // adjustmentDate: response.priceAdjustmentForm.adjustmentDate || null, // nonApplicableReason: response.priceAdjustmentForm.nonApplicableReason, // } - + // // // 기존 연동제 정보가 있는지 확인 // const existingPriceAdjustment = await tx // .select() // .from(priceAdjustmentForms) // .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId)) // .limit(1) - + // // if (existingPriceAdjustment.length > 0) { // // 업데이트 // await tx @@ -2573,4 +2654,4 @@ export async function setSpecificationMeetingParticipation(biddingCompanyId: num console.error('Failed to update specification meeting participation:', error) return { success: false, error: '사양설명회 참여상태 업데이트에 실패했습니다.' } } -}
\ No newline at end of file +} diff --git a/lib/bidding/handlers.ts b/lib/bidding/handlers.ts index 760d7900..11955a39 100644 --- a/lib/bidding/handlers.ts +++ b/lib/bidding/handlers.ts @@ -164,9 +164,21 @@ export async function mapBiddingInvitationToTemplateVariables(payload: { contactEmail?: string | null; }>; message?: string; + specificationMeeting?: { + meetingDate: Date | string | null; + meetingTime: string | null; + location: string | null; + address: string | null; + contactPerson: string | null; + contactPhone: string | null; + contactEmail: string | null; + agenda: string | null; + materials: string | null; + notes: string | null; + }; requestedAt: Date; }): Promise<Record<string, string>> { - const { bidding, biddingItems, vendors, message, requestedAt } = payload; + const { bidding, biddingItems, vendors, message, specificationMeeting, requestedAt } = payload; // 제목 const title = bidding.title || '입찰'; @@ -223,15 +235,15 @@ export async function mapBiddingInvitationToTemplateVariables(payload: { // 사양설명회 정보 const hasSpecMeeting = bidding.hasSpecificationMeeting ? '예' : '아니오'; - const specMeetingStart = bidding.submissionStartDate ? bidding.submissionStartDate.toLocaleString('ko-KR') : ''; - const specMeetingEnd = bidding.submissionEndDate ? bidding.submissionEndDate.toLocaleString('ko-KR') : ''; + const specMeetingStart = bidding.submissionStartDate ? new Date(bidding.submissionStartDate).toISOString().slice(0, 16).replace('T', ' ') : ''; + const specMeetingEnd = bidding.submissionEndDate ? new Date(bidding.submissionEndDate).toISOString().slice(0, 16).replace('T', ' ') : ''; const specMeetingStartDup = specMeetingStart; const specMeetingEndDup = specMeetingEnd; // 입찰서제출기간 정보 const submissionPeriodExecution = '예'; // 입찰 기간이 있으므로 예 - const submissionPeriodStart = bidding.submissionStartDate ? bidding.submissionStartDate.toLocaleString('ko-KR') : ''; - const submissionPeriodEnd = bidding.submissionEndDate ? bidding.submissionEndDate.toLocaleString('ko-KR') : ''; + const submissionPeriodStart = bidding.submissionStartDate ? new Date(bidding.submissionStartDate).toISOString().slice(0, 16).replace('T', ' ') : ''; + const submissionPeriodEnd = bidding.submissionEndDate ? new Date(bidding.submissionEndDate).toISOString().slice(0, 16).replace('T', ' ') : ''; // 대상 자재 수 const targetMaterialCount = biddingItems.length.toString(); diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts index 81daf506..1dd06b3c 100644 --- a/lib/bidding/pre-quote/service.ts +++ b/lib/bidding/pre-quote/service.ts @@ -1496,8 +1496,9 @@ export async function sendBiddingBasicContracts( template: "contract-sign-request",
context: {
vendorName: vendor.vendorName,
+ templateCount: contractTypes.length,
templateName: contractTypes.map(ct => ct.templateName).join(', '),
- loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/basic-contract`,
+ loginUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/partners/basic-contract`,
language:'ko'
},
});
diff --git a/lib/bidding/receive/biddings-receive-columns.tsx b/lib/bidding/receive/biddings-receive-columns.tsx index d5798782..ab2c0d02 100644 --- a/lib/bidding/receive/biddings-receive-columns.tsx +++ b/lib/bidding/receive/biddings-receive-columns.tsx @@ -192,17 +192,17 @@ export function getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): Co cell: ({ row }) => {
const startDate = row.original.submissionStartDate
const endDate = row.original.submissionEndDate
-
+
if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
-
+
const now = new Date()
- const isActive = now >= startDate && now <= endDate
- const isPast = now > endDate
-
+ const isActive = now >= new Date(startDate) && now <= new Date(endDate)
+ const isPast = now > new Date(endDate)
+
return (
<div className="text-xs">
<div className={`${isActive ? 'text-green-600 font-medium' : isPast ? 'text-red-600' : 'text-gray-600'}`}>
- {formatDate(startDate, "KR")} ~ {formatDate(endDate, "KR")}
+ {new Date(startDate).toISOString().slice(0, 16).replace('T', ' ')} ~ {new Date(endDate).toISOString().slice(0, 16).replace('T', ' ')}
</div>
{isActive && (
<Badge variant="default" className="text-xs mt-1">진행중</Badge>
diff --git a/lib/bidding/receive/biddings-receive-table.tsx b/lib/bidding/receive/biddings-receive-table.tsx index 0995c6a2..97d627ea 100644 --- a/lib/bidding/receive/biddings-receive-table.tsx +++ b/lib/bidding/receive/biddings-receive-table.tsx @@ -45,6 +45,7 @@ type BiddingReceiveItem = { participantParticipated: number
participantDeclined: number
participantPending: number
+ participantFinalSubmitted: number
// 개찰 정보
openedAt: Date | null
@@ -72,7 +73,6 @@ export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) { const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingReceiveItem> | null>(null)
const [isOpeningBidding, setIsOpeningBidding] = React.useState(false)
- const [isEarlyOpeningBidding, setIsEarlyOpeningBidding] = React.useState(false)
const router = useRouter()
const { data: session } = useSession()
@@ -180,59 +180,22 @@ export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) { const selectedRows = table.getFilteredSelectedRowModel().rows
const selectedBiddingForAction = selectedRows.length > 0 ? selectedRows[0].original : null
- // 조기개찰 가능 여부 확인
- const canEarlyOpen = React.useMemo(() => {
- if (!selectedBiddingForAction) return false
-
- const now = new Date()
- const submissionEndDate = selectedBiddingForAction.submissionEndDate
-
- // 참여협력사가 1명 이상이어야 함
- if (selectedBiddingForAction.participantParticipated < 1) return false
-
- // 입찰서 제출기간 내여야 함
- if (!submissionEndDate || now > submissionEndDate) return false
-
- // 미제출 협력사가 0이어야 함
- if (selectedBiddingForAction.participantPending > 0) return false
-
- // 참여협력사 + 포기협력사 = 참여예정협력사 여야 함
- const participatedOrDeclined = selectedBiddingForAction.participantParticipated + selectedBiddingForAction.participantDeclined
- return participatedOrDeclined === selectedBiddingForAction.participantExpected
- }, [selectedBiddingForAction])
-
// 개찰 가능 여부 확인
const canOpen = React.useMemo(() => {
if (!selectedBiddingForAction) return false
- // 참여협력사가 1명 이상이어야 함
- if (selectedBiddingForAction.participantParticipated < 1) return false
-
const now = new Date()
const submissionEndDate = selectedBiddingForAction.submissionEndDate
- // 입찰서 제출기간이 종료되어야 함
- return submissionEndDate && now > submissionEndDate
- }, [selectedBiddingForAction])
+ // 1. 입찰 마감일이 지났으면 무조건 가능
+ if (submissionEndDate && now > submissionEndDate) return true
- const handleEarlyOpenBidding = React.useCallback(async () => {
- if (!selectedBiddingForAction) return
+ // 2. 입찰 기간 내 조기개찰 조건 확인
+ // - 미제출 협력사가 0이어야 함 (참여예정 = 최종제출 + 포기)
+ const participatedOrDeclined = selectedBiddingForAction.participantFinalSubmitted + selectedBiddingForAction.participantDeclined
+ const isEarlyOpenPossible = participatedOrDeclined === selectedBiddingForAction.participantExpected
- setIsEarlyOpeningBidding(true)
- try {
- const result = await earlyOpenBiddingAction(selectedBiddingForAction.id)
- if (result.success) {
- toast.success("조기개찰이 완료되었습니다.")
- // 데이터 리프레시
- window.location.reload()
- } else {
- toast.error(result.message || "조기개찰에 실패했습니다.")
- }
- } catch (error) {
- toast.error("조기개찰 중 오류가 발생했습니다.")
- } finally {
- setIsEarlyOpeningBidding(false)
- }
+ return isEarlyOpenPossible
}, [selectedBiddingForAction])
const handleOpenBidding = React.useCallback(async () => {
@@ -270,20 +233,11 @@ export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) { onCompactChange={handleCompactChange}
>
<div className="flex items-center gap-2">
- {/* <Button
- variant="outline"
- size="sm"
- onClick={handleEarlyOpenBidding}
- disabled={!selectedBiddingForAction || !canEarlyOpen || isEarlyOpeningBidding}
- >
- {isEarlyOpeningBidding && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- 조기개찰
- </Button> */}
<Button
variant="outline"
size="sm"
onClick={handleOpenBidding}
- disabled={!selectedBiddingForAction || isOpeningBidding}
+ disabled={!selectedBiddingForAction || !canOpen || isOpeningBidding}
>
{isOpeningBidding && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
개찰
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts index 521f4c33..489268c6 100644 --- a/lib/bidding/service.ts +++ b/lib/bidding/service.ts @@ -33,7 +33,8 @@ import { like, notInArray, inArray, - isNull + isNull, + isNotNull } from 'drizzle-orm' import { revalidatePath } from 'next/cache' import { filterColumns } from '@/lib/filter-columns' @@ -390,429 +391,36 @@ export async function getBiddings(input: GetBiddingsSchema) { id: biddings.id, biddingNumber: biddings.biddingNumber, originalBiddingNumber: biddings.originalBiddingNumber, - revision: biddings.revision, projectName: biddings.projectName, - itemName: biddings.itemName, title: biddings.title, - description: biddings.description, - biddingSourceType: biddings.biddingSourceType, - isUrgent: biddings.isUrgent, // 계약 정보 contractType: biddings.contractType, biddingType: biddings.biddingType, - awardCount: biddings.awardCount, - contractStartDate: biddings.contractStartDate, - contractEndDate: biddings.contractEndDate, // 일정 관리 - preQuoteDate: biddings.preQuoteDate, biddingRegistrationDate: biddings.biddingRegistrationDate, submissionStartDate: biddings.submissionStartDate, submissionEndDate: biddings.submissionEndDate, - evaluationDate: biddings.evaluationDate, // 회의 및 문서 hasSpecificationMeeting: biddings.hasSpecificationMeeting, - hasPrDocument: biddings.hasPrDocument, - prNumber: biddings.prNumber, // 가격 정보 currency: biddings.currency, budget: biddings.budget, targetPrice: biddings.targetPrice, - finalBidPrice: biddings.finalBidPrice, // 상태 및 담당자 status: biddings.status, - isPublic: biddings.isPublic, - purchasingOrganization: biddings.purchasingOrganization, - bidPicId: biddings.bidPicId, bidPicName: biddings.bidPicName, - supplyPicId: biddings.supplyPicId, - supplyPicName: biddings.supplyPicName, // 메타 정보 remarks: biddings.remarks, - createdBy: biddings.createdBy, - createdAt: biddings.createdAt, updatedAt: biddings.updatedAt, updatedBy: biddings.updatedBy, - - // 사양설명회 상세 정보 - hasSpecificationMeetingDetails: sql<boolean>`${specificationMeetings.id} IS NOT NULL`.as('has_specification_meeting_details'), - meetingDate: specificationMeetings.meetingDate, - meetingLocation: specificationMeetings.location, - meetingContactPerson: specificationMeetings.contactPerson, - meetingIsRequired: specificationMeetings.isRequired, - - // PR 문서 집계 - prDocumentCount: sql<number>` - COALESCE(( - SELECT count(*) - FROM pr_documents - WHERE bidding_id = ${biddings.id} - ), 0) - `.as('pr_document_count'), - - prDocumentNames: sql<string[]>` - ( - SELECT array_agg(document_name ORDER BY registered_at DESC) - FROM pr_documents - WHERE bidding_id = ${biddings.id} - LIMIT 5 - ) - `.as('pr_document_names'), - - // 참여 현황 집계 (전체) - participantExpected: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - ), 0) - `.as('participant_expected'), - - // === 사전견적 참여 현황 === - preQuotePending: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND invitation_status IN ('pending', 'pre_quote_sent') - ), 0) - `.as('pre_quote_pending'), - - preQuoteAccepted: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND invitation_status = 'pre_quote_accepted' - ), 0) - `.as('pre_quote_accepted'), - - preQuoteDeclined: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND invitation_status = 'pre_quote_declined' - ), 0) - `.as('pre_quote_declined'), - - preQuoteSubmitted: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND invitation_status = 'pre_quote_submitted' - ), 0) - `.as('pre_quote_submitted'), - - // === 입찰 참여 현황 === - biddingPending: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND invitation_status = 'bidding_sent' - ), 0) - `.as('bidding_pending'), - - biddingAccepted: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND invitation_status = 'bidding_accepted' - ), 0) - `.as('bidding_accepted'), - - biddingDeclined: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND invitation_status = 'bidding_declined' - ), 0) - `.as('bidding_declined'), - - biddingCancelled: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND invitation_status = 'bidding_cancelled' - ), 0) - `.as('bidding_cancelled'), - - biddingSubmitted: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND invitation_status = 'bidding_submitted' - ), 0) - `.as('bidding_submitted'), - - // === 호환성을 위한 기존 컬럼 (사전견적 기준) === - participantParticipated: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND invitation_status = 'pre_quote_submitted' - ), 0) - `.as('participant_participated'), - - participantDeclined: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND invitation_status IN ('pre_quote_declined', 'bidding_declined') - ), 0) - `.as('participant_declined'), - - participantPending: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND invitation_status IN ('pending', 'pre_quote_sent', 'bidding_sent') - ), 0) - `.as('participant_pending'), - - participantAccepted: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND invitation_status IN ('pre_quote_accepted', 'bidding_accepted') - ), 0) - `.as('participant_accepted'), - - // 참여율 계산 (입찰 기준 - 응찰 완료 / 전체) - participationRate: sql<number>` - CASE - WHEN ( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - ) > 0 - THEN ROUND( - ( - SELECT count(*)::decimal - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND invitation_status = 'bidding_submitted' - ) / ( - SELECT count(*)::decimal - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - ) * 100, 1 - ) - ELSE 0 - END - `.as('participation_rate'), - - // 견적 금액 통계 - avgPreQuoteAmount: sql<number>` - ( - SELECT AVG(pre_quote_amount) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND pre_quote_amount IS NOT NULL - ) - `.as('avg_pre_quote_amount'), - - minPreQuoteAmount: sql<number>` - ( - SELECT MIN(pre_quote_amount) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND pre_quote_amount IS NOT NULL - ) - `.as('min_pre_quote_amount'), - - maxPreQuoteAmount: sql<number>` - ( - SELECT MAX(pre_quote_amount) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND pre_quote_amount IS NOT NULL - ) - `.as('max_pre_quote_amount'), - - avgFinalQuoteAmount: sql<number>` - ( - SELECT AVG(final_quote_amount) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND final_quote_amount IS NOT NULL - ) - `.as('avg_final_quote_amount'), - - minFinalQuoteAmount: sql<number>` - ( - SELECT MIN(final_quote_amount) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND final_quote_amount IS NOT NULL - ) - `.as('min_final_quote_amount'), - - maxFinalQuoteAmount: sql<number>` - ( - SELECT MAX(final_quote_amount) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND final_quote_amount IS NOT NULL - ) - `.as('max_final_quote_amount'), - - // 선정 및 낙찰 정보 - selectedForFinalBidCount: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND is_pre_quote_selected = true - ), 0) - `.as('selected_for_final_bid_count'), - - winnerCount: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND is_winner = true - ), 0) - `.as('winner_count'), - - winnerCompanyNames: sql<string[]>` - ( - SELECT array_agg(v.vendor_name ORDER BY v.vendor_name) - FROM bidding_companies bc - JOIN vendors v ON bc.company_id = v.id - WHERE bc.bidding_id = ${biddings.id} - AND bc.is_winner = true - ) - `.as('winner_company_names'), - - // 일정 상태 계산 - submissionStatus: sql<string>` - CASE - WHEN ${biddings.submissionStartDate} IS NULL OR ${biddings.submissionEndDate} IS NULL - THEN 'not_scheduled' - WHEN NOW() < ${biddings.submissionStartDate} - THEN 'scheduled' - WHEN NOW() BETWEEN ${biddings.submissionStartDate} AND ${biddings.submissionEndDate} - THEN 'active' - WHEN NOW() > ${biddings.submissionEndDate} - THEN 'closed' - ELSE 'unknown' - END - `.as('submission_status'), - - // 마감까지 남은 일수 - daysUntilDeadline: sql<number>` - CASE - WHEN ${biddings.submissionEndDate} IS NOT NULL - AND NOW() < ${biddings.submissionEndDate} - THEN EXTRACT(DAYS FROM (${biddings.submissionEndDate} - NOW()))::integer - ELSE NULL - END - `.as('days_until_deadline'), - - // 시작까지 남은 일수 - daysUntilStart: sql<number>` - CASE - WHEN ${biddings.submissionStartDate} IS NOT NULL - AND NOW() < ${biddings.submissionStartDate} - THEN EXTRACT(DAYS FROM (${biddings.submissionStartDate} - NOW()))::integer - ELSE NULL - END - `.as('days_until_start'), - - // 예산 대비 최저 견적 비율 - budgetEfficiencyRate: sql<number>` - CASE - WHEN ${biddings.budget} IS NOT NULL AND ${biddings.budget} > 0 - AND ( - SELECT MIN(final_quote_amount) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND final_quote_amount IS NOT NULL - ) IS NOT NULL - THEN ROUND( - ( - SELECT MIN(final_quote_amount) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND final_quote_amount IS NOT NULL - ) / ${biddings.budget} * 100, 1 - ) - ELSE NULL - END - `.as('budget_efficiency_rate'), - - // 내정가 대비 최저 견적 비율 - targetPriceEfficiencyRate: sql<number>` - CASE - WHEN ${biddings.targetPrice} IS NOT NULL AND ${biddings.targetPrice} > 0 - AND ( - SELECT MIN(final_quote_amount) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND final_quote_amount IS NOT NULL - ) IS NOT NULL - THEN ROUND( - ( - SELECT MIN(final_quote_amount) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND final_quote_amount IS NOT NULL - ) / ${biddings.targetPrice} * 100, 1 - ) - ELSE NULL - END - `.as('target_price_efficiency_rate'), - - // 입찰 진행 단계 점수 (0-100) - progressScore: sql<number>` - CASE ${biddings.status} - WHEN 'bidding_generated' THEN 10 - WHEN 'request_for_quotation' THEN 20 - WHEN 'received_quotation' THEN 40 - WHEN 'set_target_price' THEN 60 - WHEN 'bidding_opened' THEN 70 - WHEN 'bidding_closed' THEN 80 - WHEN 'evaluation_of_bidding' THEN 90 - WHEN 'vendor_selected' THEN 100 - WHEN 'bidding_disposal' THEN 0 - ELSE 0 - END - `.as('progress_score'), - - // 마지막 활동일 (가장 최근 업체 응답일) - lastActivityDate: sql<Date>` - GREATEST( - ${biddings.updatedAt}, - COALESCE(( - SELECT MAX(updated_at) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - ), ${biddings.updatedAt}) - ) - `.as('last_activity_date'), }) .from(biddings) - .leftJoin( - specificationMeetings, - sql`${biddings.id} = ${specificationMeetings.biddingId}` - ) .where(finalWhere) .orderBy(...orderByColumns) .limit(input.perPage) @@ -995,8 +603,8 @@ export async function getParticipantCountsForBidding(biddingId: number) { const expected = expectedResult[0]?.count || 0 - // 참여 완료 수 - const participatedResult = await db + // 최종 제출 완료 수 (Strict) + const finalSubmittedResult = await db .select({ count: count() }) .from(biddingCompanies) .where(and( @@ -1004,6 +612,23 @@ export async function getParticipantCountsForBidding(biddingId: number) { eq(biddingCompanies.invitationStatus, 'bidding_submitted') )) + const finalSubmitted = finalSubmittedResult[0]?.count || 0 + + // 참여 완료 수 (Broad: 최종제출 OR (참여수락 AND 견적제출)) + const participatedResult = await db + .select({ count: count() }) + .from(biddingCompanies) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + or( + eq(biddingCompanies.invitationStatus, 'bidding_submitted'), + and( + eq(biddingCompanies.invitationStatus, 'bidding_accepted'), + isNotNull(biddingCompanies.finalQuoteAmount) + ) + ) + )) + const participated = participatedResult[0]?.count || 0 // 거부/취소 수 @@ -1020,7 +645,8 @@ export async function getParticipantCountsForBidding(biddingId: number) { const declined = declinedResult[0]?.count || 0 - // 대기중 수 + // 대기중 수 (Expected - Participated - Declined) + // 또는: Pending OR Sent OR (Accepted AND Quote IS NULL) const pendingResult = await db .select({ count: count() }) .from(biddingCompanies) @@ -1029,7 +655,10 @@ export async function getParticipantCountsForBidding(biddingId: number) { or( eq(biddingCompanies.invitationStatus, 'pending'), eq(biddingCompanies.invitationStatus, 'bidding_sent'), - eq(biddingCompanies.invitationStatus, 'bidding_accepted') + and( + eq(biddingCompanies.invitationStatus, 'bidding_accepted'), + isNull(biddingCompanies.finalQuoteAmount) + ) ) )) @@ -1039,7 +668,8 @@ export async function getParticipantCountsForBidding(biddingId: number) { participantExpected: expected, participantParticipated: participated, participantDeclined: declined, - participantPending: pending + participantPending: pending, + participantFinalSubmitted: finalSubmitted } } catch (error) { console.error('Error in getParticipantCountsForBidding:', error) @@ -1047,7 +677,8 @@ export async function getParticipantCountsForBidding(biddingId: number) { participantExpected: 0, participantParticipated: 0, participantDeclined: 0, - participantPending: 0 + participantPending: 0, + participantFinalSubmitted: 0 } } } @@ -1614,8 +1245,8 @@ export async function updateBidding(input: UpdateBiddingInput, userId: string) { .set(updateData) .where(eq(biddings.id, input.id)) - revalidatePath('/admin/biddings') - revalidatePath(`/admin/biddings/${input.id}`) + revalidatePath('/evcp/bid') + revalidatePath(`/evcp/bid/${input.id}/info`) return { success: true, @@ -1651,7 +1282,7 @@ export async function deleteBidding(id: number) { .delete(biddings) .where(eq(biddings.id, id)) - revalidatePath('/admin/biddings') + revalidatePath('/evcp/bid') return { success: true, @@ -3539,14 +3170,48 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u invitedAt: new Date(), invitationStatus: 'pending' as const, // 초대 대기 상태로 초기화 // 제출 정보는 초기화 - submittedAt: null, - quotationPrice: null, - quotationCurrency: null, - quotationValidityDays: null, - deliveryDate: null, - remarks: null, + finalQuoteAmount: null, + finalQuoteSubmittedAt: null, + isFinalSubmission: false, + isWinner: null, + awardRatio: null, + respondedAt: null, + + // 연락처 정보 복제 + contactPerson: company.contactPerson, + contactEmail: company.contactEmail, + contactPhone: company.contactPhone, + + // 본입찰 대상 선정 여부 복제 (중요: 차수증가 시에도 대상 업체 유지) + isPreQuoteSelected: company.isPreQuoteSelected, + // 본입찰 참여 여부 초기화 (다시 참여해야 함) + isBiddingParticipated: null, + // 본입찰 초대 여부 초기화 (다시 초대해야 함) + isBiddingInvited: false, + + notes: company.notes, })) ) + + // 6-1. 벤더 담당자 복제 (추가) + const existingContacts = await tx + .select() + .from(biddingCompaniesContacts) + .where(eq(biddingCompaniesContacts.biddingId, biddingId)) + + if (existingContacts.length > 0) { + await tx + .insert(biddingCompaniesContacts) + .values( + existingContacts.map((contact) => ({ + biddingId: newBidding.id, + vendorId: contact.vendorId, + contactName: contact.contactName, + contactEmail: contact.contactEmail, + contactNumber: contact.contactNumber, + })) + ) + } } // 7. 사양설명회 정보 복제 (있는 경우) diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx index 10fe71a9..03429cca 100644 --- a/lib/bidding/vendor/partners-bidding-detail.tsx +++ b/lib/bidding/vendor/partners-bidding-detail.tsx @@ -32,10 +32,11 @@ import { submitPartnerResponse, updatePartnerBiddingParticipation, saveBiddingDraft, - getPriceAdjustmentFormByBiddingCompanyId + getPriceAdjustmentFormByBiddingCompanyId, + getPartnerBiddingItemQuotations } from '../detail/service' import { cancelBiddingResponse } from '../detail/bidding-actions' -import { getPrItemsForBidding, getSavedPrItemQuotations } from '@/lib/bidding/pre-quote/service' +import { getPrItemsForBidding } from '@/lib/bidding/pre-quote/service' import { getBiddingConditions } from '@/lib/bidding/service' import { PrItemsPricingTable } from './components/pr-items-pricing-table' import { SimpleFileUpload } from './components/simple-file-upload' @@ -68,13 +69,13 @@ interface BiddingDetail { contractType: string biddingType: string awardCount: string | null - contractStartDate: Date | null - contractEndDate: Date | null - preQuoteDate: Date | null - biddingRegistrationDate: Date | null - submissionStartDate: Date | null - submissionEndDate: Date | null - evaluationDate: Date | null + contractStartDate: Date | string | null + contractEndDate: Date | string | null + preQuoteDate: Date | string | null + biddingRegistrationDate: Date | string | null + submissionStartDate: Date | string | null + submissionEndDate: Date | string | null + evaluationDate: Date | string | null currency: string budget: number | null targetPrice: number | null @@ -109,7 +110,7 @@ interface PrItem { materialGroupInfo: string | null materialNumber: string | null materialInfo: string | null - requestedDeliveryDate: Date | null + requestedDeliveryDate: Date | string | null annualUnitPrice: string | null currency: string | null quantity: string | null @@ -160,16 +161,17 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD const [isCancelling, setIsCancelling] = React.useState(false) const [isFinalSubmission, setIsFinalSubmission] = React.useState(false) const [isBiddingNoticeLoading, setIsBiddingNoticeLoading] = React.useState(false) + const [isExpired, setIsExpired] = React.useState(false) // 입찰공고 관련 상태 const [biddingNotice, setBiddingNotice] = React.useState<{ id?: number - biddingId?: number + biddingId?: number | null title?: string content?: string isTemplate?: boolean - createdAt?: string - updatedAt?: string + createdAt?: string | Date + updatedAt?: string | Date } | null>(null) const [biddingConditions, setBiddingConditions] = React.useState<BiddingConditions | null>(null) const [isNoticeOpen, setIsNoticeOpen] = React.useState(false) @@ -274,6 +276,13 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD }) setBiddingDetail(result) + + // 만료 여부 확인 + if (result.submissionEndDate) { + const now = new Date() + const deadline = new Date(result.submissionEndDate) + setIsExpired(deadline < now) + } // 기존 응답 데이터로 폼 초기화 setResponseData({ @@ -297,7 +306,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD if (result?.biddingCompanyId) { try { // 입찰 데이터를 가져와서 본입찰용으로 변환 - const preQuoteData = await getSavedPrItemQuotations(result.biddingCompanyId) + const preQuoteData = await getPartnerBiddingItemQuotations(result.biddingCompanyId) if (preQuoteData && Array.isArray(preQuoteData) && preQuoteData.length > 0) { console.log('입찰 데이터:', preQuoteData) @@ -325,19 +334,19 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD }, 0) setTotalQuotationAmount(total) console.log('계산된 총 금액:', total) + + // 응찰 확정 시에만 입찰 금액을 finalQuoteAmount로 설정 + if (total > 0 && result?.isBiddingParticipated === true) { + console.log('응찰 확정됨, 입찰 금액 설정:', total) + console.log('입찰 금액을 finalQuoteAmount로 설정:', total) + setResponseData(prev => ({ + ...prev, + finalQuoteAmount: total.toString() + })) + } } } - // 응찰 확정 시에만 입찰 금액을 finalQuoteAmount로 설정 - if (totalQuotationAmount > 0 && result?.isBiddingParticipated === true) { - console.log('응찰 확정됨, 입찰 금액 설정:', totalQuotationAmount) - console.log('입찰 금액을 finalQuoteAmount로 설정:', totalQuotationAmount) - setResponseData(prev => ({ - ...prev, - finalQuoteAmount: totalQuotationAmount.toString() - })) - } - // 연동제 데이터 로드 (입찰에서 답변했으면 로드, 아니면 입찰 조건 확인) if (result.priceAdjustmentResponse !== null) { // 입찰에서 이미 답변한 경우 - 연동제 폼 로드 @@ -385,6 +394,16 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD const handleParticipationDecision = async (participated: boolean) => { if (!biddingDetail) return + // 만료 체크 + if (isExpired) { + toast({ + title: "참여 불가", + description: "제출 마감일이 지났습니다. 더 이상 입찰에 참여할 수 없습니다.", + variant: "destructive", + }) + return + } + setIsUpdatingParticipation(true) try { const result = await updatePartnerBiddingParticipation( @@ -409,7 +428,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD // 참여 확정 시 입찰 데이터가 있다면 로드 if (participated && updatedDetail.biddingCompanyId) { try { - const preQuoteData = await getSavedPrItemQuotations(updatedDetail.biddingCompanyId) + const preQuoteData = await getPartnerBiddingItemQuotations(updatedDetail.biddingCompanyId) if (preQuoteData && Array.isArray(preQuoteData) && preQuoteData.length > 0) { console.log('참여확정 후 입찰 데이터:', preQuoteData) @@ -487,18 +506,14 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD const handleSaveDraft = async () => { if (!biddingDetail || !userId) return - // 제출 마감일 체크 - if (biddingDetail.submissionEndDate) { - const now = new Date() - const deadline = new Date(biddingDetail.submissionEndDate) - if (deadline < now) { - toast({ - title: "접근 제한", - description: "제출 마감일이 지났습니다. 더 이상 입찰에 참여할 수 없습니다.", - variant: "destructive", - }) - return - } + // 제출 마감일 체크 (상태 사용) + if (isExpired) { + toast({ + title: "접근 제한", + description: "제출 마감일이 지났습니다. 더 이상 입찰에 참여할 수 없습니다.", + variant: "destructive", + }) + return } // 입찰 마감 상태 체크 @@ -566,7 +581,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD } } - // 응찰 취소 핸들러 + // 응찰 포기 핸들러 const handleCancelResponse = async () => { if (!biddingDetail || !userId) return @@ -590,7 +605,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD if (result.success) { toast({ - title: '응찰 취소 완료', + title: '응찰 포기 완료', description: '응찰이 취소되었습니다.', }) // 페이지 새로고침 @@ -602,7 +617,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD } } else { toast({ - title: '응찰 취소 실패', + title: '응찰 포기 실패', description: result.error, variant: 'destructive', }) @@ -611,7 +626,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD console.error('Failed to cancel bidding response:', error) toast({ title: '오류', - description: '응찰 취소에 실패했습니다.', + description: '응찰 포기에 실패했습니다.', variant: 'destructive', }) } finally { @@ -622,18 +637,14 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD const handleSubmitResponse = () => { if (!biddingDetail) return - // 제출 마감일 체크 - if (biddingDetail.submissionEndDate) { - const now = new Date() - const deadline = new Date(biddingDetail.submissionEndDate) - if (deadline < now) { - toast({ - title: "접근 제한", - description: "제출 마감일이 지났습니다. 더 이상 입찰에 참여할 수 없습니다.", - variant: "destructive", - }) - return - } + // 제출 마감일 체크 (상태 사용) + if (isExpired) { + toast({ + title: "접근 제한", + description: "제출 마감일이 지났습니다. 더 이상 입찰에 참여할 수 없습니다.", + variant: "destructive", + }) + return } // 입찰 마감 상태 체크 @@ -692,8 +703,11 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD additionalProposals: responseData.additionalProposals, isFinalSubmission, // 최종제출 여부 추가 // 연동제 데이터 추가 (연동제 적용요건 문의가 있는 경우만) - priceAdjustmentResponse: biddingDetail.isPriceAdjustmentApplicableQuestion ? responseData.priceAdjustmentResponse : undefined, - priceAdjustmentForm: biddingDetail.isPriceAdjustmentApplicableQuestion && responseData.priceAdjustmentResponse !== null ? priceAdjustmentForm : undefined, + priceAdjustmentResponse: biddingDetail.isPriceAdjustmentApplicableQuestion ? (responseData.priceAdjustmentResponse ?? false) as boolean : false, + priceAdjustmentForm: biddingDetail.isPriceAdjustmentApplicableQuestion && (responseData.priceAdjustmentResponse ?? false) as boolean ? { + ...priceAdjustmentForm, + adjustmentRatio: parseFloat(priceAdjustmentForm.adjustmentRatio) || 0 + } : undefined, prItemQuotations: prItemQuotations.length > 0 ? prItemQuotations.map(q => ({ prItemId: q.prItemId, bidUnitPrice: q.bidUnitPrice, @@ -849,57 +863,57 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD {/* 제출 마감일 D-day */} - {biddingDetail.submissionEndDate && ( - <div className="pt-4 border-t"> - <Label className="text-sm font-medium text-muted-foreground mb-2 block">제출 마감 정보</Label> - {(() => { - const now = new Date() - const deadline = new Date(biddingDetail.submissionEndDate.toISOString().slice(0, 16).replace('T', ' ')) - const isExpired = deadline < now - const timeLeft = deadline.getTime() - now.getTime() - const daysLeft = Math.floor(timeLeft / (1000 * 60 * 60 * 24)) - const hoursLeft = Math.floor((timeLeft % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) - - return ( - <div className={`p-3 rounded-lg border-2 ${ - isExpired - ? 'border-red-200 bg-red-50' - : daysLeft <= 1 - ? 'border-orange-200 bg-orange-50' - : 'border-green-200 bg-green-50' - }`}> - <div className="flex items-center justify-between"> - <div className="flex items-center gap-2"> - <Calendar className="w-5 h-5" /> - <span className="font-medium">제출 마감일:</span> - <span className="text-lg font-semibold"> - {biddingDetail.submissionEndDate.toISOString().slice(0, 16).replace('T', ' ')} - </span> + {biddingDetail.submissionEndDate && ( + <div className="pt-4 border-t"> + <Label className="text-sm font-medium text-muted-foreground mb-2 block">제출 마감 정보</Label> + {(() => { + const now = new Date() + const deadline = new Date(biddingDetail.submissionEndDate.toISOString().slice(0, 16).replace('T', ' ')) + // isExpired 상태 사용 + const timeLeft = deadline.getTime() - now.getTime() + const daysLeft = Math.floor(timeLeft / (1000 * 60 * 60 * 24)) + const hoursLeft = Math.floor((timeLeft % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) + + return ( + <div className={`p-3 rounded-lg border-2 ${ + isExpired + ? 'border-red-200 bg-red-50' + : daysLeft <= 1 + ? 'border-orange-200 bg-orange-50' + : 'border-green-200 bg-green-50' + }`}> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Calendar className="w-5 h-5" /> + <span className="font-medium">제출 마감일:</span> + <span className="text-lg font-semibold"> + {biddingDetail.submissionEndDate.toISOString().slice(0, 16).replace('T', ' ')} + </span> + </div> + {isExpired ? ( + <Badge variant="destructive" className="ml-2"> + 마감됨 + </Badge> + ) : daysLeft <= 1 ? ( + <Badge variant="secondary" className="ml-2 bg-orange-100 text-orange-800"> + {daysLeft === 0 ? `${hoursLeft}시간 남음` : `${daysLeft}일 남음`} + </Badge> + ) : ( + <Badge variant="secondary" className="ml-2 bg-green-100 text-green-800"> + {daysLeft}일 남음 + </Badge> + )} </div> - {isExpired ? ( - <Badge variant="destructive" className="ml-2"> - 마감됨 - </Badge> - ) : daysLeft <= 1 ? ( - <Badge variant="secondary" className="ml-2 bg-orange-100 text-orange-800"> - {daysLeft === 0 ? `${hoursLeft}시간 남음` : `${daysLeft}일 남음`} - </Badge> - ) : ( - <Badge variant="secondary" className="ml-2 bg-green-100 text-green-800"> - {daysLeft}일 남음 - </Badge> + {isExpired && ( + <div className="mt-2 text-sm text-red-600"> + ⚠️ 제출 마감일이 지났습니다. 입찰 제출이 불가능합니다. + </div> )} </div> - {isExpired && ( - <div className="mt-2 text-sm text-red-600"> - ⚠️ 제출 마감일이 지났습니다. 입찰 제출이 불가능합니다. - </div> - )} - </div> - ) - })()} - </div> - )} + ) + })()} + </div> + )} {/* 일정 정보 */} <div className="pt-4 border-t"> @@ -1086,15 +1100,15 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD <div className="flex justify-center gap-4"> <Button onClick={() => handleParticipationDecision(true)} - disabled={isUpdatingParticipation} + disabled={isUpdatingParticipation || isExpired} className="min-w-[120px]" > <CheckCircle className="w-4 h-4 mr-2" /> - 참여하기 + {isExpired ? '마감됨' : '참여하기'} </Button> <Button onClick={() => handleParticipationDecision(false)} - disabled={isUpdatingParticipation} + disabled={isUpdatingParticipation || isExpired} variant="destructive" className="min-w-[120px]" > @@ -1407,7 +1421,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD {/* 응찰 제출 버튼 - 참여 확정 상태일 때만 표시 */} <div className="flex justify-between pt-4 gap-2"> - {/* 응찰 취소 버튼 (최종제출 아닌 경우만) */} + {/* 응찰 포기 버튼 (최종제출 아닌 경우만) */} {biddingDetail.finalQuoteSubmittedAt && !biddingDetail.isFinalSubmission && ( <Button variant="destructive" @@ -1416,14 +1430,14 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD className="min-w-[100px]" > <Trash2 className="w-4 h-4 mr-2" /> - {isCancelling ? '취소 중...' : '응찰 취소'} + {isCancelling ? '취소 중...' : '응찰 포기'} </Button> )} <div className="flex gap-2 ml-auto"> <Button variant="outline" onClick={handleSaveDraft} - disabled={isSavingDraft || isSubmitting || biddingDetail.isFinalSubmission} + disabled={isSavingDraft || isSubmitting || biddingDetail.isFinalSubmission || isExpired} className="min-w-[100px]" > <Save className="w-4 h-4 mr-2" /> @@ -1431,7 +1445,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD </Button> <Button onClick={handleSubmitResponse} - disabled={isSubmitting || isSavingDraft || biddingDetail.isFinalSubmission} + disabled={isSubmitting || isSavingDraft || biddingDetail.isFinalSubmission || isExpired} className="min-w-[100px]" > <Send className="w-4 h-4 mr-2" /> diff --git a/lib/general-contracts/detail/general-contract-basic-info.tsx b/lib/general-contracts/detail/general-contract-basic-info.tsx index fc147b59..b0378912 100644 --- a/lib/general-contracts/detail/general-contract-basic-info.tsx +++ b/lib/general-contracts/detail/general-contract-basic-info.tsx @@ -26,6 +26,26 @@ interface ContractBasicInfoProps { contractId: number
}
+interface PaymentBeforeDelivery {
+ apBond?: boolean
+ apBondPercent?: string
+ drawingSubmission?: boolean
+ drawingSubmissionPercent?: string
+ materialPurchase?: boolean
+ materialPurchasePercent?: string
+ additionalCondition?: boolean
+ additionalConditionPercent?: string
+}
+
+interface PaymentAfterDelivery {
+ commissioning?: boolean
+ commissioningPercent?: string
+ finalDocument?: boolean
+ finalDocumentPercent?: string
+ other?: boolean
+ otherText?: string
+}
+
export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
const session = useSession()
const [isLoading, setIsLoading] = useState(false)
@@ -59,10 +79,10 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) { linkedBidNumber: '',
notes: '',
// 개별 JSON 필드들 (스키마에 맞게)
- paymentBeforeDelivery: {},
+ paymentBeforeDelivery: {} as PaymentBeforeDelivery,
paymentDelivery: '', // varchar 타입
paymentDeliveryAdditionalText: '',
- paymentAfterDelivery: {},
+ paymentAfterDelivery: {} as PaymentAfterDelivery,
paymentTerm: '',
taxType: '',
liquidatedDamages: false,
@@ -314,7 +334,7 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) { if (formData.contractScope !== '단가' && formData.contractScope !== '물량(실적)' && !formData.currency) {
validationErrors.push('계약통화')
}
- if (!formData.paymentTerm) validationErrors.push('지불조건')
+ // if (!formData.paymentTerm) validationErrors.push('지불조건')
if (!formData.taxType) validationErrors.push('세금조건')
if (validationErrors.length > 0) {
@@ -1132,10 +1152,11 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) { </div>
</div>
- {/* 지불조건 */}
+ {/* 지불조건 -> 세금조건 (지불조건 삭제됨) */}
<div className="space-y-2">
- <Label className="text-sm font-medium">지불조건</Label>
+ <Label className="text-sm font-medium">세금조건</Label>
<div className="space-y-2">
+ {/* 지불조건 필드 삭제됨
<div className="space-y-1">
<Label htmlFor="paymentTerm" className="text-xs">지불조건 *</Label>
<Select
@@ -1160,6 +1181,7 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) { </SelectContent>
</Select>
</div>
+ */}
<div className="space-y-1">
<Label htmlFor="taxType" className="text-xs">세금조건 *</Label>
<Select
@@ -1196,19 +1218,27 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) { }))}
className="rounded w-4 h-4"
/>
- <Label htmlFor="liquidatedDamages" className="text-xs">지체상금</Label>
- <Input
- type="number"
- min="0"
- placeholder="%"
- className="w-16 h-6 text-xs"
- value={formData.liquidatedDamagesPercent || ''}
- onChange={(e) => setFormData(prev => ({
- ...prev,
- liquidatedDamagesPercent: e.target.value
- }))}
- disabled={!formData.liquidatedDamages}
- />
+ <div className="flex flex-col">
+ <Label htmlFor="liquidatedDamages" className="text-xs">지체상금 (최대 징수 가능 비율)</Label>
+ <span className="text-[10px] text-muted-foreground">
+ * 일반적인 계약조건: 지체일수당 계약금액의 0.3%, 최대치 10%
+ </span>
+ </div>
+ <div className="flex items-center gap-1">
+ <Input
+ type="number"
+ min="0"
+ placeholder=""
+ className="w-16 h-6 text-xs text-right"
+ value={formData.liquidatedDamagesPercent || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ liquidatedDamagesPercent: e.target.value
+ }))}
+ disabled={!formData.liquidatedDamages}
+ />
+ <span className="text-xs">%</span>
+ </div>
</div>
</div>
</div>
diff --git a/lib/general-contracts/detail/general-contract-items-table.tsx b/lib/general-contracts/detail/general-contract-items-table.tsx index bda2901e..15e5c926 100644 --- a/lib/general-contracts/detail/general-contract-items-table.tsx +++ b/lib/general-contracts/detail/general-contract-items-table.tsx @@ -460,7 +460,11 @@ export function ContractItemsTable({ id="batch-unit-price" type="number" value={batchInputData.contractUnitPrice} - onChange={(e) => setBatchInputData(prev => ({ ...prev, contractUnitPrice: e.target.value }))} + onChange={(e) => { + // Leading zero removal + const val = e.target.value.replace(/^0+(?=[0-9])/, '') + setBatchInputData(prev => ({ ...prev, contractUnitPrice: val })) + }} placeholder="계약단가 입력 (선택사항)" /> </div> @@ -507,7 +511,7 @@ export function ContractItemsTable({ </div> {/* 요약 정보 */} - <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4"> + {/* <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4"> <div className="space-y-1"> <Label className="text-sm font-medium">총 계약금액</Label> <div className={`text-lg font-bold ${isTotalAmountDisabled ? 'text-gray-400' : 'text-primary'}`}> @@ -515,24 +519,12 @@ export function ContractItemsTable({ </div> </div> <div className="space-y-1"> - <Label className="text-sm font-medium">가용예산</Label> - <div className="text-lg font-bold"> - {formatCurrency(availableBudget, localItems[0]?.contractCurrency || 'KRW')} - </div> - </div> - <div className="space-y-1"> - <Label className="text-sm font-medium">가용예산 比 (금액차)</Label> - <div className={`text-lg font-bold ${amountDifference >= 0 ? 'text-green-600' : 'text-red-600'}`}> - {formatCurrency(amountDifference, localItems[0]?.contractCurrency || 'KRW')} - </div> - </div> - <div className="space-y-1"> <Label className="text-sm font-medium">가용예산 比 (비율)</Label> <div className={`text-lg font-bold ${budgetRatio <= 100 ? 'text-green-600' : 'text-red-600'}`}> {budgetRatio.toFixed(1)}% </div> </div> - </div> + </div> */} </CardHeader> <CardContent> diff --git a/lib/procurement-items/table/add-procurement-items-dialog.tsx b/lib/procurement-items/table/add-procurement-items-dialog.tsx index b2915dc2..acec40af 100644 --- a/lib/procurement-items/table/add-procurement-items-dialog.tsx +++ b/lib/procurement-items/table/add-procurement-items-dialog.tsx @@ -94,7 +94,7 @@ export function AddProcurementItemDialog({ name="itemCode"
render={({ field }) => (
<FormItem>
- <FormLabel>품목코드</FormLabel>
+ <FormLabel>품목코드 *</FormLabel>
<FormControl>
<Input placeholder="예: ITEM001" {...field} />
</FormControl>
diff --git a/lib/procurement-items/validations.ts b/lib/procurement-items/validations.ts index 1d753e9d..2004049f 100644 --- a/lib/procurement-items/validations.ts +++ b/lib/procurement-items/validations.ts @@ -35,7 +35,7 @@ export const searchParamsCache = createSearchParamsCache({ export type GetProcurementItemsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
export const createProcurementItemSchema = z.object({
- itemCode: z.string(),
+ itemCode: z.string().min(1, "품목코드는 필수입니다"),
itemName: z.string().min(1, "품목명은 필수입니다"),
material: z.string().max(100).optional().or(z.literal("")),
specification: z.string().max(255).optional().or(z.literal("")),
|
