diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-25 11:51:27 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-25 11:51:27 +0000 |
| commit | 835df8ddc115ffa74414db2a4fab7efc0d0056a9 (patch) | |
| tree | bfe814c7b51ee1541d84b6e2dee01f28594763ac | |
| parent | 6160e8bd61360ada9e8e0574671c38292eaba9e7 (diff) | |
(최겸) 구매 입찰 수정v2
| -rw-r--r-- | components/bidding/create/bidding-create-dialog.tsx | 6 | ||||
| -rw-r--r-- | components/bidding/manage/bidding-basic-info-editor.tsx | 4 | ||||
| -rw-r--r-- | db/schema/bidding.ts | 2 | ||||
| -rw-r--r-- | db/schema/vendors.ts | 2 | ||||
| -rw-r--r-- | lib/bidding/approval-actions.ts | 67 | ||||
| -rw-r--r-- | lib/bidding/detail/service.ts | 8 | ||||
| -rw-r--r-- | lib/bidding/detail/table/bidding-detail-vendor-columns.tsx | 8 | ||||
| -rw-r--r-- | lib/bidding/failure/biddings-closure-dialog.tsx | 53 | ||||
| -rw-r--r-- | lib/bidding/failure/biddings-failure-table.tsx | 131 | ||||
| -rw-r--r-- | lib/bidding/handlers.ts | 130 | ||||
| -rw-r--r-- | lib/bidding/list/biddings-table-toolbar-actions.tsx | 1 | ||||
| -rw-r--r-- | lib/bidding/list/create-bidding-dialog.tsx | 2148 | ||||
| -rw-r--r-- | lib/bidding/receive/biddings-receive-table.tsx | 4 | ||||
| -rw-r--r-- | lib/bidding/vendor/components/pr-items-pricing-table.tsx | 8 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-detail.tsx | 62 |
15 files changed, 256 insertions, 2378 deletions
diff --git a/components/bidding/create/bidding-create-dialog.tsx b/components/bidding/create/bidding-create-dialog.tsx index 498d8d1c..78e070e1 100644 --- a/components/bidding/create/bidding-create-dialog.tsx +++ b/components/bidding/create/bidding-create-dialog.tsx @@ -872,7 +872,7 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp )}
/>
- <FormField
+ {/* <FormField
control={form.control}
name="biddingConditions.contractDeliveryDate"
render={({ field }) => (
@@ -894,7 +894,7 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp <FormMessage />
</FormItem>
)}
- />
+ /> */}
</div>
{/* 8행: 계약기간 시작/종료, 진행상태, 구매조직 */}
@@ -1007,7 +1007,7 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp name="noticeType"
render={({ field }) => (
<FormItem>
- <FormLabel>구매유형 <span className="text-red-500">*</span></FormLabel>
+ <FormLabel>구매유형<span className="text-red-500">*</span></FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
diff --git a/components/bidding/manage/bidding-basic-info-editor.tsx b/components/bidding/manage/bidding-basic-info-editor.tsx index 3c450065..8580bfe1 100644 --- a/components/bidding/manage/bidding-basic-info-editor.tsx +++ b/components/bidding/manage/bidding-basic-info-editor.tsx @@ -1021,7 +1021,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB {/* 4행: 계약 납품일, 하도급법 적용여부 */} <div className="grid grid-cols-2 gap-4 mb-4"> - <div> + {/* <div> <FormLabel>계약 납품일</FormLabel> <Input type="date" @@ -1033,7 +1033,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB })) }} /> - </div> + </div> */} <div> <FormLabel>하도급법 적용여부</FormLabel> <div className="flex items-center space-x-2"> diff --git a/db/schema/bidding.ts b/db/schema/bidding.ts index 6d968615..8f9ec5fe 100644 --- a/db/schema/bidding.ts +++ b/db/schema/bidding.ts @@ -709,7 +709,7 @@ export const contractTypeLabels = { } as const export const biddingNoticeTypeLabels = { - standard: '표준', + standard: '일반', facility: '시설재', unit_price: '단가계약' } as const diff --git a/db/schema/vendors.ts b/db/schema/vendors.ts index 25255379..d587d441 100644 --- a/db/schema/vendors.ts +++ b/db/schema/vendors.ts @@ -64,7 +64,7 @@ export const vendors = pgTable("vendors", { creditRating: varchar("credit_rating", { length: 50 }), cashFlowRating: varchar("cash_flow_rating", { length: 50 }), - businessSize: varchar("business_size", { length: 255 }), + businessSize: varchar("business_size", { length: 255 }), // 기업규모 // 성조회 가입여부: 공제회 가입여부이며, 구매에서 직접 입력하겠다는 값임. 'E'=해당없음, 'Y'=가입, 'N'=미가입, null='-' isAssociationMember: varchar("is_association_member", { length: 1 }), 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} |
