summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-11-25 11:51:27 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-11-25 11:51:27 +0000
commit835df8ddc115ffa74414db2a4fab7efc0d0056a9 (patch)
treebfe814c7b51ee1541d84b6e2dee01f28594763ac /lib
parent6160e8bd61360ada9e8e0574671c38292eaba9e7 (diff)
(최겸) 구매 입찰 수정v2
Diffstat (limited to 'lib')
-rw-r--r--lib/bidding/approval-actions.ts67
-rw-r--r--lib/bidding/detail/service.ts8
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-columns.tsx8
-rw-r--r--lib/bidding/failure/biddings-closure-dialog.tsx53
-rw-r--r--lib/bidding/failure/biddings-failure-table.tsx131
-rw-r--r--lib/bidding/handlers.ts130
-rw-r--r--lib/bidding/list/biddings-table-toolbar-actions.tsx1
-rw-r--r--lib/bidding/list/create-bidding-dialog.tsx2148
-rw-r--r--lib/bidding/receive/biddings-receive-table.tsx4
-rw-r--r--lib/bidding/vendor/components/pr-items-pricing-table.tsx8
-rw-r--r--lib/bidding/vendor/partners-bidding-detail.tsx62
11 files changed, 249 insertions, 2371 deletions
diff --git a/lib/bidding/approval-actions.ts b/lib/bidding/approval-actions.ts
index 9a37d83b..06f5c206 100644
--- a/lib/bidding/approval-actions.ts
+++ b/lib/bidding/approval-actions.ts
@@ -188,7 +188,7 @@ export async function requestBiddingInvitationWithApproval(data: {
.update(biddings)
.set({
status: 'approval_pending', // 결재 진행중 상태
- updatedBy: data.currentUser.epId,
+ updatedBy: Number(data.currentUser.epId),
updatedAt: new Date()
})
.where(eq(biddings.id, data.biddingId));
@@ -269,13 +269,20 @@ export async function prepareBiddingClosureApprovalData(data: {
// 1. 입찰 정보 조회 (템플릿 변수용)
debugLog('[BiddingClosureApproval] 입찰 정보 조회 시작');
const { default: db } = await import('@/db/db');
- const { biddings } = await import('@/db/schema');
+ const { biddings, prItemsForBidding, biddingCompanies, vendors } = await import('@/db/schema');
const { eq } = await import('drizzle-orm');
const biddingInfo = await db
.select({
id: biddings.id,
title: biddings.title,
+ biddingNumber: biddings.biddingNumber,
+ projectName: biddings.projectName,
+ itemName: biddings.itemName,
+ biddingType: biddings.biddingType,
+ bidPicName: biddings.bidPicName,
+ supplyPicName: biddings.supplyPicName,
+ targetPrice: biddings.targetPrice,
})
.from(biddings)
.where(eq(biddings.id, data.biddingId))
@@ -286,9 +293,41 @@ export async function prepareBiddingClosureApprovalData(data: {
throw new Error('입찰 정보를 찾을 수 없습니다');
}
+ const bidding = biddingInfo[0];
+
+ // 입찰 대상 자재 정보 조회
+ const biddingItemsInfo = await db
+ .select({
+ id: prItemsForBidding.id,
+ materialCode: prItemsForBidding.materialNumber,
+ materialCodeName: prItemsForBidding.materialInfo,
+ quantity: prItemsForBidding.quantity,
+ quantityUnit: prItemsForBidding.quantityUnit,
+ targetUnitPrice: prItemsForBidding.targetUnitPrice,
+ currency: prItemsForBidding.targetCurrency,
+ })
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.biddingId, data.biddingId));
+
+ // 입찰 참여 업체 정보 조회
+ const vendorSubmissions = await db
+ .select({
+ vendorId: vendors.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ finalQuoteAmount: biddingCompanies.finalQuoteAmount,
+ submitted: biddingCompanies.isBiddingParticipated,
+ targetPrice: biddingCompanies.preQuoteAmount, // 사전견적 금액을 내정가로 사용
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(eq(biddingCompanies.biddingId, data.biddingId));
+
debugLog('[BiddingClosureApproval] 입찰 정보 조회 완료', {
biddingId: data.biddingId,
- title: biddingInfo[0].title,
+ title: bidding.title,
+ itemCount: biddingItemsInfo.length,
+ vendorCount: vendorSubmissions.length,
});
// 2. 템플릿 변수 매핑
@@ -296,7 +335,9 @@ export async function prepareBiddingClosureApprovalData(data: {
const requestedAt = new Date();
const { mapBiddingClosureToTemplateVariables } = await import('./handlers');
const variables = await mapBiddingClosureToTemplateVariables({
- biddingId: data.biddingId,
+ bidding,
+ biddingItems: biddingItemsInfo,
+ vendorSubmissions,
description: data.description,
requestedAt,
});
@@ -305,7 +346,8 @@ export async function prepareBiddingClosureApprovalData(data: {
});
return {
- bidding: biddingInfo[0],
+ bidding,
+ biddingItems: biddingItemsInfo,
variables,
};
}
@@ -341,12 +383,19 @@ export async function requestBiddingClosureWithApproval(data: {
const { eq } = await import('drizzle-orm');
// 유찰상태인지 확인
- const { bidding } = await db
+ const biddingResult = await db
.select()
.from(biddings)
.where(eq(biddings.id, data.biddingId))
.limit(1);
+ if (biddingResult.length === 0) {
+ debugError('[BiddingClosureApproval] 입찰 정보를 찾을 수 없음');
+ throw new Error('입찰 정보를 찾을 수 없습니다');
+ }
+
+ const bidding = biddingResult[0];
+
if (bidding.status !== 'bidding_disposal') {
debugError('[BiddingClosureApproval] 유찰 상태가 아닙니다.');
throw new Error('유찰 상태인 입찰만 폐찰할 수 있습니다.');
@@ -359,7 +408,7 @@ export async function requestBiddingClosureWithApproval(data: {
.update(biddings)
.set({
status: 'approval_pending', // 폐찰 결재 진행중 상태
- updatedBy: data.currentUser.epId,
+ updatedBy: Number(data.currentUser.id),
updatedAt: new Date()
})
.where(eq(biddings.id, data.biddingId));
@@ -370,7 +419,7 @@ export async function requestBiddingClosureWithApproval(data: {
});
// 3. 결재 데이터 준비
- const { bidding: approvalBidding, variables } = await prepareBiddingClosureApprovalData({
+ const { bidding: approvalBidding, biddingItems, variables } = await prepareBiddingClosureApprovalData({
biddingId: data.biddingId,
description: data.description,
});
@@ -514,7 +563,7 @@ export async function requestBiddingAwardWithApproval(data: {
.update(biddings)
.set({
status: 'approval_pending', // 낙찰 결재 진행중 상태
- updatedBy: data.currentUser.epId,
+ updatedBy: Number(data.currentUser.id),
updatedAt: new Date()
})
.where(eq(biddings.id, data.biddingId));
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts
index 297c6f98..e49c0628 100644
--- a/lib/bidding/detail/service.ts
+++ b/lib/bidding/detail/service.ts
@@ -1360,10 +1360,10 @@ export async function updateBiddingParticipation(
}
// =================================================
-// 품목별 견적 관련 함수들 (본입찰용)
+// 품목별 입찰 관련 함수들 (본입찰용)
// =================================================
-// 품목별 견적 임시 저장 (본입찰용)
+// 품목별 입찰 임시 저장 (본입찰용)
export async function saveBiddingDraft(
biddingCompanyId: number,
prItemQuotations: Array<{
@@ -1380,7 +1380,7 @@ export async function saveBiddingDraft(
let totalAmount = 0
await db.transaction(async (tx) => {
- // 품목별 견적 Upsert 방식으로 저장
+ // 품목별 입찰 Upsert 방식으로 저장
for (const item of prItemQuotations) {
// 기존 데이터 확인
const existingItem = await tx
@@ -1437,7 +1437,7 @@ export async function saveBiddingDraft(
return {
success: true,
- message: '품목별 견적이 임시 저장되었습니다.',
+ message: '품목별 입찰이 임시 저장되었습니다.',
totalAmount
}
} catch (error) {
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
index 80e50119..a0b69020 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
@@ -72,7 +72,7 @@ export function getBiddingDetailVendorColumns({
},
{
accessorKey: 'quotationAmount',
- header: '견적금액',
+ header: '입찰금액',
cell: ({ row }) => {
const hasAmount = row.original.quotationAmount && Number(row.original.quotationAmount) > 0
return (
@@ -82,7 +82,7 @@ export function getBiddingDetailVendorColumns({
<button
onClick={() => onViewQuotationHistory?.(row.original)}
className="text-primary hover:text-primary/80 hover:underline cursor-pointer"
- title="품목별 견적 상세 보기"
+ title="품목별 입찰 상세 보기"
>
<span className="border-b-2 border-primary">
{Number(row.original.quotationAmount).toLocaleString()} {row.original.currency}
@@ -219,12 +219,12 @@ export function getBiddingDetailVendorColumns({
</>
)}
- {/* 견적 히스토리 (응찰한 업체만) */}
+ {/* 입찰 히스토리 (응찰한 업체만) */}
{vendor.isBiddingParticipated === true && onViewQuotationHistory && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onViewQuotationHistory(vendor)}>
- 견적 히스토리
+ 입찰 히스토리
</DropdownMenuItem>
</>
)}
diff --git a/lib/bidding/failure/biddings-closure-dialog.tsx b/lib/bidding/failure/biddings-closure-dialog.tsx
index f331167b..cea1f42a 100644
--- a/lib/bidding/failure/biddings-closure-dialog.tsx
+++ b/lib/bidding/failure/biddings-closure-dialog.tsx
@@ -4,7 +4,6 @@
import { useState } from "react"
import { useSession } from "next-auth/react"
import { toast } from "sonner"
-import { requestBiddingClosureWithApproval } from "@/lib/bidding/approval-actions"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
@@ -21,58 +20,41 @@ interface BiddingsClosureDialogProps {
biddingNumber: string;
} | null;
onSuccess?: () => void;
+ onApprovalPreview: (data: { description: string; files: File[]; biddingId: number }) => Promise<void>;
}
export function BiddingsClosureDialog({
open,
onOpenChange,
bidding,
- onSuccess
+ onSuccess,
+ onApprovalPreview
}: BiddingsClosureDialogProps) {
const { data: session } = useSession()
const [description, setDescription] = useState('')
const [files, setFiles] = useState<File[]>([])
- const [isSubmitting, setIsSubmitting] = useState(false)
- const handleSubmit = async (e: React.FormEvent) => {
+ const handleNextStep = async (e: React.FormEvent) => {
e.preventDefault()
-
+
if (!bidding || !description.trim()) {
toast.error('폐찰 사유를 입력해주세요.')
return
}
-
- setIsSubmitting(true)
try {
- const result = await requestBiddingClosureWithApproval({
- biddingId: bidding.id,
+ // 결재자 선택 단계로 데이터 전달
+ await onApprovalPreview({
description: description.trim(),
- files,
- currentUser: {
- id: session?.user?.id ? Number(session.user.id) : 0,
- epId: session?.user?.epId || null,
- email: session?.user?.email || undefined,
- },
+ files: files,
+ biddingId: bidding.id,
})
- if (result.status === 'pending_approval') {
- toast.success('폐찰 결재가 상신되었습니다.')
- onOpenChange(false)
- // 폼 초기화
- setDescription('')
- setFiles([])
- if (onSuccess) {
- onSuccess()
- }
- } else {
- toast.error('결재 상신에 실패했습니다.')
- }
+ // 다이얼로그 닫기
+ onOpenChange(false)
} catch (error) {
- console.error('폐찰 결재 상신 실패:', error)
- toast.error(error instanceof Error ? error.message : '결재 상신 중 오류가 발생했습니다.')
- } finally {
- setIsSubmitting(false)
+ console.error('결재 미리보기 준비 실패:', error)
+ toast.error('결재 미리보기 준비 중 오류가 발생했습니다.')
}
}
@@ -98,7 +80,7 @@ interface BiddingsClosureDialogProps {
</DialogDescription>
</DialogHeader>
- <form onSubmit={handleSubmit} className="space-y-4">
+ <form onSubmit={handleNextStep} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="description">폐찰 사유 <span className="text-destructive">*</span></Label>
<Textarea
@@ -133,16 +115,15 @@ interface BiddingsClosureDialogProps {
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
- disabled={isSubmitting}
>
취소
</Button>
<Button
type="submit"
- variant="destructive"
- disabled={isSubmitting || !description.trim()}
+ variant="default"
+ disabled={!description.trim()}
>
- {isSubmitting ? '상신 중...' : '결재 상신'}
+ 다음 단계
</Button>
</div>
</form>
diff --git a/lib/bidding/failure/biddings-failure-table.tsx b/lib/bidding/failure/biddings-failure-table.tsx
index a0f98466..c4e3be06 100644
--- a/lib/bidding/failure/biddings-failure-table.tsx
+++ b/lib/bidding/failure/biddings-failure-table.tsx
@@ -25,7 +25,7 @@ import { FileX, RefreshCw, Undo2 } from "lucide-react"
import { bidClosureAction, cancelDisposalAction } from "@/lib/bidding/actions"
import { increaseRoundOrRebid } from "@/lib/bidding/service"
import { ApprovalPreviewDialog } from "@/lib/approval/approval-preview-dialog"
-import { requestBiddingClosureWithApproval } from "@/lib/bidding/approval-actions"
+import { requestBiddingClosureWithApproval, prepareBiddingClosureApprovalData } from "@/lib/bidding/approval-actions"
import { useToast } from "@/hooks/use-toast"
type BiddingFailureItem = {
@@ -258,6 +258,86 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) {
}
}, [session?.user?.id, toast, router])
+ // 폐찰 결재 준비 및 결재 다이얼로그 열기 핸들러
+ const handleClosureApprovalSend = async (data: { description: string; files: File[]; biddingId: number }) => {
+ try {
+ if (!session?.user?.id) {
+ toast({
+ title: '오류',
+ description: '사용자 정보가 없습니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ // 결재 데이터 준비 (템플릿 변수 등)
+ const approvalData = await prepareBiddingClosureApprovalData({
+ biddingId: data.biddingId,
+ description: data.description,
+ })
+
+ // 결재 준비 완료 - approvalPreviewData와 결재 데이터 저장 및 결재 다이얼로그 열기
+ setApprovalPreviewData({
+ templateName: '폐찰 품의 요청서',
+ variables: approvalData.variables,
+ title: `폐찰 - ${selectedBidding?.title || ''}`,
+ description: data.description,
+ files: data.files,
+ })
+ setIsApprovalPreviewDialogOpen(true)
+ } catch (error) {
+ console.error('결재 준비 중 오류 발생:', error)
+ toast({
+ title: '오류',
+ description: '결재 준비 중 오류가 발생했습니다.',
+ variant: 'destructive',
+ })
+ }
+ }
+
+ // 폐찰 결재 상신 핸들러 - 결재 완료 시 실제 폐찰 실행
+ const handleClosureApprovalSubmit = async (data: { approvers: string[]; title: string; attachments?: File[] }) => {
+ if (!session?.user?.id || !approvalPreviewData || !selectedBidding) return
+
+ try {
+ const result = await requestBiddingClosureWithApproval({
+ biddingId: selectedBidding.id,
+ description: approvalPreviewData.description,
+ files: approvalPreviewData.files,
+ currentUser: {
+ id: Number(session.user.id),
+ epId: session.user.epId || null,
+ email: session.user.email || undefined
+ },
+ approvers: data.approvers,
+ })
+
+ if (1) {
+ toast({
+ title: '성공',
+ description: `폐찰 결재가 상신되었습니다. (ID: ${result.approvalId})`,
+ })
+ setIsApprovalPreviewDialogOpen(false)
+ setApprovalPreviewData(null)
+ handleBiddingClosureDialogClose()
+ router.refresh()
+ } else {
+ toast({
+ title: '오류',
+ description: '폐찰 결재 상신 중 오류가 발생했습니다.',
+ variant: 'destructive',
+ })
+ }
+ } catch (error) {
+ console.error('폐찰 결재 상신 실패:', error)
+ toast({
+ title: '오류',
+ description: '폐찰 결재 상신 중 오류가 발생했습니다.',
+ variant: 'destructive',
+ })
+ }
+ }
+
const handleCancelDisposal = React.useCallback(async (bidding: BiddingFailureItem) => {
if (!session?.user?.id) {
toast({
@@ -438,10 +518,7 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) {
router.refresh()
handleBiddingClosureDialogClose()
}}
- onApprovalPreview={(data) => {
- setApprovalPreviewData(data)
- setIsApprovalPreviewDialogOpen(true)
- }}
+ onApprovalPreview={handleClosureApprovalSend}
/>
)}
@@ -498,52 +575,10 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) {
name: session.user.name || undefined,
email: session.user.email || undefined
}}
- onConfirm={handleClosureApprovalConfirm}
+ onConfirm={handleClosureApprovalSubmit}
/>
)}
</>
)
- // 폐찰 결재 상신 핸들러
- const handleClosureApprovalConfirm = async (data: { approvers: string[]; title: string; attachments?: File[] }) => {
- if (!session?.user?.id || !approvalPreviewData || !selectedBidding) return
-
- try {
- const result = await requestBiddingClosureWithApproval({
- biddingId: selectedBidding.id,
- description: approvalPreviewData.description,
- files: approvalPreviewData.files,
- currentUser: {
- id: Number(session.user.id),
- epId: session.user.epId || null,
- email: session.user.email || undefined
- },
- approvers: data.approvers,
- })
-
- if (result.status === 'pending_approval') {
- toast({
- title: '성공',
- description: `폐찰 결재가 상신되었습니다. (ID: ${result.approvalId})`,
- })
- setIsApprovalPreviewDialogOpen(false)
- setApprovalPreviewData(null)
- handleBiddingClosureDialogClose()
- router.refresh()
- } else {
- toast({
- title: '오류',
- description: '폐찰 결재 상신 중 오류가 발생했습니다.',
- variant: 'destructive',
- })
- }
- } catch (error) {
- console.error('폐찰 결재 상신 실패:', error)
- toast({
- title: '오류',
- description: '폐찰 결재 상신 중 오류가 발생했습니다.',
- variant: 'destructive',
- })
- }
- }
}
diff --git a/lib/bidding/handlers.ts b/lib/bidding/handlers.ts
index d55107c0..a5cc72ae 100644
--- a/lib/bidding/handlers.ts
+++ b/lib/bidding/handlers.ts
@@ -289,83 +289,47 @@ export async function mapBiddingInvitationToTemplateVariables(payload: {
* @returns 템플릿 변수 객체 (Record<string, string>)
*/
export async function mapBiddingClosureToTemplateVariables(payload: {
- biddingId: number;
+ bidding: {
+ id: number;
+ title: string;
+ biddingNumber: string;
+ projectName: string | null;
+ itemName: string | null;
+ biddingType: string;
+ bidPicName: string | null;
+ supplyPicName: string | null;
+ targetPrice: string | null;
+ };
+ biddingItems: Array<{
+ id: number;
+ materialCode: string | null;
+ materialCodeName: string | null;
+ quantity: string | null;
+ quantityUnit: string | null;
+ targetUnitPrice: string | null;
+ currency: string | null;
+ }>;
+ vendorSubmissions: Array<{
+ vendorId: number | null;
+ vendorName: string | null;
+ vendorCode: string | null;
+ finalQuoteAmount: string | null;
+ submitted: boolean | null;
+ targetPrice: string | null;
+ }>;
description: string;
requestedAt: Date;
}): Promise<Record<string, string>> {
- const { biddingId, description, requestedAt } = payload;
-
- // 1. 입찰 정보 조회
- debugLog('[BiddingClosureMapper] 입찰 정보 조회 시작');
- const { default: db } = await import('@/db/db');
- const { biddings, prItemsForBidding, biddingCompanies, biddingVendorSubmissions } = await import('@/db/schema');
- const { eq, leftJoin } = await import('drizzle-orm');
-
- const biddingInfo = await db
- .select({
- id: biddings.id,
- title: biddings.title,
- biddingNumber: biddings.biddingNumber,
- projectName: biddings.projectName,
- itemName: biddings.itemName,
- biddingType: biddings.biddingType,
- bidPicName: biddings.bidPicName,
- supplyPicName: biddings.supplyPicName,
- targetPrice: biddings.targetPrice,
- winnerCount: biddings.winnerCount,
- })
- .from(biddings)
- .where(eq(biddings.id, biddingId))
- .limit(1);
-
- if (biddingInfo.length === 0) {
- debugError('[BiddingClosureMapper] 입찰 정보를 찾을 수 없음');
- throw new Error('입찰 정보를 찾을 수 없습니다');
- }
-
- const bidding = biddingInfo[0];
-
- // 2. 입찰 대상 자재 정보 조회
- const biddingItemsInfo = await db
- .select({
- id: prItemsForBidding.id,
- materialCode: prItemsForBidding.materialNumber,
- materialCodeName: prItemsForBidding.materialInfo,
- quantity: prItemsForBidding.quantity,
- quantityUnit: prItemsForBidding.quantityUnit,
- targetUnitPrice: prItemsForBidding.targetUnitPrice,
- currency: prItemsForBidding.targetCurrency,
- })
- .from(prItemsForBidding)
- .where(eq(prItemsForBidding.biddingId, biddingId));
-
- // 3. 입찰 참여 업체 및 제출 정보 조회
- const vendorSubmissions = await db
- .select({
- vendorId: biddingCompanies.vendorId,
- vendorName: biddingCompanies.vendorName,
- vendorCode: biddingCompanies.vendorCode,
- targetPrice: biddingVendorSubmissions.targetPrice,
- bidPrice: biddingVendorSubmissions.bidPrice,
- submitted: biddingVendorSubmissions.submitted,
- })
- .from(biddingCompanies)
- .leftJoin(biddingVendorSubmissions, eq(biddingCompanies.id, biddingVendorSubmissions.biddingCompanyId))
- .where(eq(biddingCompanies.biddingId, biddingId));
-
- debugLog('[BiddingClosureMapper] 입찰 정보 조회 완료', {
- biddingId,
- itemCount: biddingItemsInfo.length,
- vendorCount: vendorSubmissions.length,
- });
+ const { bidding, biddingItems, vendorSubmissions, description, requestedAt } = payload;
// 기본 정보 매핑
const title = bidding.title || '폐찰';
const biddingTitle = bidding.title || '';
const biddingNumber = bidding.biddingNumber || '';
- const winnerCount = (bidding.winnerCount || 1).toString();
+ const winnerCount = '1'; // 기본값
const contractType = bidding.biddingType || '';
- const targetPrice = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : '';
+ const targetPriceNum = bidding.targetPrice ? parseFloat(bidding.targetPrice) : 0;
+ const targetPrice = targetPriceNum ? targetPriceNum.toLocaleString() : '';
const biddingManager = bidding.bidPicName || bidding.supplyPicName || '';
const biddingOverview = bidding.itemName || '';
@@ -374,36 +338,34 @@ export async function mapBiddingClosureToTemplateVariables(payload: {
// 협력사별 입찰 현황 매핑
const vendorVariables: Record<string, string> = {};
- vendorSubmissions.forEach((vendor, index) => {
+ vendorSubmissions.filter(vendor => vendor.vendorId && vendor.vendorName).forEach((vendor, index) => {
const num = index + 1;
+ const targetPriceNum = vendor.targetPrice ? parseFloat(vendor.targetPrice) : 0;
+ const finalQuoteAmountNum = vendor.finalQuoteAmount ? parseFloat(vendor.finalQuoteAmount) : 0;
+
vendorVariables[`협력사_코드_${num}`] = vendor.vendorCode || '';
vendorVariables[`협력사명_${num}`] = vendor.vendorName || '';
vendorVariables[`응찰유무_${num}`] = vendor.submitted ? '응찰' : '미응찰';
- vendorVariables[`내정가_${num}`] = vendor.targetPrice ? vendor.targetPrice.toLocaleString() : '';
- vendorVariables[`입찰가_${num}`] = vendor.bidPrice ? vendor.bidPrice.toLocaleString() : '';
- vendorVariables[`비율_${num}`] = (vendor.targetPrice && vendor.bidPrice && vendor.targetPrice > 0)
- ? ((vendor.bidPrice / vendor.targetPrice) * 100).toFixed(2) + '%'
+ vendorVariables[`내정가_${num}`] = targetPriceNum ? targetPriceNum.toLocaleString() : '';
+ vendorVariables[`입찰가_${num}`] = finalQuoteAmountNum ? finalQuoteAmountNum.toLocaleString() : '';
+ vendorVariables[`비율_${num}`] = (targetPriceNum && finalQuoteAmountNum && targetPriceNum > 0)
+ ? ((finalQuoteAmountNum / targetPriceNum) * 100).toFixed(2) + '%'
: '';
});
// 품목별 입찰 정보 매핑 (간소화 - 첫 번째 품목 기준으로 매핑)
const materialVariables: Record<string, string> = {};
- biddingItemsInfo.forEach((item, index) => {
+ biddingItems.forEach((item, index) => {
const num = index + 1;
+ const quantityNum = item.quantity ? parseFloat(item.quantity) : 0;
+ const targetUnitPriceNum = item.targetUnitPrice ? parseFloat(item.targetUnitPrice) : 0;
+
materialVariables[`품목코드_${num}`] = item.materialCode || '';
materialVariables[`품목명_${num}`] = item.materialCodeName || '';
- materialVariables[`수량_${num}`] = item.quantity ? item.quantity.toLocaleString() : '';
+ materialVariables[`수량_${num}`] = quantityNum ? quantityNum.toString() : '';
materialVariables[`단위_${num}`] = item.quantityUnit || '';
materialVariables[`통화_${num}`] = item.currency || '';
- materialVariables[`내정가_${num}`] = item.targetUnitPrice ? item.targetUnitPrice.toLocaleString() : '';
-
- // 각 품목에 대한 협력사별 입찰가 (간소화: 동일 품목에 대한 모든 업체 입찰가 표시)
- vendorSubmissions.forEach((vendor, vendorIndex) => {
- const vendorNum = vendorIndex + 1;
- materialVariables[`협력사코드_${num}`] = vendor.vendorCode || '';
- materialVariables[`협력사명_${num}`] = vendor.vendorName || '';
- materialVariables[`입찰가_${num}`] = vendor.bidPrice ? vendor.bidPrice.toLocaleString() : '';
- });
+ materialVariables[`내정가_${num}`] = targetUnitPriceNum ? targetUnitPriceNum.toString() : '';
});
return {
diff --git a/lib/bidding/list/biddings-table-toolbar-actions.tsx b/lib/bidding/list/biddings-table-toolbar-actions.tsx
index 3f65f559..a3851630 100644
--- a/lib/bidding/list/biddings-table-toolbar-actions.tsx
+++ b/lib/bidding/list/biddings-table-toolbar-actions.tsx
@@ -16,7 +16,6 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { BiddingListItem } from "@/db/schema"
-// import { CreateBiddingDialog } from "./create-bidding-dialog"
import { TransmissionDialog } from "./biddings-transmission-dialog"
import { BiddingCreateDialog } from "@/components/bidding/create/bidding-create-dialog"
import { useForm } from "react-hook-form"
diff --git a/lib/bidding/list/create-bidding-dialog.tsx b/lib/bidding/list/create-bidding-dialog.tsx
deleted file mode 100644
index 90abda57..00000000
--- a/lib/bidding/list/create-bidding-dialog.tsx
+++ /dev/null
@@ -1,2148 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import { useRouter } from 'next/navigation'
-import { useForm } from 'react-hook-form'
-import { zodResolver } from '@hookform/resolvers/zod'
-import {
- Loader2,
- Plus,
- Trash2,
- FileText,
- Paperclip,
- ChevronRight,
- ChevronLeft,
- X,
-} from 'lucide-react'
-import { toast } from 'sonner'
-import { useSession } from 'next-auth/react'
-
-import { Button } from '@/components/ui/button'
-import {
- Dialog,
- DialogContent,
- DialogTrigger,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
-import {
- Form,
- FormControl,
- FormDescription,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from '@/components/ui/form'
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select'
-import { Input } from '@/components/ui/input'
-import { Textarea } from '@/components/ui/textarea'
-import { Switch } from '@/components/ui/switch'
-import { Label } from '@/components/ui/label'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { Tabs, TabsContent } from '@/components/ui/tabs'
-import { Checkbox } from '@/components/ui/checkbox'
-
-import { createBidding } from '@/lib/bidding/service'
-import {
- getIncotermsForSelection,
- getPaymentTermsForSelection,
- getPlaceOfShippingForSelection,
- getPlaceOfDestinationForSelection,
-} from '@/lib/procurement-select/service'
-import { TAX_CONDITIONS } from '@/lib/tax-conditions/types'
-import { createBiddingSchema, type CreateBiddingSchema } from '@/lib/bidding/validation'
-import { contractTypeLabels, biddingTypeLabels, awardCountLabels } from '@/db/schema'
-import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
-import { cn } from '@/lib/utils'
-import { PurchaseGroupCodeSelector } from '@/components/common/selectors/purchase-group-code/purchase-group-code-selector'
-import { ProcurementManagerSelector } from '@/components/common/selectors/procurement-manager/procurement-manager-selector'
-import { WbsCodeSingleSelector } from '@/components/common/selectors/wbs-code/wbs-code-single-selector'
-import { CostCenterSingleSelector } from '@/components/common/selectors/cost-center/cost-center-single-selector'
-import { GlAccountSingleSelector } from '@/components/common/selectors/gl-account/gl-account-single-selector'
-import { MaterialGroupSelectorDialogSingle } from '@/components/common/material/material-group-selector-dialog-single'
-import { MaterialSelectorDialogSingle } from '@/components/common/selectors/material/material-selector-dialog-single'
-import { ProjectSelector } from '@/components/ProjectSelector'
-
-// 사양설명회 정보 타입
-interface SpecificationMeetingInfo {
- meetingDate: string
- meetingTime: string
- location: string
- address: string
- contactPerson: string
- contactPhone: string
- contactEmail: string
- agenda: string
- materials: string
- notes: string
- isRequired: boolean
- meetingFiles: File[] // 사양설명회 첨부파일
-}
-
-// PR 아이템 정보 타입
-interface PRItemInfo {
- id: string // 임시 ID for UI
- prNumber: string
- projectId?: number // 프로젝트 ID 추가
- projectInfo?: string // 프로젝트 정보 (기존 호환성 유지)
- shi?: string // SHI 정보 추가
- quantity: string
- quantityUnit: string
- totalWeight: string
- weightUnit: string
- materialDescription: string
- hasSpecDocument: boolean
- requestedDeliveryDate: string
- specFiles: File[]
- isRepresentative: boolean // 대표 아이템 여부
- // 가격 정보
- annualUnitPrice: string
- currency: string
- // 자재 그룹 정보 (필수)
- materialGroupNumber: string // 자재그룹코드 - 필수
- materialGroupInfo: string // 자재그룹명 - 필수
- // 자재 정보
- materialNumber: string // 자재코드
- materialInfo: string // 자재명
- // 단위 정보
- priceUnit: string // 가격단위
- purchaseUnit: string // 구매단위
- materialWeight: string // 자재순중량
- // WBS 정보
- wbsCode: string // WBS 코드
- wbsName: string // WBS 명칭
- // Cost Center 정보
- costCenterCode: string // 코스트센터 코드
- costCenterName: string // 코스트센터 명칭
- // GL Account 정보
- glAccountCode: string // GL 계정 코드
- glAccountName: string // GL 계정 명칭
- // 내정 정보
- targetUnitPrice: string
- targetAmount: string
- targetCurrency: string
- // 예산 정보
- budgetAmount: string
- budgetCurrency: string
- // 실적 정보
- actualAmount: string
- actualCurrency: string
-}
-
-const TAB_ORDER = ['basic', 'schedule', 'details', 'manager'] as const
-type TabType = typeof TAB_ORDER[number]
-
-export function CreateBiddingDialog() {
- const router = useRouter()
- const [isSubmitting, setIsSubmitting] = React.useState(false)
- const { data: session } = useSession()
- const [open, setOpen] = React.useState(false)
- const [activeTab, setActiveTab] = React.useState<TabType>('basic')
- const [showSuccessDialog, setShowSuccessDialog] = React.useState(false)
- const [createdBiddingId, setCreatedBiddingId] = React.useState<number | null>(null)
- const [showCloseConfirmDialog, setShowCloseConfirmDialog] = React.useState(false)
-
- const [paymentTermsOptions, setPaymentTermsOptions] = React.useState<
- Array<{ code: string; description: string }>
- >([])
- const [incotermsOptions, setIncotermsOptions] = React.useState<
- Array<{ code: string; description: string }>
- >([])
- const [shippingPlaces, setShippingPlaces] = React.useState<
- Array<{ code: string; description: string }>
- >([])
- const [destinationPlaces, setDestinationPlaces] = React.useState<
- Array<{ code: string; description: string }>
- >([])
-
- const [specMeetingInfo, setSpecMeetingInfo] =
- React.useState<SpecificationMeetingInfo>({
- meetingDate: '',
- meetingTime: '',
- location: '',
- address: '',
- contactPerson: '',
- contactPhone: '',
- contactEmail: '',
- agenda: '',
- materials: '',
- notes: '',
- isRequired: false,
- meetingFiles: [],
- })
-
- const [prItems, setPrItems] = React.useState<PRItemInfo[]>([
- {
- id: `pr-default`,
- prNumber: '',
- projectId: undefined,
- projectInfo: '',
- shi: '',
- quantity: '',
- quantityUnit: 'EA',
- totalWeight: '',
- weightUnit: 'KG',
- materialDescription: '',
- hasSpecDocument: false,
- requestedDeliveryDate: '',
- specFiles: [],
- isRepresentative: true,
- annualUnitPrice: '',
- currency: 'KRW',
- materialGroupNumber: '',
- materialGroupInfo: '',
- materialNumber: '',
- materialInfo: '',
- priceUnit: '1',
- purchaseUnit: 'EA',
- materialWeight: '',
- wbsCode: '',
- wbsName: '',
- costCenterCode: '',
- costCenterName: '',
- glAccountCode: '',
- glAccountName: '',
- targetUnitPrice: '',
- targetAmount: '',
- targetCurrency: 'KRW',
- budgetAmount: '',
- budgetCurrency: 'KRW',
- actualAmount: '',
- actualCurrency: 'KRW',
- },
- ])
- const [selectedItemForFile, setSelectedItemForFile] = React.useState<string | null>(null)
- const [quantityWeightMode, setQuantityWeightMode] = React.useState<'quantity' | 'weight'>('quantity')
- const [costCenterDialogOpen, setCostCenterDialogOpen] = React.useState(false)
- const [glAccountDialogOpen, setGlAccountDialogOpen] = React.useState(false)
- const [wbsCodeDialogOpen, setWbsCodeDialogOpen] = React.useState(false)
- const [materialGroupDialogOpen, setMaterialGroupDialogOpen] = React.useState(false)
- const [materialDialogOpen, setMaterialDialogOpen] = React.useState(false)
- const [biddingConditions, setBiddingConditions] = React.useState({
- paymentTerms: '',
- taxConditions: '',
- incoterms: '',
- incotermsOption: '',
- contractDeliveryDate: '',
- shippingPort: '',
- destinationPort: '',
- isPriceAdjustmentApplicable: false,
- sparePartOptions: '',
- })
-
- // -- 데이터 로딩 및 상태 동기화 로직
- const loadPaymentTerms = React.useCallback(async () => {
- try {
- const data = await getPaymentTermsForSelection()
- setPaymentTermsOptions(data)
- const p008Exists = data.some((item) => item.code === 'P008')
- if (p008Exists) {
- setBiddingConditions((prev) => ({ ...prev, paymentTerms: 'P008' }))
- }
- } catch (error) {
- console.error('Failed to load payment terms:', error)
- toast.error('결제조건 목록을 불러오는데 실패했습니다.')
- }
- }, [])
-
- const loadIncoterms = React.useCallback(async () => {
- try {
- const data = await getIncotermsForSelection()
- setIncotermsOptions(data)
- const dapExists = data.some((item) => item.code === 'DAP')
- if (dapExists) {
- setBiddingConditions((prev) => ({ ...prev, incoterms: 'DAP' }))
- }
- } catch (error) {
- console.error('Failed to load incoterms:', error)
- toast.error('운송조건 목록을 불러오는데 실패했습니다.')
- }
- }, [])
-
- const loadShippingPlaces = React.useCallback(async () => {
- try {
- const data = await getPlaceOfShippingForSelection()
- setShippingPlaces(data)
- } catch (error) {
- console.error('Failed to load shipping places:', error)
- toast.error('선적지 목록을 불러오는데 실패했습니다.')
- }
- }, [])
-
- const loadDestinationPlaces = React.useCallback(async () => {
- try {
- const data = await getPlaceOfDestinationForSelection()
- setDestinationPlaces(data)
- } catch (error) {
- console.error('Failed to load destination places:', error)
- toast.error('하역지 목록을 불러오는데 실패했습니다.')
- }
- }, [])
-
- React.useEffect(() => {
- if (open) {
- loadPaymentTerms()
- loadIncoterms()
- loadShippingPlaces()
- loadDestinationPlaces()
- const v1Exists = TAX_CONDITIONS.some((item) => item.code === 'V1')
- if (v1Exists) {
- setBiddingConditions((prev) => ({ ...prev, taxConditions: 'V1' }))
- }
- }
- }, [open, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces])
-
- const hasPrDocuments = React.useMemo(() => {
- return prItems.some((item) => item.prNumber.trim() !== '' || item.specFiles.length > 0)
- }, [prItems])
-
- const form = useForm<CreateBiddingSchema>({
- resolver: zodResolver(createBiddingSchema),
- defaultValues: {
- revision: 0,
- projectId: 0,
- projectName: '',
- itemName: '',
- title: '',
- description: '',
- content: '',
- contractType: 'general',
- biddingType: 'equipment',
- biddingTypeCustom: '',
- awardCount: 'single',
- contractStartDate: '',
- contractEndDate: '',
- submissionStartDate: '',
- submissionEndDate: '',
- hasSpecificationMeeting: false,
- prNumber: '',
- currency: 'KRW',
- status: 'bidding_generated',
- isPublic: false,
- purchasingOrganization: '',
- managerName: '',
- managerEmail: '',
- managerPhone: '',
- remarks: '',
- },
- })
-
- const currentTabIndex = TAB_ORDER.indexOf(activeTab)
- const isLastTab = currentTabIndex === TAB_ORDER.length - 1
- const isFirstTab = currentTabIndex === 0
-
- const goToNextTab = () => {
- if (!isLastTab) {
- setActiveTab(TAB_ORDER[currentTabIndex + 1])
- }
- }
-
- const goToPreviousTab = () => {
- if (!isFirstTab) {
- setActiveTab(TAB_ORDER[currentTabIndex - 1])
- }
- }
-
- const getTabValidationState = React.useCallback(() => {
- const formValues = form.getValues()
- const formErrors = form.formState.errors
-
- return {
- basic: {
- isValid: formValues.title.trim() !== '',
- hasErrors: !!formErrors.title,
- },
- schedule: {
- isValid:
- formValues.submissionStartDate &&
- formValues.submissionEndDate &&
- (!formValues.hasSpecificationMeeting ||
- (specMeetingInfo.meetingDate && specMeetingInfo.location && specMeetingInfo.contactPerson)),
- hasErrors: !!(formErrors.submissionStartDate || formErrors.submissionEndDate),
- },
- details: {
- // 임시로 자재그룹코드 필수 체크 해제
- // isValid: prItems.length > 0 && prItems.every(item => item.materialGroupNumber.trim() !== ''),
- isValid: prItems.length > 0,
- hasErrors: false,
- },
- manager: {
- // 임시로 담당자 필수 체크 해제
- isValid: true,
- hasErrors: false, // !!(formErrors.managerName || formErrors.managerEmail || formErrors.managerPhone),
- },
- }
- }, [form, specMeetingInfo.meetingDate, specMeetingInfo.location, specMeetingInfo.contactPerson, prItems])
-
- const tabValidation = getTabValidationState()
-
- const isCurrentTabValid = () => {
- const validation = tabValidation[activeTab as keyof typeof tabValidation]
- return validation?.isValid ?? true
- }
-
- const representativePrNumber = React.useMemo(() => {
- const representativeItem = prItems.find((item) => item.isRepresentative)
- return representativeItem?.prNumber || ''
- }, [prItems])
-
- const representativeItemName = React.useMemo(() => {
- const representativeItem = prItems.find((item) => item.isRepresentative)
- return representativeItem?.materialGroupInfo || ''
- }, [prItems])
-
- React.useEffect(() => {
- form.setValue('hasPrDocument', hasPrDocuments)
- form.setValue('prNumber', representativePrNumber)
- form.setValue('itemName', representativeItemName)
- }, [hasPrDocuments, representativePrNumber, representativeItemName, form])
-
- const addPRItem = () => {
- const newItem: PRItemInfo = {
- id: `pr-${Math.random().toString(36).substr(2, 9)}`,
- prNumber: '',
- projectId: undefined,
- projectInfo: '',
- shi: '',
- quantity: '',
- quantityUnit: 'EA',
- totalWeight: '',
- weightUnit: 'KG',
- materialDescription: '',
- hasSpecDocument: false,
- requestedDeliveryDate: '',
- specFiles: [],
- isRepresentative: prItems.length === 0,
- annualUnitPrice: '',
- currency: 'KRW',
- materialGroupNumber: '',
- materialGroupInfo: '',
- materialNumber: '',
- materialInfo: '',
- priceUnit: '1',
- purchaseUnit: 'EA',
- materialWeight: '',
- wbsCode: '',
- wbsName: '',
- costCenterCode: '',
- costCenterName: '',
- glAccountCode: '',
- glAccountName: '',
- targetUnitPrice: '',
- targetAmount: '',
- targetCurrency: 'KRW',
- budgetAmount: '',
- budgetCurrency: 'KRW',
- actualAmount: '',
- actualCurrency: 'KRW',
- }
- setPrItems((prev) => [...prev, newItem])
- }
-
- const removePRItem = (id: string) => {
- if (prItems.length <= 1) {
- toast.error('최소 하나의 품목이 필요합니다.')
- return
- }
-
- setPrItems((prev) => {
- const filteredItems = prev.filter((item) => item.id !== id)
- const removedItem = prev.find((item) => item.id === id)
- if (removedItem?.isRepresentative && filteredItems.length > 0) {
- filteredItems[0].isRepresentative = true
- }
- return filteredItems
- })
- if (selectedItemForFile === id) {
- setSelectedItemForFile(null)
- }
- }
-
- const updatePRItem = (id: string, updates: Partial<PRItemInfo>) => {
- setPrItems((prev) =>
- prev.map((item) => {
- if (item.id === id) {
- const updatedItem = { ...item, ...updates }
- // 내정단가, 수량, 중량, 가격단위가 변경되면 내정금액 재계산
- if (updates.targetUnitPrice || updates.quantity || updates.totalWeight || updates.priceUnit) {
- updatedItem.targetAmount = calculateTargetAmount(updatedItem)
- }
- return updatedItem
- }
- return item
- })
- )
- }
-
- const setRepresentativeItem = (id: string) => {
- setPrItems((prev) =>
- prev.map((item) => ({
- ...item,
- isRepresentative: item.id === id,
- }))
- )
- }
-
- const handleQuantityWeightModeChange = (mode: 'quantity' | 'weight') => {
- setQuantityWeightMode(mode)
- }
-
- const calculateTargetAmount = (item: PRItemInfo) => {
- const unitPrice = parseFloat(item.targetUnitPrice) || 0
- const priceUnit = parseFloat(item.priceUnit) || 1 // 기본값 1
- let amount = 0
-
- if (quantityWeightMode === 'quantity') {
- const quantity = parseFloat(item.quantity) || 0
- // (수량 / 가격단위) * 내정단가
- amount = (quantity / priceUnit) * unitPrice
- } else {
- const weight = parseFloat(item.totalWeight) || 0
- // (중량 / 가격단위) * 내정단가
- amount = (weight / priceUnit) * unitPrice
- }
-
- // 소수점 버림
- return Math.floor(amount).toString()
- }
-
- const addSpecFiles = (itemId: string, files: File[]) => {
- updatePRItem(itemId, {
- specFiles: [...(prItems.find((item) => item.id === itemId)?.specFiles || []), ...files],
- })
- setSelectedItemForFile(null)
- }
-
- const removeSpecFile = (itemId: string, fileIndex: number) => {
- const item = prItems.find((item) => item.id === itemId)
- if (item) {
- const newFiles = item.specFiles.filter((_, index) => index !== fileIndex)
- updatePRItem(itemId, { specFiles: newFiles })
- }
- }
-
- const handleNextClick = () => {
- if (!isCurrentTabValid()) {
- if (activeTab === 'basic') {
- toast.error('기본 정보를 모두 입력해주세요.')
- } else if (activeTab === 'schedule') {
- if (form.watch('hasSpecificationMeeting')) {
- toast.error('사양설명회 필수 정보를 입력해주세요.')
- } else {
- toast.error('제출 시작일시와 마감일시를 입력해주세요.')
- }
- } else if (activeTab === 'details') {
- toast.error('최소 하나의 아이템이 필요하며, 모든 아이템에 자재그룹코드가 필수입니다.')
- }
- return
- }
-
- goToNextTab()
- }
-
- async function onSubmit(data: CreateBiddingSchema) {
- if (data.hasSpecificationMeeting) {
- const requiredFields = [
- { field: specMeetingInfo.meetingDate, name: '회의일시' },
- { field: specMeetingInfo.location, name: '회의 장소' },
- { field: specMeetingInfo.contactPerson, name: '담당자' },
- ]
-
- const missingFields = requiredFields.filter((item) => !item.field.trim())
- if (missingFields.length > 0) {
- toast.error(`사양설명회 필수 정보가 누락되었습니다: ${missingFields.map((f) => f.name).join(', ')}`)
- setActiveTab('schedule')
- return
- }
- }
-
- setIsSubmitting(true)
- try {
- const userId = session?.user?.id?.toString() || '1'
-
- const extendedData = {
- ...data,
- hasPrDocument: hasPrDocuments,
- prNumber: representativePrNumber,
- specificationMeeting: data.hasSpecificationMeeting
- ? {
- ...specMeetingInfo,
- meetingFiles: specMeetingInfo.meetingFiles,
- }
- : null,
- prItems: prItems.length > 0 ? prItems : [],
- biddingConditions: biddingConditions,
- }
-
- const result = await createBidding(extendedData, userId)
-
- if (result.success) {
- toast.success(
- (result as { success: true; message: string }).message || '입찰이 성공적으로 생성되었습니다.'
- )
- setOpen(false)
- router.refresh()
- if (result.success && 'data' in result && result.data?.id) {
- setCreatedBiddingId(result.data.id)
- setShowSuccessDialog(true)
- }
- } else {
- const errorMessage =
- result.success === false && 'error' in result ? result.error : '입찰 생성에 실패했습니다.'
- toast.error(errorMessage)
- }
- } catch (error) {
- console.error('Error creating bidding:', error)
- toast.error('입찰 생성 중 오류가 발생했습니다.')
- } finally {
- setIsSubmitting(false)
- }
- }
-
- const resetAllStates = React.useCallback(() => {
- form.reset({
- revision: 0,
- projectId: 0,
- projectName: '',
- itemName: '',
- title: '',
- description: '',
- content: '',
- contractType: 'general',
- biddingType: 'equipment',
- biddingTypeCustom: '',
- awardCount: 'single',
- contractStartDate: '',
- contractEndDate: '',
- submissionStartDate: '',
- submissionEndDate: '',
- hasSpecificationMeeting: false,
- prNumber: '',
- currency: 'KRW',
- status: 'bidding_generated',
- isPublic: false,
- purchasingOrganization: '',
- managerName: '',
- managerEmail: '',
- managerPhone: '',
- remarks: '',
- })
-
- setSpecMeetingInfo({
- meetingDate: '',
- meetingTime: '',
- location: '',
- address: '',
- contactPerson: '',
- contactPhone: '',
- contactEmail: '',
- agenda: '',
- materials: '',
- notes: '',
- isRequired: false,
- meetingFiles: [],
- })
- setPrItems([
- {
- id: `pr-default`,
- prNumber: '',
- projectId: undefined,
- projectInfo: '',
- shi: '',
- quantity: '',
- quantityUnit: 'EA',
- totalWeight: '',
- weightUnit: 'KG',
- materialDescription: '',
- hasSpecDocument: false,
- requestedDeliveryDate: '',
- specFiles: [],
- isRepresentative: true,
- annualUnitPrice: '',
- currency: 'KRW',
- materialGroupNumber: '',
- materialGroupInfo: '',
- materialNumber: '',
- materialInfo: '',
- priceUnit: '',
- purchaseUnit: '',
- materialWeight: '',
- wbsCode: '',
- wbsName: '',
- costCenterCode: '',
- costCenterName: '',
- glAccountCode: '',
- glAccountName: '',
- targetUnitPrice: '',
- targetAmount: '',
- targetCurrency: 'KRW',
- budgetAmount: '',
- budgetCurrency: 'KRW',
- actualAmount: '',
- actualCurrency: 'KRW',
- },
- ])
- setSelectedItemForFile(null)
- setCostCenterDialogOpen(false)
- setGlAccountDialogOpen(false)
- setWbsCodeDialogOpen(false)
- setMaterialGroupDialogOpen(false)
- setMaterialDialogOpen(false)
- setBiddingConditions({
- paymentTerms: '',
- taxConditions: '',
- incoterms: '',
- incotermsOption: '',
- contractDeliveryDate: '',
- shippingPort: '',
- destinationPort: '',
- isPriceAdjustmentApplicable: false,
- sparePartOptions: '',
- })
- setActiveTab('basic')
- setShowSuccessDialog(false)
- setCreatedBiddingId(null)
- }, [form])
-
- function handleDialogOpenChange(nextOpen: boolean) {
- if (!nextOpen) {
- setShowCloseConfirmDialog(true)
- } else {
- setOpen(nextOpen)
- }
- }
-
- const handleCloseConfirm = (confirmed: boolean) => {
- setShowCloseConfirmDialog(false)
- if (confirmed) {
- resetAllStates()
- setOpen(false)
- }
- }
-
- const handleCreateBidding = () => {
- if (!isCurrentTabValid()) {
- toast.error('필수 정보를 모두 입력해주세요.')
- return
- }
-
- form.handleSubmit(onSubmit)()
- }
-
- const handleNavigateToDetail = () => {
- if (createdBiddingId) {
- router.push(`/evcp/bid/${createdBiddingId}`)
- }
- setShowSuccessDialog(false)
- setCreatedBiddingId(null)
- }
-
- const handleStayOnPage = () => {
- setShowSuccessDialog(false)
- setCreatedBiddingId(null)
- }
-
- // PR 아이템 테이블 렌더링
- const renderPrItemsTable = () => {
- return (
- <div className="border rounded-lg overflow-hidden">
- <div className="overflow-x-auto">
- <table className="w-full border-collapse">
- <thead className="bg-muted/50">
- <tr>
- <th className="sticky left-0 z-10 bg-muted/50 border-r px-2 py-3 text-left text-xs font-medium min-w-[50px]">
- <span className="sr-only">대표</span>
- </th>
- <th className="sticky left-[50px] z-10 bg-muted/50 border-r px-3 py-3 text-left text-xs font-medium min-w-[40px]">
- #
- </th>
- <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">프로젝트코드</th>
- <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">프로젝트명</th>
- <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">자재그룹코드 <span className="text-red-500">*</span></th>
- <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">자재그룹명 <span className="text-red-500">*</span></th>
- <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">자재코드</th>
- <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">자재명</th>
- <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">수량</th>
- <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">단위</th>
- <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">가격단위</th>
- <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">구매단위</th>
- <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">내정단가</th>
- <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">내정금액</th>
- <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">내정통화</th>
- <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">예산금액</th>
- <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">예산통화</th>
- <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">실적금액</th>
- <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">실적통화</th>
- <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">WBS코드</th>
- <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">WBS명</th>
- <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">코스트센터코드</th>
- <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">코스트센터명</th>
- <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">GL계정코드</th>
- <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">GL계정명</th>
- <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">납품요청일</th>
- <th className="sticky right-0 z-10 bg-muted/50 border-l px-3 py-3 text-center text-xs font-medium min-w-[100px]">
- 액션
- </th>
- </tr>
- </thead>
- <tbody>
- {prItems.map((item, index) => (
- <tr key={item.id} className="border-t hover:bg-muted/30">
- <td className="sticky left-0 z-10 bg-background border-r px-2 py-2 text-center">
- <Checkbox
- checked={item.isRepresentative}
- onCheckedChange={() => setRepresentativeItem(item.id)}
- disabled={prItems.length <= 1 && item.isRepresentative}
- title="대표 아이템"
- />
- </td>
- <td className="sticky left-[50px] z-10 bg-background border-r px-3 py-2 text-xs text-muted-foreground">
- {index + 1}
- </td>
- <td className="border-r px-3 py-2">
- <ProjectSelector
- selectedProjectId={item.projectId || null}
- onProjectSelect={(project) => {
- updatePRItem(item.id, {
- projectId: project.id,
- projectInfo: project.projectName
- })
- }}
- placeholder="프로젝트 선택"
- />
- </td>
- <td className="border-r px-3 py-2">
- <Input
- placeholder="프로젝트명"
- value={item.projectInfo || ''}
- readOnly
- className="h-8 text-xs bg-muted/50"
- />
- </td>
- <td className="border-r px-3 py-2">
- <MaterialGroupSelectorDialogSingle
- triggerLabel={item.materialGroupNumber || "자재그룹 선택"}
- triggerVariant="outline"
- selectedMaterial={item.materialGroupNumber ? {
- materialGroupCode: item.materialGroupNumber,
- materialGroupDescription: item.materialGroupInfo,
- displayText: `${item.materialGroupNumber} - ${item.materialGroupInfo}`
- } : null}
- onMaterialSelect={(material) => {
- if (material) {
- updatePRItem(item.id, {
- materialGroupNumber: material.materialGroupCode,
- materialGroupInfo: material.materialGroupDescription
- })
- } else {
- updatePRItem(item.id, {
- materialGroupNumber: '',
- materialGroupInfo: ''
- })
- }
- }}
- title="자재그룹 선택"
- description="자재그룹을 검색하고 선택해주세요."
- />
- </td>
- <td className="border-r px-3 py-2">
- <Input
- placeholder="자재그룹명"
- value={item.materialGroupInfo}
- readOnly
- className="h-8 text-xs bg-muted/50"
- />
- </td>
- <td className="border-r px-3 py-2">
- <MaterialSelectorDialogSingle
- triggerLabel={item.materialNumber || "자재 선택"}
- triggerVariant="outline"
- selectedMaterial={item.materialNumber ? {
- materialCode: item.materialNumber,
- materialName: item.materialInfo,
- displayText: `${item.materialNumber} - ${item.materialInfo}`
- } : null}
- onMaterialSelect={(material) => {
- if (material) {
- updatePRItem(item.id, {
- materialNumber: material.materialCode,
- materialInfo: material.materialName
- })
- } else {
- updatePRItem(item.id, {
- materialNumber: '',
- materialInfo: ''
- })
- }
- }}
- title="자재 선택"
- description="자재를 검색하고 선택해주세요."
- />
- </td>
- <td className="border-r px-3 py-2">
- <Input
- placeholder="자재명"
- value={item.materialInfo}
- readOnly
- className="h-8 text-xs bg-muted/50"
- />
- </td>
- <td className="border-r px-3 py-2">
- {quantityWeightMode === 'quantity' ? (
- <Input
- type="number"
- min="0"
- placeholder="수량"
- value={item.quantity}
- onChange={(e) => updatePRItem(item.id, { quantity: e.target.value })}
- className="h-8 text-xs"
- />
- ) : (
- <Input
- type="number"
- min="0"
- placeholder="중량"
- value={item.totalWeight}
- onChange={(e) => updatePRItem(item.id, { totalWeight: e.target.value })}
- className="h-8 text-xs"
- />
- )}
- </td>
- <td className="border-r px-3 py-2">
- {quantityWeightMode === 'quantity' ? (
- <Select
- value={item.quantityUnit}
- onValueChange={(value) => updatePRItem(item.id, { quantityUnit: value })}
- >
- <SelectTrigger className="h-8 text-xs">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="EA">EA</SelectItem>
- <SelectItem value="SET">SET</SelectItem>
- <SelectItem value="LOT">LOT</SelectItem>
- <SelectItem value="M">M</SelectItem>
- <SelectItem value="M2">M²</SelectItem>
- <SelectItem value="M3">M³</SelectItem>
- </SelectContent>
- </Select>
- ) : (
- <Select
- value={item.weightUnit}
- onValueChange={(value) => updatePRItem(item.id, { weightUnit: value })}
- >
- <SelectTrigger className="h-8 text-xs">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="KG">KG</SelectItem>
- <SelectItem value="TON">TON</SelectItem>
- <SelectItem value="G">G</SelectItem>
- <SelectItem value="LB">LB</SelectItem>
- </SelectContent>
- </Select>
- )}
- </td>
- <td className="border-r px-3 py-2">
- <Input
- type="number"
- min="1"
- step="1"
- placeholder="가격단위"
- value={item.priceUnit || ''}
- onChange={(e) => updatePRItem(item.id, { priceUnit: e.target.value })}
- className="h-8 text-xs"
- />
- </td>
- <td className="border-r px-3 py-2">
- {quantityWeightMode === 'quantity' ? (
- <Select
- value={item.purchaseUnit || item.quantityUnit || 'EA'}
- onValueChange={(value) => updatePRItem(item.id, { purchaseUnit: value })}
- >
- <SelectTrigger className="h-8 text-xs">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="EA">EA</SelectItem>
- <SelectItem value="SET">SET</SelectItem>
- <SelectItem value="LOT">LOT</SelectItem>
- <SelectItem value="M">M</SelectItem>
- <SelectItem value="M2">M²</SelectItem>
- <SelectItem value="M3">M³</SelectItem>
- </SelectContent>
- </Select>
- ) : (
- <Select
- value={item.purchaseUnit || item.weightUnit || 'KG'}
- onValueChange={(value) => updatePRItem(item.id, { purchaseUnit: value })}
- >
- <SelectTrigger className="h-8 text-xs">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="KG">KG</SelectItem>
- <SelectItem value="TON">TON</SelectItem>
- <SelectItem value="G">G</SelectItem>
- <SelectItem value="LB">LB</SelectItem>
- </SelectContent>
- </Select>
- )}
- </td>
- <td className="border-r px-3 py-2">
- <Input
- type="number"
- min="0"
- step="1"
- placeholder="내정단가"
- value={item.targetUnitPrice || ''}
- onChange={(e) => updatePRItem(item.id, { targetUnitPrice: e.target.value })}
- className="h-8 text-xs"
- />
- </td>
- <td className="border-r px-3 py-2">
- <Input
- type="number"
- min="0"
- step="1"
- placeholder="내정금액"
- readOnly
- value={item.targetAmount || ''}
- className="h-8 text-xs bg-muted/50"
- />
- </td>
- <td className="border-r px-3 py-2">
- <Select
- value={item.targetCurrency}
- onValueChange={(value) => updatePRItem(item.id, { targetCurrency: value })}
- >
- <SelectTrigger className="h-8 text-xs">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="KRW">KRW</SelectItem>
- <SelectItem value="USD">USD</SelectItem>
- <SelectItem value="EUR">EUR</SelectItem>
- <SelectItem value="JPY">JPY</SelectItem>
- </SelectContent>
- </Select>
- </td>
- <td className="border-r px-3 py-2">
- <Input
- type="number"
- min="0"
- step="1"
- placeholder="예산금액"
- value={item.budgetAmount || ''}
- onChange={(e) => updatePRItem(item.id, { budgetAmount: e.target.value })}
- className="h-8 text-xs"
- />
- </td>
- <td className="border-r px-3 py-2">
- <Select
- value={item.budgetCurrency}
- onValueChange={(value) => updatePRItem(item.id, { budgetCurrency: value })}
- >
- <SelectTrigger className="h-8 text-xs">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="KRW">KRW</SelectItem>
- <SelectItem value="USD">USD</SelectItem>
- <SelectItem value="EUR">EUR</SelectItem>
- <SelectItem value="JPY">JPY</SelectItem>
- </SelectContent>
- </Select>
- </td>
- <td className="border-r px-3 py-2">
- <Input
- type="number"
- min="0"
- step="1"
- placeholder="실적금액"
- value={item.actualAmount || ''}
- onChange={(e) => updatePRItem(item.id, { actualAmount: e.target.value })}
- className="h-8 text-xs"
- />
- </td>
- <td className="border-r px-3 py-2">
- <Select
- value={item.actualCurrency}
- onValueChange={(value) => updatePRItem(item.id, { actualCurrency: value })}
- >
- <SelectTrigger className="h-8 text-xs">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="KRW">KRW</SelectItem>
- <SelectItem value="USD">USD</SelectItem>
- <SelectItem value="EUR">EUR</SelectItem>
- <SelectItem value="JPY">JPY</SelectItem>
- </SelectContent>
- </Select>
- </td>
- <td className="border-r px-3 py-2">
- <Button
- variant="outline"
- onClick={() => setWbsCodeDialogOpen(true)}
- className="w-full justify-start h-8 text-xs"
- >
- {item.wbsCode ? (
- <span className="truncate">
- {`${item.wbsCode}${item.wbsName ? ` - ${item.wbsName}` : ''}`}
- </span>
- ) : (
- <span className="text-muted-foreground">WBS 코드 선택</span>
- )}
- </Button>
- <WbsCodeSingleSelector
- open={wbsCodeDialogOpen}
- onOpenChange={setWbsCodeDialogOpen}
- selectedCode={item.wbsCode ? {
- PROJ_NO: '',
- WBS_ELMT: item.wbsCode,
- WBS_ELMT_NM: item.wbsName || '',
- WBS_LVL: ''
- } : undefined}
- onCodeSelect={(wbsCode) => {
- updatePRItem(item.id, {
- wbsCode: wbsCode.WBS_ELMT,
- wbsName: wbsCode.WBS_ELMT_NM
- })
- setWbsCodeDialogOpen(false)
- }}
- title="WBS 코드 선택"
- description="WBS 코드를 선택하세요"
- showConfirmButtons={false}
- />
- </td>
- <td className="border-r px-3 py-2">
- <Input
- placeholder="WBS명"
- value={item.wbsName || ''}
- readOnly
- className="h-8 text-xs bg-muted/50"
- />
- </td>
- <td className="border-r px-3 py-2">
- <Button
- variant="outline"
- onClick={() => setCostCenterDialogOpen(true)}
- className="w-full justify-start h-8 text-xs"
- >
- {item.costCenterCode ? (
- <span className="truncate">
- {`${item.costCenterCode}${item.costCenterName ? ` - ${item.costCenterName}` : ''}`}
- </span>
- ) : (
- <span className="text-muted-foreground">코스트센터 선택</span>
- )}
- </Button>
- <CostCenterSingleSelector
- open={costCenterDialogOpen}
- onOpenChange={setCostCenterDialogOpen}
- selectedCode={item.costCenterCode ? {
- KOSTL: item.costCenterCode,
- KTEXT: '',
- LTEXT: item.costCenterName || '',
- DATAB: '',
- DATBI: ''
- } : undefined}
- onCodeSelect={(costCenter) => {
- updatePRItem(item.id, {
- costCenterCode: costCenter.KOSTL,
- costCenterName: costCenter.LTEXT
- })
- setCostCenterDialogOpen(false)
- }}
- title="코스트센터 선택"
- description="코스트센터를 선택하세요"
- showConfirmButtons={false}
- />
- </td>
- <td className="border-r px-3 py-2">
- <Input
- placeholder="코스트센터명"
- value={item.costCenterName || ''}
- readOnly
- className="h-8 text-xs bg-muted/50"
- />
- </td>
- <td className="border-r px-3 py-2">
- <Button
- variant="outline"
- onClick={() => setGlAccountDialogOpen(true)}
- className="w-full justify-start h-8 text-xs"
- >
- {item.glAccountCode ? (
- <span className="truncate">
- {`${item.glAccountCode}${item.glAccountName ? ` - ${item.glAccountName}` : ''}`}
- </span>
- ) : (
- <span className="text-muted-foreground">GL계정 선택</span>
- )}
- </Button>
- <GlAccountSingleSelector
- open={glAccountDialogOpen}
- onOpenChange={setGlAccountDialogOpen}
- selectedCode={item.glAccountCode ? {
- SAKNR: item.glAccountCode,
- FIPEX: '',
- TEXT1: item.glAccountName || ''
- } : undefined}
- onCodeSelect={(glAccount) => {
- updatePRItem(item.id, {
- glAccountCode: glAccount.SAKNR,
- glAccountName: glAccount.TEXT1
- })
- setGlAccountDialogOpen(false)
- }}
- title="GL 계정 선택"
- description="GL 계정을 선택하세요"
- showConfirmButtons={false}
- />
- </td>
- <td className="border-r px-3 py-2">
- <Input
- placeholder="GL계정명"
- value={item.glAccountName || ''}
- readOnly
- className="h-8 text-xs bg-muted/50"
- />
- </td>
- <td className="border-r px-3 py-2">
- <Input
- type="date"
- value={item.requestedDeliveryDate}
- onChange={(e) => updatePRItem(item.id, { requestedDeliveryDate: e.target.value })}
- className="h-8 text-xs"
- />
- </td>
- <td className="sticky right-0 z-10 bg-background border-l px-3 py-2">
- <div className="flex items-center justify-center gap-1">
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => {
- const fileInput = document.createElement('input')
- fileInput.type = 'file'
- fileInput.multiple = true
- fileInput.accept = '.pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg'
- fileInput.onchange = (e) => {
- const files = Array.from((e.target as HTMLInputElement).files || [])
- if (files.length > 0) {
- addSpecFiles(item.id, files)
- }
- }
- fileInput.click()
- }}
- className="h-7 w-7 p-0"
- title="파일 첨부"
- >
- <Paperclip className="h-3.5 w-3.5" />
- {item.specFiles.length > 0 && (
- <span className="absolute -top-1 -right-1 bg-primary text-primary-foreground rounded-full w-4 h-4 text-[10px] flex items-center justify-center">
- {item.specFiles.length}
- </span>
- )}
- </Button>
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => removePRItem(item.id)}
- disabled={prItems.length <= 1}
- className="h-7 w-7 p-0"
- title="품목 삭제"
- >
- <Trash2 className="h-3.5 w-3.5" />
- </Button>
- </div>
- </td>
- </tr>
- ))}
- </tbody>
- </table>
- </div>
-
- {/* 첨부된 파일 목록 표시 */}
- {prItems.some(item => item.specFiles.length > 0) && (
- <div className="border-t p-4 bg-muted/20">
- <Label className="text-sm font-medium mb-2 block">첨부된 스펙 파일</Label>
- <div className="space-y-3">
- {prItems.map((item, index) => (
- item.specFiles.length > 0 && (
- <div key={item.id} className="space-y-1">
- <div className="text-xs font-medium text-muted-foreground">
- {item.materialGroupInfo || item.materialGroupNumber || `ITEM-${index + 1}`}
- </div>
- <div className="flex flex-wrap gap-2">
- {item.specFiles.map((file, fileIndex) => (
- <div
- key={fileIndex}
- className="inline-flex items-center gap-1 px-2 py-1 bg-background border rounded text-xs"
- >
- <Paperclip className="h-3 w-3" />
- <span className="max-w-[200px] truncate">{file.name}</span>
- <span className="text-muted-foreground">
- ({(file.size / 1024 / 1024).toFixed(2)} MB)
- </span>
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => removeSpecFile(item.id, fileIndex)}
- className="h-4 w-4 p-0 ml-1 hover:bg-destructive/20"
- >
- <X className="h-3 w-3" />
- </Button>
- </div>
- ))}
- </div>
- </div>
- )
- ))}
- </div>
- </div>
- )}
- </div>
- )
- }
-
-
- return (
- <>
- <Dialog open={open} onOpenChange={handleDialogOpenChange}>
- <DialogTrigger asChild>
- <Button variant="default" size="sm">
- <Plus className="mr-2 h-4 w-4" />
- 신규 입찰
- </Button>
- </DialogTrigger>
- <DialogContent className="h-[90vh] p-0 flex flex-col" style={{ maxWidth: '1400px' }}>
- {/* 고정 헤더 */}
- <div className="flex-shrink-0 p-6 border-b">
- <DialogHeader>
- <DialogTitle>신규 입찰 생성</DialogTitle>
- <DialogDescription>
- 새로운 입찰을 생성합니다. 단계별로 정보를 입력해주세요.
- </DialogDescription>
- </DialogHeader>
- </div>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0" id="create-bidding-form">
- {/* 탭 영역 */}
- <div className="flex-1 overflow-hidden">
- <Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as TabType)} className="h-full flex flex-col">
- {/* 탭 리스트 */}
- <div className="px-6">
- <div className="flex space-x-1 bg-muted p-1 rounded-lg overflow-x-auto">
- {TAB_ORDER.map((tab) => (
- <button
- key={tab}
- type="button"
- onClick={() => setActiveTab(tab)}
- className={cn(
- 'relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0',
- activeTab === tab ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
- )}
- >
- {tab === 'basic' && '기본 정보'}
- {tab === 'schedule' && '입찰 계획'}
- {tab === 'details' && '세부 내역'}
- {tab === 'manager' && '담당자'}
- {!tabValidation[tab].isValid && (
- <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
- )}
- </button>
- ))}
- </div>
- </div>
-
- {/* 탭 콘텐츠 */}
- <div className="flex-1 overflow-y-auto p-6">
- <TabsContent value="basic" className="mt-0 space-y-6">
- <Card>
- <CardHeader>
- <CardTitle>기본 정보 및 계약 정보</CardTitle>
- </CardHeader>
- <CardContent className="space-y-6">
- <FormField
- control={form.control}
- name="title"
- render={({ field }) => (
- <FormItem>
- <FormLabel>입찰명 <span className="text-red-500">*</span></FormLabel>
- <FormControl>
- <Input placeholder="입찰명을 입력하세요" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name="description"
- render={({ field }) => (
- <FormItem>
- <FormLabel>입찰개요</FormLabel>
- <FormControl>
- <Textarea placeholder="입찰에 대한 설명을 입력하세요" rows={4} {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <div className="grid grid-cols-2 gap-6">
- <FormField
- control={form.control}
- name="contractType"
- render={({ field }) => (
- <FormItem>
- <FormLabel>계약구분 <span className="text-red-500">*</span></FormLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="계약구분 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {Object.entries(contractTypeLabels).map(([value, label]) => (
- <SelectItem key={value} value={value}>
- {label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name="biddingType"
- render={({ field }) => (
- <FormItem>
- <FormLabel>입찰유형 <span className="text-red-500">*</span></FormLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="입찰유형 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {Object.entries(biddingTypeLabels).map(([value, label]) => (
- <SelectItem key={value} value={value}>
- {label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
- {form.watch('biddingType') === 'other' && (
- <FormField
- control={form.control}
- name="biddingTypeCustom"
- render={({ field }) => (
- <FormItem>
- <FormLabel>기타 입찰유형 <span className="text-red-500">*</span></FormLabel>
- <FormControl>
- <Input placeholder="직접 입력하세요" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- )}
- </div>
- <div className="grid grid-cols-2 gap-6">
- <FormField
- control={form.control}
- name="awardCount"
- render={({ field }) => (
- <FormItem>
- <FormLabel>낙찰업체 수 <span className="text-red-500">*</span></FormLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="낙찰업체 수 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {Object.entries(awardCountLabels).map(([value, label]) => (
- <SelectItem key={value} value={value}>
- {label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name="currency"
- render={({ field }) => (
- <FormItem>
- <FormLabel>통화 <span className="text-red-500">*</span></FormLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="통화 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="KRW">KRW (원)</SelectItem>
- <SelectItem value="USD">USD (달러)</SelectItem>
- <SelectItem value="EUR">EUR (유로)</SelectItem>
- <SelectItem value="JPY">JPY (엔)</SelectItem>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- <div className="grid grid-cols-2 gap-6">
- <FormField
- control={form.control}
- name="contractStartDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel>계약시작일</FormLabel>
- <FormControl>
- <Input type="date" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name="contractEndDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel>계약종료일</FormLabel>
- <FormControl>
- <Input type="date" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- <FormField
- control={form.control}
- name="purchasingOrganization"
- render={({ field }) => (
- <FormItem>
- <FormLabel>구매조직</FormLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="구매조직 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="조선">조선</SelectItem>
- <SelectItem value="해양">해양</SelectItem>
- <SelectItem value="기타">기타</SelectItem>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
- </CardContent>
- </Card>
- </TabsContent>
-
- <TabsContent value="schedule" className="mt-0 space-y-6">
- <Card>
- <CardHeader>
- <CardTitle>일정 정보</CardTitle>
- </CardHeader>
- <CardContent className="space-y-6">
- <div className="grid grid-cols-2 gap-6">
- <FormField
- control={form.control}
- name="submissionStartDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel>제출시작일시 <span className="text-red-500">*</span></FormLabel>
- <FormControl>
- <Input type="datetime-local" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name="submissionEndDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel>제출마감일시 <span className="text-red-500">*</span></FormLabel>
- <FormControl>
- <Input type="datetime-local" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- <FormField
- control={form.control}
- name="hasSpecificationMeeting"
- render={({ field }) => (
- <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
- <div className="space-y-0.5">
- <FormLabel className="text-base">사양설명회 실시</FormLabel>
- <FormDescription>
- 사양설명회를 실시할 경우 상세 정보를 입력하세요
- </FormDescription>
- </div>
- <FormControl>
- <Switch checked={field.value} onCheckedChange={field.onChange} />
- </FormControl>
- </FormItem>
- )}
- />
- {form.watch('hasSpecificationMeeting') && (
- <div className="space-y-6 p-4 border rounded-lg bg-muted/50">
- <div className="grid grid-cols-2 gap-4">
- <div>
- <Label>회의일시 <span className="text-red-500">*</span></Label>
- <Input
- type="datetime-local"
- value={specMeetingInfo.meetingDate}
- onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, meetingDate: e.target.value }))}
- className={!specMeetingInfo.meetingDate ? 'border-red-200' : ''}
- />
- {!specMeetingInfo.meetingDate && (
- <p className="text-sm text-red-500 mt-1">회의일시는 필수입니다</p>
- )}
- </div>
- <div>
- <Label>회의시간</Label>
- <Input
- placeholder="예: 14:00 ~ 16:00"
- value={specMeetingInfo.meetingTime}
- onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, meetingTime: e.target.value }))}
- />
- </div>
- </div>
- <div>
- <Label>장소 <span className="text-red-500">*</span></Label>
- <Input
- placeholder="회의 장소"
- value={specMeetingInfo.location}
- onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, location: e.target.value }))}
- className={!specMeetingInfo.location ? 'border-red-200' : ''}
- />
- {!specMeetingInfo.location && (
- <p className="text-sm text-red-500 mt-1">회의 장소는 필수입니다</p>
- )}
- </div>
- <div className="grid grid-cols-3 gap-4">
- <div>
- <Label>담당자 <span className="text-red-500">*</span></Label>
- <Input
- placeholder="담당자명"
- value={specMeetingInfo.contactPerson}
- onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactPerson: e.target.value }))}
- className={!specMeetingInfo.contactPerson ? 'border-red-200' : ''}
- />
- {!specMeetingInfo.contactPerson && (
- <p className="text-sm text-red-500 mt-1">담당자는 필수입니다</p>
- )}
- </div>
- <div>
- <Label>연락처</Label>
- <Input
- placeholder="전화번호"
- value={specMeetingInfo.contactPhone}
- onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactPhone: e.target.value }))}
- />
- </div>
- <div>
- <Label>이메일</Label>
- <Input
- type="email"
- placeholder="이메일"
- value={specMeetingInfo.contactEmail}
- onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactEmail: e.target.value }))}
- />
- </div>
- </div>
- </div>
- )}
- </CardContent>
- </Card>
-
- {/* 입찰 조건 섹션 */}
- <Card>
- <CardHeader>
- <CardTitle>입찰 조건</CardTitle>
- <p className="text-sm text-muted-foreground">
- 벤더가 사전견적 시 참고할 입찰 조건을 설정하세요
- </p>
- </CardHeader>
- <CardContent className="space-y-6">
- <div className="grid grid-cols-2 gap-6">
- <div className="space-y-2">
- <Label>
- 지급조건 <span className="text-red-500">*</span>
- </Label>
- <Select
- value={biddingConditions.paymentTerms}
- onValueChange={(value) => setBiddingConditions(prev => ({
- ...prev,
- paymentTerms: value
- }))}
- >
- <SelectTrigger>
- <SelectValue placeholder="지급조건 선택" />
- </SelectTrigger>
- <SelectContent>
- {paymentTermsOptions.length > 0 ? (
- paymentTermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code}>
- {option.code} {option.description && `(${option.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <Label>
- 세금조건 <span className="text-red-500">*</span>
- </Label>
- <Select
- value={biddingConditions.taxConditions}
- onValueChange={(value) => setBiddingConditions(prev => ({
- ...prev,
- taxConditions: value
- }))}
- >
- <SelectTrigger>
- <SelectValue placeholder="세금조건 선택" />
- </SelectTrigger>
- <SelectContent>
- {TAX_CONDITIONS.map((condition) => (
- <SelectItem key={condition.code} value={condition.code}>
- {condition.name}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <Label>
- 운송조건(인코텀즈) <span className="text-red-500">*</span>
- </Label>
- <Select
- value={biddingConditions.incoterms}
- onValueChange={(value) => setBiddingConditions(prev => ({
- ...prev,
- incoterms: value
- }))}
- >
- <SelectTrigger>
- <SelectValue placeholder="인코텀즈 선택" />
- </SelectTrigger>
- <SelectContent>
- {incotermsOptions.length > 0 ? (
- incotermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code}>
- {option.code} {option.description && `(${option.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <Label>인코텀즈 옵션 (선택사항)</Label>
- <Input
- placeholder="예: 현지 배송 포함, 특정 주소 배송 등"
- value={biddingConditions.incotermsOption}
- onChange={(e) => setBiddingConditions(prev => ({
- ...prev,
- incotermsOption: e.target.value
- }))}
- />
- <p className="text-xs text-muted-foreground">
- 인코텀즈와 관련된 추가 조건이나 특이사항을 입력하세요
- </p>
- </div>
-
- <div className="space-y-2">
- <Label>
- 계약 납품일 <span className="text-red-500">*</span>
- </Label>
- <Input
- type="date"
- value={biddingConditions.contractDeliveryDate}
- onChange={(e) => setBiddingConditions(prev => ({
- ...prev,
- contractDeliveryDate: e.target.value
- }))}
- />
- </div>
-
- <div className="space-y-2">
- <Label>선적지 (선택사항)</Label>
- <Select
- value={biddingConditions.shippingPort}
- onValueChange={(value) => setBiddingConditions(prev => ({
- ...prev,
- shippingPort: value
- }))}
- >
- <SelectTrigger>
- <SelectValue placeholder="선적지 선택" />
- </SelectTrigger>
- <SelectContent>
- {shippingPlaces.length > 0 ? (
- shippingPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code}>
- {place.code} {place.description && `(${place.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <Label>하역지 (선택사항)</Label>
- <Select
- value={biddingConditions.destinationPort}
- onValueChange={(value) => setBiddingConditions(prev => ({
- ...prev,
- destinationPort: value
- }))}
- >
- <SelectTrigger>
- <SelectValue placeholder="하역지 선택" />
- </SelectTrigger>
- <SelectContent>
- {destinationPlaces.length > 0 ? (
- destinationPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code}>
- {place.code} {place.description && `(${place.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
- </div>
-
- <div className="flex items-center space-x-2">
- <Switch
- id="price-adjustment"
- checked={biddingConditions.isPriceAdjustmentApplicable}
- onCheckedChange={(checked) => setBiddingConditions(prev => ({
- ...prev,
- isPriceAdjustmentApplicable: checked
- }))}
- />
- <Label htmlFor="price-adjustment">
- 연동제 적용 요건 문의
- </Label>
- </div>
-
- <div className="space-y-2">
- <Label>스페어파트 옵션</Label>
- <Textarea
- placeholder="스페어파트 관련 옵션을 입력하세요"
- value={biddingConditions.sparePartOptions}
- onChange={(e) => setBiddingConditions(prev => ({
- ...prev,
- sparePartOptions: e.target.value
- }))}
- rows={3}
- />
- </div>
- </CardContent>
- </Card>
- </TabsContent>
-
- <TabsContent value="details" className="mt-0 space-y-6">
- <Card>
- <CardHeader className="flex flex-row items-center justify-between">
- <div>
- <CardTitle>세부내역 관리</CardTitle>
- <p className="text-sm text-muted-foreground mt-1">
- 최소 하나의 아이템이 필요하며, 자재그룹코드는 필수입니다
- </p>
- <p className="text-xs text-amber-600 mt-1">
- 수량/단위 또는 중량/중량단위를 선택해서 입력하세요
- </p>
- </div>
- <Button
- type="button"
- variant="outline"
- onClick={addPRItem}
- className="flex items-center gap-2"
- >
- <Plus className="h-4 w-4" />
- 아이템 추가
- </Button>
- </CardHeader>
- <CardContent className="space-y-6">
- <div className="flex items-center space-x-4 p-4 bg-muted rounded-lg">
- <div className="text-sm font-medium">계산 기준:</div>
- <div className="flex items-center space-x-2">
- <input
- type="radio"
- id="quantity-mode"
- name="quantityWeightMode"
- checked={quantityWeightMode === 'quantity'}
- onChange={() => handleQuantityWeightModeChange('quantity')}
- className="h-4 w-4"
- />
- <label htmlFor="quantity-mode" className="text-sm">수량 기준</label>
- </div>
- <div className="flex items-center space-x-2">
- <input
- type="radio"
- id="weight-mode"
- name="quantityWeightMode"
- checked={quantityWeightMode === 'weight'}
- onChange={() => handleQuantityWeightModeChange('weight')}
- className="h-4 w-4"
- />
- <label htmlFor="weight-mode" className="text-sm">중량 기준</label>
- </div>
- </div>
- <div className="space-y-4">
- {prItems.length > 0 ? (
- renderPrItemsTable()
- ) : (
- <div className="text-center py-12 border-2 border-dashed border-gray-300 rounded-lg">
- <FileText className="h-12 w-12 text-gray-400 mx-auto mb-4" />
- <p className="text-gray-500 mb-2">아직 아이템이 없습니다</p>
- <p className="text-sm text-gray-400 mb-4">
- PR 아이템이나 수기 아이템을 추가하여 입찰 세부내역을 작성하세요
- </p>
- <Button
- type="button"
- variant="outline"
- onClick={addPRItem}
- className="flex items-center gap-2 mx-auto"
- >
- <Plus className="h-4 w-4" />
- 첫 번째 아이템 추가
- </Button>
- </div>
- )}
- </div>
- </CardContent>
- </Card>
- </TabsContent>
-
- <TabsContent value="manager" className="mt-0 space-y-6">
- <Card>
- <CardHeader>
- <CardTitle>담당자 정보</CardTitle>
- </CardHeader>
- <CardContent className="space-y-6">
- <div className="grid grid-cols-2 gap-6">
- <div className="space-y-2">
- <Label>입찰담당자 <span className="text-red-500">*</span></Label>
- <PurchaseGroupCodeSelector
- onCodeSelect={(code) => {
- form.setValue('managerName', code.DISPLAY_NAME || '')
- }}
- placeholder="입찰담당자 선택"
- />
- </div>
- <div className="space-y-2">
- <Label>조달담당자 <span className="text-red-500">*</span></Label>
- <ProcurementManagerSelector
- onManagerSelect={(manager) => {
- form.setValue('managerEmail', manager.DISPLAY_NAME || '')
- }}
- placeholder="조달담당자 선택"
- />
- </div>
- </div>
- </CardContent>
- </Card>
- <Card>
- <CardHeader>
- <CardTitle>기타 설정</CardTitle>
- </CardHeader>
- <CardContent className="space-y-6">
- <FormField
- control={form.control}
- name="isPublic"
- render={({ field }) => (
- <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
- <div className="space-y-0.5">
- <FormLabel className="text-base">공개 입찰</FormLabel>
- <FormDescription>
- 공개 입찰 여부를 설정합니다
- </FormDescription>
- </div>
- <FormControl>
- <Switch checked={field.value} onCheckedChange={field.onChange} />
- </FormControl>
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name="remarks"
- render={({ field }) => (
- <FormItem>
- <FormLabel>비고</FormLabel>
- <FormControl>
- <Textarea placeholder="추가 메모나 특이사항을 입력하세요" rows={4} {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </CardContent>
- </Card>
- </TabsContent>
- </div>
- </Tabs>
- </div>
-
- {/* 고정 버튼 영역 */}
- <div className="flex-shrink-0 border-t bg-background p-6">
- <div className="flex justify-between items-center">
- <div className="text-sm text-muted-foreground">
- {activeTab === 'basic' && (<span>기본 정보를 입력하세요.</span>)}
- {activeTab === 'schedule' && (<span>일정 및 사양설명회 정보를 입력하세요.</span>)}
- {activeTab === 'details' && (<span>세부내역을 관리하세요.</span>)}
- {activeTab === 'manager' && (<span>담당자 정보를 확인하고 입찰을 생성하세요.</span>)}
- {!tabValidation[activeTab].isValid && (
- <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span>
- )}
- </div>
- <div className="flex gap-3">
- <Button
- type="button"
- variant="outline"
- onClick={() => setShowCloseConfirmDialog(true)}
- disabled={isSubmitting}
- >
- 취소
- </Button>
- {!isFirstTab && (
- <Button
- type="button"
- variant="outline"
- onClick={goToPreviousTab}
- disabled={isSubmitting}
- className="flex items-center gap-2"
- >
- <ChevronLeft className="h-4 w-4" />
- 이전
- </Button>
- )}
- {isLastTab ? (
- <Button
- type="button"
- onClick={handleCreateBidding}
- disabled={isSubmitting || !isCurrentTabValid()}
- className="flex items-center gap-2"
- >
- {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- 입찰 생성
- </Button>
- ) : (
- <Button
- type="button"
- onClick={handleNextClick}
- disabled={isSubmitting || !isCurrentTabValid()}
- className="flex items-center gap-2"
- >
- 다음
- <ChevronRight className="h-4 w-4" />
- </Button>
- )}
- </div>
- </div>
- </div>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- {/* 닫기 확인 다이얼로그 */}
- <AlertDialog open={showCloseConfirmDialog} onOpenChange={setShowCloseConfirmDialog}>
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>입찰 생성을 취소하시겠습니까?</AlertDialogTitle>
- <AlertDialogDescription>
- 현재 입력 중인 내용이 모두 삭제되며, 생성되지 않습니다. 정말로 취소하시겠습니까?
- </AlertDialogDescription>
- </AlertDialogHeader>
- <AlertDialogFooter>
- <AlertDialogCancel onClick={() => handleCloseConfirm(false)}>
- 아니오 (계속 입력)
- </AlertDialogCancel>
- <AlertDialogAction onClick={() => handleCloseConfirm(true)}>
- 예 (취소)
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
- {/* 성공 다이얼로그 */}
- <AlertDialog open={showSuccessDialog} onOpenChange={setShowSuccessDialog}>
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>입찰이 성공적으로 생성되었습니다</AlertDialogTitle>
- <AlertDialogDescription>
- 생성된 입찰의 상세페이지로 이동하시겠습니까? 아니면 현재 페이지에 남아있으시겠습니까?
- </AlertDialogDescription>
- </AlertDialogHeader>
- <AlertDialogFooter>
- <AlertDialogCancel onClick={handleStayOnPage}>현재 페이지에 남기</AlertDialogCancel>
- <AlertDialogAction onClick={handleNavigateToDetail}>상세페이지로 이동</AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
- </>
- )
-} \ No newline at end of file
diff --git a/lib/bidding/receive/biddings-receive-table.tsx b/lib/bidding/receive/biddings-receive-table.tsx
index 1c5dd16b..0995c6a2 100644
--- a/lib/bidding/receive/biddings-receive-table.tsx
+++ b/lib/bidding/receive/biddings-receive-table.tsx
@@ -270,7 +270,7 @@ export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) {
onCompactChange={handleCompactChange}
>
<div className="flex items-center gap-2">
- <Button
+ {/* <Button
variant="outline"
size="sm"
onClick={handleEarlyOpenBidding}
@@ -278,7 +278,7 @@ export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) {
>
{isEarlyOpeningBidding && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
조기개찰
- </Button>
+ </Button> */}
<Button
variant="outline"
size="sm"
diff --git a/lib/bidding/vendor/components/pr-items-pricing-table.tsx b/lib/bidding/vendor/components/pr-items-pricing-table.tsx
index 22051a13..a0230478 100644
--- a/lib/bidding/vendor/components/pr-items-pricing-table.tsx
+++ b/lib/bidding/vendor/components/pr-items-pricing-table.tsx
@@ -279,7 +279,7 @@ export function PrItemsPricingTable({
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Package className="w-5 h-5" />
- 품목별 견적 작성
+ 품목별 입찰 작성
</CardTitle>
</CardHeader>
<CardContent>
@@ -299,8 +299,8 @@ export function PrItemsPricingTable({
<TableHead>중량단위</TableHead>
<TableHead>구매단위</TableHead>
<TableHead>SHI 납품요청일</TableHead>
- <TableHead>견적단가</TableHead>
- <TableHead>견적금액</TableHead>
+ <TableHead>입찰단가</TableHead>
+ <TableHead>입찰금액</TableHead>
<TableHead>납품예정일</TableHead>
{/* <TableHead>기술사양</TableHead> */}
<TableHead>SPEC</TableHead>
@@ -449,7 +449,7 @@ export function PrItemsPricingTable({
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Calculator className="w-4 h-4" />
- <Label className="font-semibold">총 사전견적 금액</Label>
+ <Label className="font-semibold">총 입찰 금액</Label>
</div>
<div className="text-2xl font-bold text-primary">
{formatCurrency(totalAmount)}
diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx
index 504fc916..10fe71a9 100644
--- a/lib/bidding/vendor/partners-bidding-detail.tsx
+++ b/lib/bidding/vendor/partners-bidding-detail.tsx
@@ -93,7 +93,7 @@ interface BiddingDetail {
additionalProposals: string | null
responseSubmittedAt: Date | null
priceAdjustmentResponse: boolean | null // 연동제 적용 여부
- isPreQuoteParticipated: boolean | null // 사전견적 참여 여부
+ isPreQuoteParticipated: boolean | null // 입찰 참여 여부
isPriceAdjustmentApplicableQuestion: boolean // 연동제 적용요건 문의 여부
}
@@ -174,7 +174,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
const [biddingConditions, setBiddingConditions] = React.useState<BiddingConditions | null>(null)
const [isNoticeOpen, setIsNoticeOpen] = React.useState(false)
- // 품목별 견적 관련 상태
+ // 품목별 입찰 관련 상태
const [prItems, setPrItems] = React.useState<PrItem[]>([])
const [prItemQuotations, setPrItemQuotations] = React.useState<BiddingPrItemQuotation[]>([])
const [totalQuotationAmount, setTotalQuotationAmount] = React.useState(0)
@@ -293,16 +293,16 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
// PR 아이템 설정
setPrItems(prItemsResult)
- // 사전견적 데이터를 본입찰용으로 로드 (응찰 확정 시 또는 사전견적이 있는 경우)
+ // 입찰 데이터를 본입찰용으로 로드 (응찰 확정 시 또는 입찰이 있는 경우)
if (result?.biddingCompanyId) {
try {
- // 사전견적 데이터를 가져와서 본입찰용으로 변환
+ // 입찰 데이터를 가져와서 본입찰용으로 변환
const preQuoteData = await getSavedPrItemQuotations(result.biddingCompanyId)
if (preQuoteData && Array.isArray(preQuoteData) && preQuoteData.length > 0) {
- console.log('사전견적 데이터:', preQuoteData)
+ console.log('입찰 데이터:', preQuoteData)
- // 사전견적 데이터를 본입찰 포맷으로 변환
+ // 입찰 데이터를 본입찰 포맷으로 변환
const convertedQuotations = preQuoteData
.filter(item => item && typeof item === 'object' && item.prItemId)
.map(item => ({
@@ -313,7 +313,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
technicalSpecification: item.technicalSpecification || undefined
}))
- console.log('변환된 견적 데이터:', convertedQuotations)
+ console.log('변환된 입찰 데이터:', convertedQuotations)
if (Array.isArray(convertedQuotations) && convertedQuotations.length > 0) {
setPrItemQuotations(convertedQuotations)
@@ -328,19 +328,19 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
}
}
- // 응찰 확정 시에만 사전견적 금액을 finalQuoteAmount로 설정
+ // 응찰 확정 시에만 입찰 금액을 finalQuoteAmount로 설정
if (totalQuotationAmount > 0 && result?.isBiddingParticipated === true) {
- console.log('응찰 확정됨, 사전견적 금액 설정:', totalQuotationAmount)
- console.log('사전견적 금액을 finalQuoteAmount로 설정:', totalQuotationAmount)
+ console.log('응찰 확정됨, 입찰 금액 설정:', totalQuotationAmount)
+ console.log('입찰 금액을 finalQuoteAmount로 설정:', totalQuotationAmount)
setResponseData(prev => ({
...prev,
finalQuoteAmount: totalQuotationAmount.toString()
}))
}
- // 연동제 데이터 로드 (사전견적에서 답변했으면 로드, 아니면 입찰 조건 확인)
+ // 연동제 데이터 로드 (입찰에서 답변했으면 로드, 아니면 입찰 조건 확인)
if (result.priceAdjustmentResponse !== null) {
- // 사전견적에서 이미 답변한 경우 - 연동제 폼 로드
+ // 입찰에서 이미 답변한 경우 - 연동제 폼 로드
const savedPriceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(result.biddingCompanyId)
if (savedPriceAdjustmentForm) {
setPriceAdjustmentForm({
@@ -397,7 +397,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
toast({
title: participated ? '참여 확정' : '미참여 확정',
description: participated
- ? '입찰에 참여하셨습니다. 이제 견적을 작성할 수 있습니다.'
+ ? '입찰에 참여하셨습니다. 이제 입찰을 작성할 수 있습니다.'
: '입찰 참여를 거절하셨습니다.',
})
@@ -406,13 +406,13 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
if (updatedDetail) {
setBiddingDetail(updatedDetail)
- // 참여 확정 시 사전견적 데이터가 있다면 로드
+ // 참여 확정 시 입찰 데이터가 있다면 로드
if (participated && updatedDetail.biddingCompanyId) {
try {
const preQuoteData = await getSavedPrItemQuotations(updatedDetail.biddingCompanyId)
if (preQuoteData && Array.isArray(preQuoteData) && preQuoteData.length > 0) {
- console.log('참여확정 후 사전견적 데이터:', preQuoteData)
+ console.log('참여확정 후 입찰 데이터:', preQuoteData)
const convertedQuotations = preQuoteData
.filter(item => item && typeof item === 'object' && item.prItemId)
@@ -424,7 +424,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
technicalSpecification: item.technicalSpecification || undefined
}))
- console.log('참여확정 후 변환된 견적 데이터:', convertedQuotations)
+ console.log('참여확정 후 변환된 입찰 데이터:', convertedQuotations)
if (Array.isArray(convertedQuotations) && convertedQuotations.length > 0) {
setPrItemQuotations(convertedQuotations)
@@ -467,16 +467,16 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
}
}
- // 품목별 견적 변경 핸들러
+ // 품목별 입찰 변경 핸들러
const handleQuotationsChange = (quotations: BiddingPrItemQuotation[]) => {
- console.log('견적 변경:', quotations)
+ console.log('입찰 변경:', quotations)
setPrItemQuotations(quotations)
}
// 총 금액 변경 핸들러
const handleTotalAmountChange = (total: number) => {
setTotalQuotationAmount(total)
- // 자동으로 총 견적 금액도 업데이트
+ // 자동으로 총 입찰 금액도 업데이트
setResponseData(prev => ({
...prev,
finalQuoteAmount: total.toString()
@@ -517,7 +517,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
if (prItemQuotations.length === 0) {
toast({
title: '저장할 데이터 없음',
- description: '저장할 품목별 견적이 없습니다.',
+ description: '저장할 품목별 입찰이 없습니다.',
variant: 'destructive',
})
return
@@ -545,7 +545,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
if (result.success) {
toast({
title: '임시 저장 완료',
- description: '품목별 견적이 임시 저장되었습니다.',
+ description: '품목별 입찰이 임시 저장되었습니다.',
})
} else {
toast({
@@ -580,7 +580,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
return
}
- if (isBrowser && !window.confirm('응찰을 취소하시겠습니까? 작성한 견적 내용이 모두 삭제됩니다.')) {
+ if (isBrowser && !window.confirm('응찰을 취소하시겠습니까? 작성한 입찰 내용이 모두 삭제됩니다.')) {
return
}
@@ -653,17 +653,17 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
if (!responseData.finalQuoteAmount.trim()) {
toast({
title: '유효성 오류',
- description: '견적 금액을 입력해주세요.',
+ description: '입찰 금액을 입력해주세요.',
variant: 'destructive',
})
return
}
- // 품목별 견적이 있는지 확인
+ // 품목별 입찰이 있는지 확인
if (prItems.length > 0 && prItemQuotations.length === 0) {
toast({
title: '유효성 오류',
- description: '품목별 견적을 작성해주세요.',
+ description: '품목별 입찰을 작성해주세요.',
variant: 'destructive',
})
return
@@ -683,7 +683,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
throw new Error(participationResult.error)
}
- // 2. 최종 견적 응답 제출 (PR 아이템별 견적 포함)
+ // 2. 최종 입찰 응답 제출 (PR 아이템별 입찰 포함)
const result = await submitPartnerResponse(
biddingDetail.biddingCompanyId,
{
@@ -708,7 +708,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
if (result.success) {
toast({
title: isFinalSubmission ? '응찰 완료' : '임시 저장 완료',
- description: isFinalSubmission ? '견적이 최종 제출되었습니다.' : '견적이 임시 저장되었습니다.',
+ description: isFinalSubmission ? '입찰이 최종 제출되었습니다.' : '입찰이 임시 저장되었습니다.',
})
// 데이터 새로고침
@@ -1060,7 +1060,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
<XCircle className="w-16 h-16 text-destructive mx-auto mb-4" />
<h3 className="text-lg font-semibold text-destructive mb-2">입찰에 참여하지 않기로 결정했습니다</h3>
<p className="text-muted-foreground">
- 해당 입찰에 대한 견적 제출 및 관련 기능은 이용할 수 없습니다.
+ 해당 입찰에 대한 입찰 제출 및 관련 기능은 이용할 수 없습니다.
</p>
</div>
</CardContent>
@@ -1081,7 +1081,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</div>
<h3 className="text-lg font-semibold mb-2">이 입찰에 참여하시겠습니까?</h3>
<p className="text-muted-foreground mb-6">
- 참여를 선택하시면 견적 작성 및 제출이 가능합니다.
+ 참여를 선택하시면 입찰 작성 및 제출이 가능합니다.
</p>
<div className="flex justify-center gap-4">
<Button
@@ -1115,7 +1115,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
- {/* 품목별 상세 견적 테이블 */}
+ {/* 품목별 상세 입찰 테이블 */}
{prItems.length > 0 ? (
<PrItemsPricingTable
prItems={prItems}
@@ -1133,7 +1133,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</div>
)}
- {/* 견적 첨부파일 섹션 */}
+ {/* 입찰 첨부파일 섹션 */}
{biddingDetail && userId && (
<SimpleFileUpload
biddingId={biddingId}