summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/companies/page.tsx4
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/info/page.tsx4
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/items/page.tsx4
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/schedule/page.tsx4
-rw-r--r--components/bidding/bidding-round-actions.tsx221
-rw-r--r--components/bidding/create/bidding-create-dialog.tsx121
-rw-r--r--components/bidding/manage/bidding-basic-info-editor.tsx136
-rw-r--r--components/bidding/manage/bidding-companies-editor.tsx48
-rw-r--r--components/bidding/manage/bidding-detail-vendor-create-dialog.tsx14
-rw-r--r--components/bidding/manage/bidding-items-editor.tsx49
-rw-r--r--components/bidding/manage/bidding-schedule-editor.tsx225
-rw-r--r--lib/approval/handlers-registry.ts15
-rw-r--r--lib/approval/templates/입찰초대 결재.html805
-rw-r--r--lib/bidding/approval-actions.ts243
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-columns.tsx22
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx59
-rw-r--r--lib/bidding/failure/biddings-failure-table.tsx63
-rw-r--r--lib/bidding/handlers.ts283
-rw-r--r--lib/bidding/list/biddings-table-columns.tsx11
-rw-r--r--lib/bidding/service.ts74
20 files changed, 1862 insertions, 543 deletions
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/companies/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/companies/page.tsx
index f1699665..0ef26754 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/companies/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/companies/page.tsx
@@ -55,7 +55,7 @@ export default async function BiddingCompaniesPage({ params }: PageProps) {
</p>
</div>
</div>
- <Link href={`/${lng}/evcp/bid/${id}`} passHref>
+ <Link href={`/evcp/bid`} passHref>
<Button variant="outline" className="flex items-center">
<ArrowLeft className="mr-2 h-4 w-4" />
입찰 관리로 돌아가기
@@ -69,7 +69,7 @@ export default async function BiddingCompaniesPage({ params }: PageProps) {
</div>
{/* 입찰 업체 및 담당자 에디터 */}
- <BiddingCompaniesEditor biddingId={parsedId} />
+ <BiddingCompaniesEditor biddingId={parsedId} readonly={bidding.status !== 'bidding_generated'} />
</div>
)
}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/info/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/info/page.tsx
index 7281d206..9ef99302 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/info/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/info/page.tsx
@@ -55,7 +55,7 @@ export default async function BiddingBasicInfoPage({ params }: PageProps) {
</p>
</div>
</div>
- <Link href={`/${lng}/evcp/bid/${id}`} passHref>
+ <Link href={`/evcp/bid`} passHref>
<Button variant="outline" className="flex items-center">
<ArrowLeft className="mr-2 h-4 w-4" />
입찰 관리로 돌아가기
@@ -69,7 +69,7 @@ export default async function BiddingBasicInfoPage({ params }: PageProps) {
</div>
{/* 입찰 기본 정보 에디터 */}
- <BiddingBasicInfoEditor biddingId={parsedId} />
+ <BiddingBasicInfoEditor biddingId={parsedId} readonly={bidding.status !== 'bidding_generated'} />
</div>
)
}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/items/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/items/page.tsx
index 5b686a1c..bc71ae8b 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/items/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/items/page.tsx
@@ -56,7 +56,7 @@ export default async function BiddingItemsPage({ params }: PageProps) {
</div>
</div>
- <Link href={`/${lng}/evcp/bid/${id}`} passHref>
+ <Link href={`/evcp/bid`} passHref>
<Button variant="outline" className="flex items-center">
<ArrowLeft className="mr-2 h-4 w-4" />
입찰 관리로 돌아가기
@@ -70,7 +70,7 @@ export default async function BiddingItemsPage({ params }: PageProps) {
</div>
{/* 입찰 품목 에디터 */}
- <BiddingItemsEditor biddingId={parsedId} />
+ <BiddingItemsEditor biddingId={parsedId} readonly={bidding.status !== 'bidding_generated'} />
</div>
)
}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/schedule/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/schedule/page.tsx
index a79bef88..1d5b7601 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/schedule/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/schedule/page.tsx
@@ -53,7 +53,7 @@ export default async function BiddingSchedulePage({ params }: PageProps) {
입찰 No. {bidding.biddingNumber ?? ""} - {bidding.title}
</p>
</div>
- <Link href={`/${lng}/evcp/bid/${id}`} passHref>
+ <Link href={`/evcp/bid`} passHref>
<Button variant="outline" className="flex items-center">
<ArrowLeft className="mr-2 h-4 w-4" />
입찰 관리로 돌아가기
@@ -67,7 +67,7 @@ export default async function BiddingSchedulePage({ params }: PageProps) {
</div>
{/* 입찰 일정 에디터 */}
- <BiddingScheduleEditor biddingId={parsedId} />
+ <BiddingScheduleEditor biddingId={parsedId} readonly={bidding.status !== 'bidding_generated'} />
</div>
)
}
diff --git a/components/bidding/bidding-round-actions.tsx b/components/bidding/bidding-round-actions.tsx
deleted file mode 100644
index 86fea72a..00000000
--- a/components/bidding/bidding-round-actions.tsx
+++ /dev/null
@@ -1,221 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import { useTransition } from "react"
-import { Button } from "@/components/ui/button"
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { RefreshCw, RotateCw } from "lucide-react"
-import { increaseRoundOrRebid } from "@/lib/bidding/service"
-import { useToast } from "@/hooks/use-toast"
-
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog"
-import { useSession } from "next-auth/react"
-
-interface BiddingRoundActionsProps {
- biddingId: number
- biddingStatus?: string
-}
-
-export function BiddingRoundActions({ biddingId, biddingStatus }: BiddingRoundActionsProps) {
- const router = useRouter()
- const { toast } = useToast()
- const [isPending, startTransition] = useTransition()
- const [showRoundDialog, setShowRoundDialog] = React.useState(false)
- const [showRebidDialog, setShowRebidDialog] = React.useState(false)
- const { data: session } = useSession()
- const userId = session?.user?.id
-
- // 차수증가는 입찰공고 또는 입찰 진행중 상태에서 가능
- const canIncreaseRound = biddingStatus === 'bidding_generated' || biddingStatus === 'bidding_opened'
-
- // 유찰 및 낙찰은 입찰 진행중 상태에서 가능 (이 컴포넌트에서는 사용하지 않음)
-
- // 재입찰은 유찰 상태에서만 가능
- const canRebid = biddingStatus === 'bidding_disposal'
-
- const handleRoundIncrease = () => {
- if (!userId) {
- toast({
- title: "오류",
- description: "사용자 정보를 찾을 수 없습니다.",
- variant: "destructive",
- })
- return
- }
- const userIdStr = userId as string
- startTransition(async () => {
- try {
- const result = await increaseRoundOrRebid(biddingId, userIdStr, 'round_increase')
-
- if (result.success) {
- toast({
- title: "성공",
- description: result.message,
- variant: "default",
- })
- setShowRoundDialog(false)
- // 새로 생성된 입찰 페이지로 이동
- if (result.biddingId) {
- router.push(`/evcp/bid/${result.biddingId}`)
- router.refresh()
- }
- } else {
- toast({
- title: "오류",
- description: result.error || "차수증가 중 오류가 발생했습니다.",
- variant: "destructive",
- })
- }
- } catch (error) {
- console.error('차수증가 실패:', error)
- toast({
- title: "오류",
- description: "차수증가 중 오류가 발생했습니다.",
- variant: "destructive",
- })
- }
- })
- }
-
- const handleRebid = () => {
- if (!userId) {
- toast({
- title: "오류",
- description: "사용자 정보를 찾을 수 없습니다.",
- variant: "destructive",
- })
- return
- }
- const userIdStr = userId as string
- startTransition(async () => {
- try {
- const result = await increaseRoundOrRebid(biddingId, userIdStr, 'rebidding')
-
- if (result.success) {
- toast({
- title: "성공",
- description: result.message,
- variant: "default",
- })
- setShowRebidDialog(false)
- // 새로 생성된 입찰 페이지로 이동
- if (result.biddingId) {
- router.push(`/evcp/bid/${result.biddingId}`)
- router.refresh()
- }
- } else {
- toast({
- title: "오류",
- description: result.error || "재입찰 중 오류가 발생했습니다.",
- variant: "destructive",
- })
- }
- } catch (error) {
- console.error('재입찰 실패:', error)
- toast({
- title: "오류",
- description: "재입찰 중 오류가 발생했습니다.",
- variant: "destructive",
- })
- }
- })
- }
-
- // 유찰 상태가 아니면 컴포넌트를 렌더링하지 않음
- if (!canIncreaseRound && !canRebid) {
- return null
- }
-
- return (
- <>
- <Card className="mt-6">
- <CardHeader>
- <CardTitle>입찰 차수 관리</CardTitle>
- </CardHeader>
- <CardContent>
- <div className="flex gap-4">
- <Button
- variant="outline"
- onClick={() => setShowRoundDialog(true)}
- disabled={!canIncreaseRound || isPending}
- className="flex items-center gap-2"
- >
- <RotateCw className="w-4 h-4" />
- 차수증가
- </Button>
- <Button
- variant="outline"
- onClick={() => setShowRebidDialog(true)}
- disabled={!canRebid || isPending}
- className="flex items-center gap-2"
- >
- <RefreshCw className="w-4 h-4" />
- 재입찰
- </Button>
- </div>
- <p className="text-sm text-muted-foreground mt-2">
- 유찰 상태에서 차수증가 또는 재입찰을 진행할 수 있습니다.
- </p>
- </CardContent>
- </Card>
-
- {/* 차수증가 확인 다이얼로그 */}
- <AlertDialog open={showRoundDialog} onOpenChange={setShowRoundDialog}>
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>차수증가</AlertDialogTitle>
- <AlertDialogDescription>
- 현재 입찰의 정보를 복제하여 새로운 차수의 입찰을 생성합니다.
- <br />
- 기존 입찰 조건, 아이템, 벤더 정보가 복제되며, 벤더 제출 정보는 초기화됩니다.
- <br />
- <br />
- 계속하시겠습니까?
- </AlertDialogDescription>
- </AlertDialogHeader>
- <AlertDialogFooter>
- <AlertDialogCancel disabled={isPending}>취소</AlertDialogCancel>
- <AlertDialogAction onClick={handleRoundIncrease} disabled={isPending}>
- {isPending ? "처리중..." : "확인"}
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
-
- {/* 재입찰 확인 다이얼로그 */}
- <AlertDialog open={showRebidDialog} onOpenChange={setShowRebidDialog}>
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>재입찰</AlertDialogTitle>
- <AlertDialogDescription>
- 현재 입찰의 정보를 복제하여 재입찰을 생성합니다.
- <br />
- 기존 입찰 조건, 아이템, 벤더 정보가 복제되며, 벤더 제출 정보는 초기화됩니다.
- <br />
- <br />
- 계속하시겠습니까?
- </AlertDialogDescription>
- </AlertDialogHeader>
- <AlertDialogFooter>
- <AlertDialogCancel disabled={isPending}>취소</AlertDialogCancel>
- <AlertDialogAction onClick={handleRebid} disabled={isPending}>
- {isPending ? "처리중..." : "확인"}
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
- </>
- )
-}
-
-
diff --git a/components/bidding/create/bidding-create-dialog.tsx b/components/bidding/create/bidding-create-dialog.tsx
index bdb00a01..c7d79435 100644
--- a/components/bidding/create/bidding-create-dialog.tsx
+++ b/components/bidding/create/bidding-create-dialog.tsx
@@ -38,12 +38,23 @@ import {
import { TAX_CONDITIONS } from '@/lib/tax-conditions/types'
import { getBiddingNoticeTemplate } from '@/lib/bidding/service'
import TiptapEditor from '@/components/qna/tiptap-editor'
+
+// Dropzone components
+import {
+ Dropzone,
+ DropzoneDescription,
+ DropzoneInput,
+ DropzoneTitle,
+ DropzoneUploadIcon,
+ DropzoneZone,
+} from "@/components/ui/dropzone"
import { PurchaseGroupCodeSelector } from '@/components/common/selectors/purchase-group-code'
import { ProcurementManagerSelector } from '@/components/common/selectors/procurement-manager'
import type { PurchaseGroupCodeWithUser } from '@/components/common/selectors/purchase-group-code/purchase-group-code-service'
import type { ProcurementManagerWithUser } from '@/components/common/selectors/procurement-manager/procurement-manager-service'
import { createBidding } from '@/lib/bidding/service'
import { useSession } from 'next-auth/react'
+import { useRouter } from 'next/navigation'
interface BiddingCreateDialogProps {
form: UseFormReturn<CreateBiddingSchema>
@@ -52,6 +63,7 @@ interface BiddingCreateDialogProps {
export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProps) {
const { data: session } = useSession()
+ const router = useRouter()
const userId = session?.user?.id ? Number(session.user.id) : null;
const [isOpen, setIsOpen] = React.useState(false)
@@ -145,6 +157,13 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp
React.useEffect(() => {
if (isOpen) {
+ if (userId && session?.user?.name) {
+ // 현재 사용자의 정보를 임시로 입찰담당자로 설정
+ form.setValue('bidPicName', session.user.name)
+ form.setValue('bidPicId', userId)
+ // userCode는 현재 세션에 없으므로 이름으로 설정 (실제로는 API에서 가져와야 함)
+ // form.setValue('bidPicCode', session.user.name)
+ }
loadPaymentTerms()
loadIncoterms()
loadShippingPlaces()
@@ -280,7 +299,7 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp
}))
// sparePartOptions가 undefined인 경우 빈 문자열로 설정
- const biddingData: CreateBiddingInput = {
+ const biddingData = {
...data,
attachments,
vendorAttachments,
@@ -298,6 +317,12 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp
if (result.success) {
toast.success("입찰이 성공적으로 생성되었습니다.")
+
+ // 생성된 입찰의 상세 페이지로 이동
+ if ('data' in result && result.data?.id) {
+ router.push(`/evcp/bid/${result.data.id}`)
+ }
+
setIsOpen(false)
form.reset()
setShiAttachmentFiles([])
@@ -1130,30 +1155,35 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp
SHI용 첨부파일
</CardTitle>
<p className="text-sm text-muted-foreground">
- SHI에서 제공하는 문서나 파일을 업로드하세요
+ 내부 보관를 위해 필요한 문서나 파일을 업로드 하세요.
</p>
</CardHeader>
<CardContent className="space-y-4">
- <div className="border-2 border-dashed border-gray-300 rounded-lg p-6">
- <div className="text-center">
- <Upload className="h-12 w-12 text-gray-400 mx-auto mb-4" />
- <div className="space-y-2">
- <p className="text-sm text-gray-600">
- 파일을 드래그 앤 드롭하거나{' '}
- <label className="text-blue-600 hover:text-blue-500 cursor-pointer">
- <input
- type="file"
- multiple
- className="hidden"
- onChange={handleShiFileUpload}
- accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg"
- />
- 찾아보세요
- </label>
- </p>
- </div>
- </div>
- </div>
+ <Dropzone
+ maxSize={6e8} // 600MB
+ onDropAccepted={(files) => {
+ const newFiles = Array.from(files)
+ setShiAttachmentFiles(prev => [...prev, ...newFiles])
+ }}
+ onDropRejected={() => {
+ toast({
+ title: "File upload rejected",
+ description: "Please check file size and type.",
+ variant: "destructive",
+ })
+ }}
+ >
+ {() => (
+ <DropzoneZone className="flex justify-center h-32">
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>파일을 드래그하여 업로드</DropzoneTitle>
+ </div>
+ </div>
+ </DropzoneZone>
+ )}
+ </Dropzone>
{shiAttachmentFiles.length > 0 && (
<div className="space-y-2">
@@ -1197,30 +1227,35 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp
협력업체용 첨부파일
</CardTitle>
<p className="text-sm text-muted-foreground">
- 협력업체에서 제공하는 문서나 파일을 업로드하세요
+ 협력사로 제공하는 문서나 파일을 업로드 하세요.
</p>
</CardHeader>
<CardContent className="space-y-4">
- <div className="border-2 border-dashed border-gray-300 rounded-lg p-6">
- <div className="text-center">
- <Upload className="h-12 w-12 text-gray-400 mx-auto mb-4" />
- <div className="space-y-2">
- <p className="text-sm text-gray-600">
- 파일을 드래그 앤 드롭하거나{' '}
- <label className="text-blue-600 hover:text-blue-500 cursor-pointer">
- <input
- type="file"
- multiple
- className="hidden"
- onChange={handleVendorFileUpload}
- accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg"
- />
- 찾아보세요
- </label>
- </p>
- </div>
- </div>
- </div>
+ <Dropzone
+ maxSize={6e8} // 600MB
+ onDropAccepted={(files) => {
+ const newFiles = Array.from(files)
+ setVendorAttachmentFiles(prev => [...prev, ...newFiles])
+ }}
+ onDropRejected={() => {
+ toast({
+ title: "File upload rejected",
+ description: "Please check file size and type.",
+ variant: "destructive",
+ })
+ }}
+ >
+ {() => (
+ <DropzoneZone className="flex justify-center h-32">
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>파일을 드래그하여 업로드</DropzoneTitle>
+ </div>
+ </div>
+ </DropzoneZone>
+ )}
+ </Dropzone>
{vendorAttachmentFiles.length > 0 && (
<div className="space-y-2">
diff --git a/components/bidding/manage/bidding-basic-info-editor.tsx b/components/bidding/manage/bidding-basic-info-editor.tsx
index e92c39a5..f0d56689 100644
--- a/components/bidding/manage/bidding-basic-info-editor.tsx
+++ b/components/bidding/manage/bidding-basic-info-editor.tsx
@@ -45,6 +45,16 @@ import type { ProcurementManagerWithUser } from '@/components/common/selectors/p
import { getBiddingDocuments, uploadBiddingDocument, deleteBiddingDocument } from '@/lib/bidding/detail/service'
import { downloadFile } from '@/lib/file-download'
+// Dropzone components
+import {
+ Dropzone,
+ DropzoneDescription,
+ DropzoneInput,
+ DropzoneTitle,
+ DropzoneUploadIcon,
+ DropzoneZone,
+} from "@/components/ui/dropzone"
+
// 입찰 기본 정보 에디터 컴포넌트
interface BiddingBasicInfo {
title?: string
@@ -78,6 +88,7 @@ interface BiddingBasicInfo {
interface BiddingBasicInfoEditorProps {
biddingId: number
+ readonly?: boolean
}
interface UploadedDocument {
@@ -95,7 +106,7 @@ interface UploadedDocument {
uploadedBy: string
}
-export function BiddingBasicInfoEditor({ biddingId }: BiddingBasicInfoEditorProps) {
+export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingBasicInfoEditorProps) {
const [isLoading, setIsLoading] = React.useState(true)
const [isSubmitting, setIsSubmitting] = React.useState(false)
const [isLoadingTemplate, setIsLoadingTemplate] = React.useState(false)
@@ -535,7 +546,7 @@ export function BiddingBasicInfoEditor({ biddingId }: BiddingBasicInfoEditorProp
<FormItem>
<FormLabel>입찰명 <span className="text-red-500">*</span></FormLabel>
<FormControl>
- <Input placeholder="입찰명을 입력하세요" {...field} />
+ <Input placeholder="입찰명을 입력하세요" {...field} disabled={readonly} />
</FormControl>
<FormMessage />
</FormItem>
@@ -883,7 +894,7 @@ export function BiddingBasicInfoEditor({ biddingId }: BiddingBasicInfoEditorProp
<FormItem>
<FormLabel>입찰개요</FormLabel>
<FormControl>
- <Textarea placeholder="입찰에 대한 설명을 입력하세요" rows={2} {...field} />
+ <Textarea placeholder="입찰에 대한 설명을 입력하세요" rows={2} {...field} readOnly={readonly} />
</FormControl>
<FormMessage />
</FormItem>
@@ -896,7 +907,7 @@ export function BiddingBasicInfoEditor({ biddingId }: BiddingBasicInfoEditorProp
<FormItem>
<FormLabel>비고</FormLabel>
<FormControl>
- <Textarea placeholder="추가 사항이나 참고사항을 입력하세요" rows={3} {...field} />
+ <Textarea placeholder="추가 사항이나 참고사항을 입력하세요" rows={3} {...field} readOnly={readonly} />
</FormControl>
<FormMessage />
</FormItem>
@@ -1123,6 +1134,7 @@ export function BiddingBasicInfoEditor({ biddingId }: BiddingBasicInfoEditorProp
}))
}}
rows={3}
+ readOnly={readonly}
/>
</div>
</div>
@@ -1138,7 +1150,7 @@ export function BiddingBasicInfoEditor({ biddingId }: BiddingBasicInfoEditorProp
<TiptapEditor
content={field.value || noticeTemplate}
setContent={field.onChange}
- disabled={isLoadingTemplate}
+ disabled={isLoadingTemplate || readonly}
height="300px"
/>
</div>
@@ -1156,12 +1168,14 @@ export function BiddingBasicInfoEditor({ biddingId }: BiddingBasicInfoEditorProp
</div>
{/* 액션 버튼 */}
- <div className="flex justify-end gap-4 pt-4">
- <Button type="submit" disabled={isSubmitting} className="flex items-center gap-2">
- {isSubmitting ? '저장 중...' : '저장'}
- <ChevronRight className="h-4 w-4" />
- </Button>
- </div>
+ {!readonly && (
+ <div className="flex justify-end gap-4 pt-4">
+ <Button type="submit" disabled={isSubmitting} className="flex items-center gap-2">
+ {isSubmitting ? '저장 중...' : '저장'}
+ <ChevronRight className="h-4 w-4" />
+ </Button>
+ </div>
+ )}
</form>
</Form>
</CardContent>
@@ -1175,33 +1189,35 @@ export function BiddingBasicInfoEditor({ biddingId }: BiddingBasicInfoEditorProp
SHI용 첨부파일
</CardTitle>
<p className="text-sm text-muted-foreground">
- SHI에서 제공하는 문서나 파일을 업로드하세요
+ 내부 보관를 위해 필요한 문서나 파일을 업로드 하세요.
</p>
</CardHeader>
<CardContent className="space-y-4">
- <div className="border-2 border-dashed border-gray-300 rounded-lg p-6">
- <div className="text-center">
- <Upload className="h-12 w-12 text-gray-400 mx-auto mb-4" />
- <div className="space-y-2">
- <p className="text-sm text-gray-600">
- 파일을 드래그 앤 드롭하거나{' '}
- <label className="text-blue-600 hover:text-blue-500 cursor-pointer">
- <input
- type="file"
- multiple
- className="hidden"
- onChange={(e) => {
- const files = Array.from(e.target.files || [])
- setShiAttachmentFiles(prev => [...prev, ...files])
- }}
- accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg"
- />
- 찾아보세요
- </label>
- </p>
- </div>
- </div>
- </div>
+ <Dropzone
+ maxSize={6e8} // 600MB
+ onDropAccepted={(files) => {
+ const newFiles = Array.from(files)
+ setShiAttachmentFiles(prev => [...prev, ...newFiles])
+ }}
+ onDropRejected={() => {
+ toast({
+ title: "File upload rejected",
+ description: "Please check file size and type.",
+ variant: "destructive",
+ })
+ }}
+ >
+ {() => (
+ <DropzoneZone className="flex justify-center h-32">
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>파일을 드래그하여 업로드</DropzoneTitle>
+ </div>
+ </div>
+ </DropzoneZone>
+ )}
+ </Dropzone>
{shiAttachmentFiles.length > 0 && (
<div className="space-y-2">
@@ -1307,33 +1323,35 @@ export function BiddingBasicInfoEditor({ biddingId }: BiddingBasicInfoEditorProp
협력업체용 첨부파일
</CardTitle>
<p className="text-sm text-muted-foreground">
- 협력업체에서 제공하는 문서나 파일을 업로드하세요
+ 협력사로 제공하는 문서나 파일을 업로드 하세요.
</p>
</CardHeader>
<CardContent className="space-y-4">
- <div className="border-2 border-dashed border-gray-300 rounded-lg p-6">
- <div className="text-center">
- <Upload className="h-12 w-12 text-gray-400 mx-auto mb-4" />
- <div className="space-y-2">
- <p className="text-sm text-gray-600">
- 파일을 드래그 앤 드롭하거나{' '}
- <label className="text-blue-600 hover:text-blue-500 cursor-pointer">
- <input
- type="file"
- multiple
- className="hidden"
- onChange={(e) => {
- const files = Array.from(e.target.files || [])
- setVendorAttachmentFiles(prev => [...prev, ...files])
- }}
- accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg"
- />
- 찾아보세요
- </label>
- </p>
- </div>
- </div>
- </div>
+ <Dropzone
+ maxSize={6e8} // 600MB
+ onDropAccepted={(files) => {
+ const newFiles = Array.from(files)
+ setVendorAttachmentFiles(prev => [...prev, ...newFiles])
+ }}
+ onDropRejected={() => {
+ toast({
+ title: "File upload rejected",
+ description: "Please check file size and type.",
+ variant: "destructive",
+ })
+ }}
+ >
+ {() => (
+ <DropzoneZone className="flex justify-center h-32">
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>파일을 드래그하여 업로드</DropzoneTitle>
+ </div>
+ </div>
+ </DropzoneZone>
+ )}
+ </Dropzone>
{vendorAttachmentFiles.length > 0 && (
<div className="space-y-2">
diff --git a/components/bidding/manage/bidding-companies-editor.tsx b/components/bidding/manage/bidding-companies-editor.tsx
index 4992c2ab..a81f0063 100644
--- a/components/bidding/manage/bidding-companies-editor.tsx
+++ b/components/bidding/manage/bidding-companies-editor.tsx
@@ -11,8 +11,7 @@ import {
createBiddingCompanyContact,
deleteBiddingCompanyContact,
getVendorContactsByVendorId,
- updateBiddingCompanyPriceAdjustmentQuestion,
- getBiddingById
+ updateBiddingCompanyPriceAdjustmentQuestion
} from '@/lib/bidding/service'
import { deleteBiddingCompany } from '@/lib/bidding/pre-quote/service'
import { BiddingDetailVendorCreateDialog } from './bidding-detail-vendor-create-dialog'
@@ -54,6 +53,7 @@ interface QuotationVendor {
interface BiddingCompaniesEditorProps {
biddingId: number
+ readonly?: boolean
}
interface VendorContact {
@@ -79,7 +79,7 @@ interface BiddingCompanyContact {
updatedAt: Date
}
-export function BiddingCompaniesEditor({ biddingId }: BiddingCompaniesEditorProps) {
+export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingCompaniesEditorProps) {
const [vendors, setVendors] = React.useState<QuotationVendor[]>([])
const [isLoading, setIsLoading] = React.useState(false)
const [addVendorDialogOpen, setAddVendorDialogOpen] = React.useState(false)
@@ -89,10 +89,6 @@ export function BiddingCompaniesEditor({ biddingId }: BiddingCompaniesEditorProp
// 각 업체별 첫 번째 담당자 정보 저장 (vendorId -> 첫 번째 담당자)
const [vendorFirstContacts, setVendorFirstContacts] = React.useState<Map<number, BiddingCompanyContact>>(new Map())
- // 입찰 정보 (단수/복수 낙찰 확인용)
- const [biddingInfo, setBiddingInfo] = React.useState<any>(null)
- const [isLoadingBiddingInfo, setIsLoadingBiddingInfo] = React.useState(false)
-
// 담당자 추가 다이얼로그
const [addContactDialogOpen, setAddContactDialogOpen] = React.useState(false)
const [newContact, setNewContact] = React.useState({
@@ -146,29 +142,6 @@ export function BiddingCompaniesEditor({ biddingId }: BiddingCompaniesEditorProp
}
}, [biddingId])
- // 입찰 정보 로딩
- const loadBiddingInfo = React.useCallback(async () => {
- setIsLoadingBiddingInfo(true)
- try {
- const result = await getBiddingById(biddingId)
- if (result) {
- setBiddingInfo(result)
- } else {
- console.error('Failed to load bidding info')
- setBiddingInfo(null)
- }
- } catch (error) {
- console.error('Failed to load bidding info:', error)
- setBiddingInfo(null)
- } finally {
- setIsLoadingBiddingInfo(false)
- }
- }, [biddingId])
-
- // 단수 입찰 여부 확인 및 업체 추가 제한
- const isSingleAwardBidding = biddingInfo?.awardCount === 'single'
- const canAddVendor = !isSingleAwardBidding || vendors.length === 0
-
// 데이터 로딩
React.useEffect(() => {
const loadVendors = async () => {
@@ -222,8 +195,7 @@ export function BiddingCompaniesEditor({ biddingId }: BiddingCompaniesEditorProp
}
loadVendors()
- loadBiddingInfo()
- }, [biddingId, loadBiddingInfo])
+ }, [biddingId])
// 업체 선택 핸들러 (단일 선택)
const handleVendorSelect = async (vendor: QuotationVendor) => {
@@ -513,10 +485,12 @@ export function BiddingCompaniesEditor({ biddingId }: BiddingCompaniesEditorProp
</p>
<p className="text-sm text-muted-foreground mt-1"> 단수 입찰의 경우 1개 업체만 등록 가능합니다. </p>
</div>
- <Button onClick={() => setAddVendorDialogOpen(true)} className="flex items-center gap-2">
- <Plus className="h-4 w-4" />
- 업체 추가
- </Button>
+ {!readonly && (
+ <Button onClick={() => setAddVendorDialogOpen(true)} className="flex items-center gap-2">
+ <Plus className="h-4 w-4" />
+ 업체 추가
+ </Button>
+ )}
</CardHeader>
<CardContent>
{vendors.length === 0 ? (
@@ -687,8 +661,6 @@ export function BiddingCompaniesEditor({ biddingId }: BiddingCompaniesEditorProp
open={addVendorDialogOpen}
onOpenChange={setAddVendorDialogOpen}
onSuccess={reloadVendors}
- isSingleAwardBidding={isSingleAwardBidding}
- currentVendorCount={vendors.length}
/>
{/* 담당자 추가 다이얼로그 (직접 입력) */}
diff --git a/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx b/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx
index 205224b9..de813121 100644
--- a/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx
+++ b/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx
@@ -39,8 +39,6 @@ interface BiddingDetailVendorCreateDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSuccess: () => void
- isSingleAwardBidding?: boolean
- currentVendorCount?: number
}
interface Vendor {
@@ -60,8 +58,6 @@ export function BiddingDetailVendorCreateDialog({
open,
onOpenChange,
onSuccess,
- isSingleAwardBidding = false,
- currentVendorCount = 0
}: BiddingDetailVendorCreateDialogProps) {
const { toast } = useToast()
const [isPending, startTransition] = useTransition()
@@ -104,16 +100,6 @@ export function BiddingDetailVendorCreateDialog({
// 벤더 추가
const handleAddVendor = (vendor: Vendor) => {
- // 단수 입찰이고 이미 업체가 선택되었거나 기존 업체가 있는 경우 제한
- if (isSingleAwardBidding && (selectedVendorsWithQuestion.length > 0 || currentVendorCount > 0)) {
- toast({
- title: '제한 사항',
- description: '단수 입찰의 경우 1개 업체만 등록 가능합니다.',
- variant: 'destructive',
- })
- return
- }
-
if (!selectedVendorsWithQuestion.find(v => v.vendor.id === vendor.id)) {
setSelectedVendorsWithQuestion([
...selectedVendorsWithQuestion,
diff --git a/components/bidding/manage/bidding-items-editor.tsx b/components/bidding/manage/bidding-items-editor.tsx
index dc0aaeec..38113dfa 100644
--- a/components/bidding/manage/bidding-items-editor.tsx
+++ b/components/bidding/manage/bidding-items-editor.tsx
@@ -76,6 +76,7 @@ interface PRItemInfo {
interface BiddingItemsEditorProps {
biddingId: number
+ readonly?: boolean
}
import { removeBiddingItem, addPRItemForBidding, getBiddingById, getBiddingConditions } from '@/lib/bidding/service'
@@ -84,7 +85,7 @@ import { ProcurementItemSelectorDialogSingle } from '@/components/common/selecto
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
-export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) {
+export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItemsEditorProps) {
const { data: session } = useSession()
const [items, setItems] = React.useState<PRItemInfo[]>([])
const [isLoading, setIsLoading] = React.useState(false)
@@ -620,7 +621,7 @@ export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) {
/>
</td>
<td className="border-r px-3 py-2">
- {biddingType === 'equipment' ? (
+ {biddingType !== 'equipment' ? (
<ProcurementItemSelectorDialogSingle
triggerLabel={item.materialGroupNumber || "품목 선택"}
triggerVariant="outline"
@@ -642,8 +643,8 @@ export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) {
})
}
}}
- title="품목 선택"
- description="품목을 검색하고 선택해주세요."
+ title="1회성 품목 선택"
+ description="1회성 품목을 검색하고 선택해주세요."
/>
) : (
<MaterialGroupSelectorDialogSingle
@@ -1149,25 +1150,27 @@ export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) {
</Card>
{/* 액션 버튼 */}
- <div className="flex justify-end gap-4">
- <Button
- onClick={handleSave}
- disabled={isSubmitting}
- className="min-w-[120px]"
- >
- {isSubmitting ? (
- <>
- <RefreshCw className="w-4 h-4 mr-2 animate-spin" />
- 저장 중...
- </>
- ) : (
- <>
- <Save className="w-4 h-4 mr-2" />
- 저장
- </>
- )}
- </Button>
- </div>
+ {!readonly && (
+ <div className="flex justify-end gap-4">
+ <Button
+ onClick={handleSave}
+ disabled={isSubmitting}
+ className="min-w-[120px]"
+ >
+ {isSubmitting ? (
+ <>
+ <RefreshCw className="w-4 h-4 mr-2 animate-spin" />
+ 저장 중...
+ </>
+ ) : (
+ <>
+ <Save className="w-4 h-4 mr-2" />
+ 저장
+ </>
+ )}
+ </Button>
+ </div>
+ )}
{/* 사전견적용 일반견적 생성 다이얼로그 */}
<CreatePreQuoteRfqDialog
diff --git a/components/bidding/manage/bidding-schedule-editor.tsx b/components/bidding/manage/bidding-schedule-editor.tsx
index ce03c742..f2978f95 100644
--- a/components/bidding/manage/bidding-schedule-editor.tsx
+++ b/components/bidding/manage/bidding-schedule-editor.tsx
@@ -12,6 +12,9 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
+import { ApprovalPreviewDialog } from '@/lib/approval/approval-preview-dialog'
+import { requestBiddingInvitationWithApproval } from '@/lib/bidding/approval-actions'
+import { prepareBiddingApprovalData } from '@/lib/bidding/approval-actions'
import { BiddingInvitationDialog } from '@/lib/bidding/detail/table/bidding-invitation-dialog'
import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from '@/lib/bidding/pre-quote/service'
import { registerBidding } from '@/lib/bidding/detail/service'
@@ -41,16 +44,17 @@ interface SpecificationMeetingInfo {
interface BiddingScheduleEditorProps {
biddingId: number
+ readonly?: boolean
}
interface VendorContractRequirement {
vendorId: number
vendorName: string
- vendorCode?: string | null
+ vendorCode?: string
vendorCountry?: string
- vendorEmail?: string | null
- contactPerson?: string | null
- contactEmail?: string | null
+ vendorEmail?: string
+ contactPerson?: string
+ contactEmail?: string
ndaYn?: boolean
generalGtcYn?: boolean
projectGtcYn?: boolean
@@ -88,7 +92,7 @@ interface BiddingInvitationData {
message?: string
}
-export function BiddingScheduleEditor({ biddingId }: BiddingScheduleEditorProps) {
+export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingScheduleEditorProps) {
const { data: session } = useSession()
const router = useRouter()
const { toast } = useToast()
@@ -110,7 +114,11 @@ export function BiddingScheduleEditor({ biddingId }: BiddingScheduleEditorProps)
const [isSubmitting, setIsSubmitting] = React.useState(false)
const [biddingInfo, setBiddingInfo] = React.useState<{ title: string; projectName?: string; status: string } | null>(null)
const [isBiddingInvitationDialogOpen, setIsBiddingInvitationDialogOpen] = React.useState(false)
+ const [isApprovalDialogOpen, setIsApprovalDialogOpen] = React.useState(false)
const [selectedVendors, setSelectedVendors] = React.useState<VendorContractRequirement[]>([])
+ const [approvalVariables, setApprovalVariables] = React.useState<Record<string, string>>({})
+ const [approvalTitle, setApprovalTitle] = React.useState('')
+ const [invitationData, setInvitationData] = React.useState<BiddingInvitationData | null>(null)
// 데이터 로딩
React.useEffect(() => {
@@ -197,11 +205,11 @@ export function BiddingScheduleEditor({ biddingId }: BiddingScheduleEditorProps)
return result.vendors.map((vendor): VendorContractRequirement => ({
vendorId: vendor.vendorId,
vendorName: vendor.vendorName,
- vendorCode: vendor.vendorCode ?? undefined,
+ vendorCode: vendor.vendorCode || undefined,
vendorCountry: vendor.vendorCountry,
- vendorEmail: vendor.vendorEmail ?? undefined,
- contactPerson: vendor.contactPerson ?? undefined,
- contactEmail: vendor.contactEmail ?? undefined,
+ vendorEmail: vendor.vendorEmail || undefined,
+ contactPerson: vendor.contactPerson || undefined,
+ contactEmail: vendor.contactEmail || undefined,
ndaYn: vendor.ndaYn,
generalGtcYn: vendor.generalGtcYn,
projectGtcYn: vendor.projectGtcYn,
@@ -219,7 +227,7 @@ export function BiddingScheduleEditor({ biddingId }: BiddingScheduleEditorProps)
}
}, [biddingId])
- // 본입찰 초대 다이얼로그가 열릴 때 선정된 업체들 조회
+ // 입찰 초대 다이얼로그가 열릴 때 선정된 업체들 조회
React.useEffect(() => {
if (isBiddingInvitationDialogOpen) {
getSelectedVendors().then(vendors => {
@@ -228,75 +236,96 @@ export function BiddingScheduleEditor({ biddingId }: BiddingScheduleEditorProps)
}
}, [isBiddingInvitationDialogOpen, getSelectedVendors])
- // 입찰 초대 발송 핸들러
- const handleBiddingInvitationSend = async (data: BiddingInvitationData) => {
- try {
- const userId = session?.user?.id?.toString() || '1'
-
- // 1. 기본계약 발송
- // sendBiddingBasicContracts에 필요한 형식으로 변환
- const vendorDataForContract = data.vendors.map(vendor => ({
- vendorId: vendor.vendorId,
- vendorName: vendor.vendorName,
- vendorCode: vendor.vendorCode || undefined,
- vendorCountry: vendor.vendorCountry,
- selectedMainEmail: vendor.selectedMainEmail,
- additionalEmails: vendor.additionalEmails,
- customEmails: vendor.customEmails,
- contractRequirements: {
- ndaYn: vendor.ndaYn || false,
- generalGtcYn: vendor.generalGtcYn || false,
- projectGtcYn: vendor.projectGtcYn || false,
- agreementYn: vendor.agreementYn || false,
- },
- biddingCompanyId: vendor.biddingCompanyId,
- biddingId: vendor.biddingId,
- hasExistingContracts: vendor.hasExistingContracts,
- }))
-
- const contractResult = await sendBiddingBasicContracts(
- biddingId,
- vendorDataForContract,
- data.generatedPdfs,
- data.message
- )
+ // 입찰공고 버튼 클릭 핸들러 - 입찰 초대 다이얼로그 열기
+ const handleBiddingInvitationClick = () => {
+ setIsBiddingInvitationDialogOpen(true)
+ }
- if (!contractResult.success) {
- const errorMessage = 'message' in contractResult
- ? contractResult.message
- : 'error' in contractResult
- ? contractResult.error
- : '기본계약 발송에 실패했습니다.'
+ // 결재 상신 핸들러 - 결재 완료 시 실제 입찰 등록 실행
+ const handleApprovalSubmit = async ({ approvers, title, attachments }: { approvers: string[], title: string, attachments?: File[] }) => {
+ try {
+ if (!session?.user?.id || !session.user.epId || !invitationData) {
toast({
- title: '기본계약 발송 실패',
- description: errorMessage,
+ title: '오류',
+ description: '필요한 정보가 없습니다.',
variant: 'destructive',
})
return
}
- // 2. 입찰 등록 진행
- const registerResult = await registerBidding(biddingId, userId)
+ // 결재 상신
+ const result = await requestBiddingInvitationWithApproval({
+ biddingId,
+ vendors: selectedVendors,
+ message: invitationData.message || '',
+ currentUser: {
+ id: session.user.id,
+ epId: session.user.epId,
+ email: session.user.email || undefined,
+ },
+ approvers,
+ })
- if (registerResult.success) {
+ if (result.status === 'pending_approval') {
toast({
- title: '본입찰 초대 완료',
- description: '기본계약 발송 및 본입찰 초대가 완료되었습니다.',
+ title: '입찰초대 결재 상신 완료',
+ description: `결재가 상신되었습니다. (ID: ${result.approvalId})`,
})
+ setIsApprovalDialogOpen(false)
setIsBiddingInvitationDialogOpen(false)
+ setInvitationData(null)
router.refresh()
- } else {
+ }
+ } catch (error) {
+ console.error('결재 상신 중 오류 발생:', error)
+ toast({
+ title: '오류',
+ description: '결재 상신 중 오류가 발생했습니다.',
+ variant: 'destructive',
+ })
+ }
+ }
+
+ // 입찰 초대 발송 핸들러 - 결재 준비 및 결재 다이얼로그 열기
+ const handleBiddingInvitationSend = async (data: BiddingInvitationData) => {
+ try {
+ if (!session?.user?.id || !session.user.epId) {
+ toast({
+ title: '오류',
+ description: '사용자 정보가 없습니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ // 선정된 업체들 조회
+ const vendors = await getSelectedVendors()
+ if (vendors.length === 0) {
toast({
title: '오류',
- description: 'error' in registerResult ? registerResult.error : '입찰 등록에 실패했습니다.',
+ description: '선정된 업체가 없습니다.',
variant: 'destructive',
})
+ return
}
+
+ // 결재 데이터 준비 (템플릿 변수, 제목 등)
+ const approvalData = await prepareBiddingApprovalData({
+ biddingId,
+ vendors,
+ message: data.message || '',
+ })
+
+ // 결재 준비 완료 - invitationData와 결재 데이터 저장 및 결재 다이얼로그 열기
+ setInvitationData(data)
+ setApprovalVariables(approvalData.variables)
+ setApprovalTitle(`입찰초대 - ${approvalData.bidding.title}`)
+ setIsApprovalDialogOpen(true)
} catch (error) {
- console.error('본입찰 초대 실패:', error)
+ console.error('결재 준비 중 오류 발생:', error)
toast({
title: '오류',
- description: '본입찰 초대에 실패했습니다.',
+ description: '결재 준비 중 오류가 발생했습니다.',
variant: 'destructive',
})
}
@@ -614,36 +643,38 @@ export function BiddingScheduleEditor({ biddingId }: BiddingScheduleEditorProps)
</Card>
{/* 액션 버튼 */}
- <div className="flex justify-between gap-4">
- <Button
- variant="default"
- onClick={() => setIsBiddingInvitationDialogOpen(true)}
- disabled={!biddingInfo || biddingInfo.status !== 'bidding_generated'}
- className="min-w-[120px]"
- >
- <Send className="w-4 h-4 mr-2" />
- 입찰공고
- </Button>
- <div className="flex gap-4">
+ {!readonly && (
+ <div className="flex justify-between gap-4">
<Button
- onClick={handleSave}
- disabled={isSubmitting || !biddingInfo || biddingInfo.status !== 'bidding_generated'}
+ variant="default"
+ onClick={handleBiddingInvitationClick}
+ disabled={!biddingInfo || biddingInfo.status !== 'bidding_generated'}
className="min-w-[120px]"
>
- {isSubmitting ? (
- <>
- <RefreshCw className="w-4 h-4 mr-2 animate-spin" />
- 저장 중...
- </>
- ) : (
- <>
- <Save className="w-4 h-4 mr-2" />
- 저장
- </>
- )}
+ <Send className="w-4 h-4 mr-2" />
+ 입찰공고
</Button>
+ <div className="flex gap-4">
+ <Button
+ onClick={handleSave}
+ disabled={isSubmitting || !biddingInfo || biddingInfo.status !== 'bidding_generated'}
+ className="min-w-[120px]"
+ >
+ {isSubmitting ? (
+ <>
+ <RefreshCw className="w-4 h-4 mr-2 animate-spin" />
+ 저장 중...
+ </>
+ ) : (
+ <>
+ <Save className="w-4 h-4 mr-2" />
+ 저장
+ </>
+ )}
+ </Button>
+ </div>
</div>
- </div>
+ )}
{/* 입찰 초대 다이얼로그 */}
{biddingInfo && (
@@ -656,6 +687,32 @@ export function BiddingScheduleEditor({ biddingId }: BiddingScheduleEditorProps)
onSend={handleBiddingInvitationSend}
/>
)}
+
+ {/* 입찰초대 결재 다이얼로그 */}
+ {session?.user && session.user.epId && biddingInfo && invitationData && Object.keys(approvalVariables).length > 0 && (
+ <ApprovalPreviewDialog
+ open={isApprovalDialogOpen}
+ onOpenChange={(open) => {
+ setIsApprovalDialogOpen(open)
+ if (!open) {
+ // 다이얼로그가 닫히면 결재 변수 초기화
+ setApprovalVariables({})
+ setApprovalTitle('')
+ setInvitationData(null)
+ }
+ }}
+ templateName="입찰초대 결재"
+ variables={approvalVariables}
+ title={approvalTitle}
+ currentUser={{
+ id: Number(session.user.id),
+ epId: session.user.epId,
+ name: session.user.name || undefined,
+ email: session.user.email || undefined
+ }}
+ onConfirm={handleApprovalSubmit}
+ />
+ )}
</div>
)
}
diff --git a/lib/approval/handlers-registry.ts b/lib/approval/handlers-registry.ts
index a92c5ce5..7aec3ae5 100644
--- a/lib/approval/handlers-registry.ts
+++ b/lib/approval/handlers-registry.ts
@@ -49,6 +49,21 @@ export async function initializeApprovalHandlers() {
// RFQ 발송 핸들러 등록 (결재 승인 후 실행될 함수 sendRfqWithApprovalInternal)
registerActionHandler('rfq_send_with_attachments', sendRfqWithApprovalInternal);
+ // 7. 기술영업 RFQ 발송 핸들러 (DRM 파일이 있는 경우)
+ const {
+ sendTechSalesRfqWithApprovalInternal,
+ resendTechSalesRfqWithDrmInternal
+ } = await import('@/lib/techsales-rfq/approval-handlers');
+ // 기술영업 RFQ 발송 핸들러 등록 (결재 승인 후 실행될 함수 sendTechSalesRfqWithApprovalInternal)
+ registerActionHandler('tech_sales_rfq_send_with_drm', sendTechSalesRfqWithApprovalInternal);
+ // 기술영업 RFQ 재발송 핸들러 등록 (결재 승인 후 실행될 함수 resendTechSalesRfqWithDrmInternal)
+ registerActionHandler('tech_sales_rfq_resend_with_drm', resendTechSalesRfqWithDrmInternal);
+
+ // 8. 입찰초대 핸들러
+ const { requestBiddingInvitationInternal } = await import('@/lib/bidding/handlers');
+ // 입찰초대 핸들러 등록 (결재 승인 후 실행될 함수 requestBiddingInvitationInternal)
+ registerActionHandler('bidding_invitation', requestBiddingInvitationInternal);
+
// ... 추가 핸들러 등록
console.log('[Approval Handlers] All handlers registered successfully');
diff --git a/lib/approval/templates/입찰초대 결재.html b/lib/approval/templates/입찰초대 결재.html
new file mode 100644
index 00000000..d22b9322
--- /dev/null
+++ b/lib/approval/templates/입찰초대 결재.html
@@ -0,0 +1,805 @@
+<div
+ style="
+ max-width: 1000px;
+ margin: 0 auto;
+ font-family: 'Malgun Gothic', 'Segoe UI', sans-serif;
+ font-size: 14px;
+ color: #333;
+ line-height: 1.5;
+ border: 1px solid #666; /* 전체적인 테두리 추가 */
+ "
+>
+ <!-- 1. 제목 -->
+ <table
+ style="
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 0px;
+ border-bottom: 2px solid #000;
+ "
+ >
+ <thead>
+ <tr>
+ <th
+ style="
+ background-color: #fff;
+ color: #000;
+ padding: 15px;
+ text-align: center;
+ font-size: 20px;
+ font-weight: 700;
+ "
+ >
+ 입찰 결재 요청서 ({{제목}})
+ </th>
+ </tr>
+ </thead>
+ </table>
+
+ <!-- 2. 입찰 기본 정보 및 개요 -->
+ <table
+ style="
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 15px;
+ "
+ >
+ <thead>
+ <tr>
+ <th
+ colspan="4"
+ style="
+ background-color: #333;
+ color: #fff;
+ padding: 10px;
+ text-align: left;
+ font-size: 15px;
+ font-weight: 600;
+ border-bottom: 1px solid #666;
+ "
+ >
+ ■ 입찰 기본 정보
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <!-- 1행 -->
+ <tr>
+ <td
+ style="
+ background-color: #f5f5f5;
+ padding: 8px 10px;
+ font-weight: 600;
+ width: 15%;
+ border: 1px solid #ccc;
+ text-align: center;
+ "
+ >
+ 입찰명
+ </td>
+ <td
+ style="
+ padding: 8px 10px;
+ width: 35%;
+ border: 1px solid #ccc;
+ "
+ >
+ {{입찰명}}
+ </td>
+ <td
+ style="
+ background-color: #f5f5f5;
+ padding: 8px 10px;
+ font-weight: 600;
+ width: 15%;
+ border: 1px solid #ccc;
+ text-align: center;
+ "
+ >
+ 입찰번호
+ </td>
+ <td
+ style="
+ padding: 8px 10px;
+ width: 35%;
+ border: 1px solid #ccc;
+ "
+ >
+ {{입찰번호}}
+ </td>
+ </tr>
+ <!-- 2행 -->
+ <tr>
+ <td
+ style="
+ background-color: #f5f5f5;
+ padding: 8px 10px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ text-align: center;
+ "
+ >
+ 낙찰업체수
+ </td>
+ <td
+ style="
+ padding: 8px 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{낙찰업체수}}
+ </td>
+ <td
+ style="
+ background-color: #f5f5f5;
+ padding: 8px 10px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ text-align: center;
+ "
+ >
+ 계약구분
+ </td>
+ <td
+ style="
+ padding: 8px 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{계약구분}}
+ </td>
+ </tr>
+ <!-- 3행 -->
+ <tr>
+ <td
+ style="
+ background-color: #f5f5f5;
+ padding: 8px 10px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ text-align: center;
+ "
+ >
+ P/R번호
+ </td>
+ <td
+ style="
+ padding: 8px 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{P/R번호}}
+ </td>
+ <td
+ style="
+ background-color: #f5f5f5;
+ padding: 8px 10px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ text-align: center;
+ "
+ >
+ 예산
+ </td>
+ <td
+ style="
+ padding: 8px 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{예산}}
+ </td>
+ </tr>
+ <!-- 4행 -->
+ <tr>
+ <td
+ style="
+ background-color: #f5f5f5;
+ padding: 8px 10px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ text-align: center;
+ "
+ >
+ 내정가
+ </td>
+ <td
+ style="
+ padding: 8px 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{내정가}}
+ </td>
+ <td
+ style="
+ background-color: #f5f5f5;
+ padding: 8px 10px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ text-align: center;
+ "
+ >
+ 입찰요청 시스템
+ </td>
+ <td
+ style="
+ padding: 8px 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ eVCP
+ </td>
+ </tr>
+ <!-- 5행 -->
+ <tr>
+ <td
+ style="
+ background-color: #f5f5f5;
+ padding: 8px 10px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ text-align: center;
+ "
+ >
+ 입찰담당자
+ </td>
+ <td
+ style="
+ padding: 8px 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{입찰담당자}}
+ </td>
+ <td
+ style="
+ background-color: #f5f5f5;
+ padding: 8px 10px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ text-align: center;
+ "
+ >
+ 내정가 산정 기준
+ </td>
+ <td
+ style="
+ padding: 8px 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{내정가_산정_기준}}
+ </td>
+ </tr>
+ <!-- 6행: 입찰 개요 -->
+ <tr>
+ <td
+ style="
+ background-color: #f5f5f5;
+ padding: 8px 10px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ text-align: center;
+ "
+ >
+ 입찰 개요
+ </td>
+ <td
+ colspan="3"
+ style="
+ padding: 8px 10px;
+ height: 80px;
+ border: 1px solid #ccc;
+ vertical-align: top;
+ "
+ >
+ {{입찰개요}}
+ </td>
+ </tr>
+ <!-- 7행: 입찰 공고문 -->
+ <tr>
+ <td
+ style="
+ background-color: #f5f5f5;
+ padding: 8px 10px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ text-align: center;
+ "
+ >
+ 입찰 공고문
+ </td>
+ <td
+ colspan="3"
+ style="
+ padding: 8px 10px;
+ height: 80px;
+ border: 1px solid #ccc;
+ vertical-align: top;
+ "
+ >
+ {{입찰공고문}}
+ </td>
+ </tr>
+ </tbody>
+ </table>
+
+ <!-- 3. 입찰 대상 협력사 -->
+ <table
+ style="
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 15px;
+ "
+ >
+ <thead>
+ <tr>
+ <th
+ colspan="6"
+ style="
+ background-color: #333;
+ color: #fff;
+ padding: 10px;
+ text-align: left;
+ font-size: 15px;
+ font-weight: 600;
+ border-bottom: 1px solid #666;
+ "
+ >
+ ■ 입찰 대상 협력사
+ </th>
+ </tr>
+ <tr>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 8px 10px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ width: 5%;
+ "
+ >
+ 순번
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 8px 10px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ width: 15%;
+ "
+ >
+ 협력사 코드
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 8px 10px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ width: 25%;
+ "
+ >
+ 협력사명
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 8px 10px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ width: 15%;
+ "
+ >
+ 담당자
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 8px 10px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ width: 25%;
+ "
+ >
+ 이메일
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 8px 10px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ width: 15%;
+ "
+ >
+ 전화번호
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <!-- 데이터 행 (반복 영역) -->
+ <tr>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">1</td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{협력사_코드_1}}</td>
+ <td style="padding: 8px 10px; border: 1px solid #ccc;">{{협력사명_1}}</td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{담당자_1}}</td>
+ <td style="padding: 8px 10px; border: 1px solid #ccc;">{{이메일_1}}</td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{전화번호_1}}</td>
+ </tr>
+ <!-- ... 추가 협력사 정보 행 ... -->
+ <tr>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">2</td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{협력사_코드_2}}</td>
+ <td style="padding: 8px 10px; border: 1px solid #ccc;">{{협력사명_2}}</td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{담당자_2}}</td>
+ <td style="padding: 8px 10px; border: 1px solid #ccc;">{{이메일_2}}</td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{전화번호_2}}</td>
+ </tr>
+ <!-- /데이터 행 -->
+ </tbody>
+ </table>
+
+ <!-- 4. 입찰 일정 계획 -->
+ <table
+ style="
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 15px;
+ "
+ >
+ <thead>
+ <tr>
+ <th
+ colspan="4"
+ style="
+ background-color: #333;
+ color: #fff;
+ padding: 10px;
+ text-align: left;
+ font-size: 15px;
+ font-weight: 600;
+ border-bottom: 1px solid #666;
+ "
+ >
+ ■ 입찰 일정 계획
+ </th>
+ </tr>
+ <tr>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 8px 10px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ width: 25%;
+ "
+ >
+ 구분
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 8px 10px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ width: 15%;
+ "
+ >
+ 실행 여부
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 8px 10px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ width: 30%;
+ "
+ >
+ 시작 예정 일시
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 8px 10px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ width: 30%;
+ "
+ >
+ 종료 예정 일시
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td
+ style="
+ padding: 8px 10px;
+ border: 1px solid #ccc;
+ text-align: center;
+ font-weight: 600;
+ "
+ >
+ 사양 설명회
+ </td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">
+ {{사양설명회_실행여부}}
+ </td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">
+ {{사양설명회_시작예정일시}}
+ </td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">
+ {{사양설명회_종료예정일시}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ padding: 8px 10px;
+ border: 1px solid #ccc;
+ text-align: center;
+ font-weight: 600;
+ "
+ >
+ 입찰서 제출 기간
+ </td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">
+ {{입찰서제출기간_실행여부}}
+ </td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">
+ {{입찰서제출기간_시작예정일시}}
+ </td>
+ <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">
+ {{입찰서제출기간_종료예정일시}}
+ </td>
+ </tr>
+ </tbody>
+ </table>
+
+ <!-- 5. 입찰 대상 자재 정보 -->
+ <table
+ style="
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 15px;
+ "
+ >
+ <thead>
+ <tr>
+ <th
+ colspan="15"
+ style="
+ background-color: #333;
+ color: #fff;
+ padding: 10px;
+ text-align: left;
+ font-size: 15px;
+ font-weight: 600;
+ border-bottom: 1px solid #666;
+ "
+ >
+ ■ 입찰 대상 자재 정보 (총 {{대상_자재_수}} 건)
+ </th>
+ </tr>
+ <tr style="font-size: 12px;">
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 6px 4px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 순번
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 6px 4px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 프로젝트
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 6px 4px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 자재그룹
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 6px 4px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 자재그룹명
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 6px 4px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 자재코드
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 6px 4px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 자재코드명
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 6px 4px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 수량
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 6px 4px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 구매단위
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 6px 4px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 내정단가
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 6px 4px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 수량단위
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 6px 4px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 총중량
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 6px 4px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 중량단위
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 6px 4px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 예산
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 6px 4px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 내정금액
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ padding: 6px 4px;
+ text-align: center;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 통화
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <!-- 데이터 행 (반복 영역) -->
+ <tr style="font-size: 12px;">
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">1</td>
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{프로젝트_1}}</td>
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{자재그룹_1}}</td>
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{자재그룹명_1}}</td>
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{자재코드_1}}</td>
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{자재코드명_1}}</td>
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{수량_1}}</td>
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{구매단위_1}}</td>
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{내정단가_1}}</td>
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{수량단위_1}}</td>
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{총중량_1}}</td>
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{중량단위_1}}</td>
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{예산_1}}</td>
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{내정금액_1}}</td>
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{통화_1}}</td>
+ </tr>
+ <tr style="font-size: 12px;">
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">2</td>
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{프로젝트_2}}</td>
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{자재그룹_2}}</td>
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{자재그룹명_2}}</td>
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{자재코드_2}}</td>
+ <td style="padding: 6px 4px; border: 1px solid #ccc;">{{자재코드명_2}}</td>
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{수량_2}}</td>
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{구매단위_2}}</td>
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{내정단가_2}}</td>
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{수량단위_2}}</td>
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{총중량_2}}</td>
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{중량단위_2}}</td>
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{예산_2}}</td>
+ <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{내정금액_2}}</td>
+ <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{통화_2}}</td>
+ </tr>
+ <!-- /데이터 행 -->
+ </tbody>
+ </table>
+</div> \ No newline at end of file
diff --git a/lib/bidding/approval-actions.ts b/lib/bidding/approval-actions.ts
new file mode 100644
index 00000000..3a82b08f
--- /dev/null
+++ b/lib/bidding/approval-actions.ts
@@ -0,0 +1,243 @@
+/**
+ * 입찰초대 관련 결재 서버 액션
+ *
+ * ✅ 베스트 프랙티스:
+ * - 'use server' 지시어 포함 (서버 액션)
+ * - UI에서 호출하는 진입점 함수들
+ * - withApproval()을 사용하여 결재 프로세스 시작
+ * - 템플릿 변수 준비 및 입력 검증
+ * - 핸들러(Internal)에는 최소 데이터만 전달
+ */
+
+'use server';
+
+import { ApprovalSubmissionSaga } from '@/lib/approval';
+import { mapBiddingInvitationToTemplateVariables } from './handlers';
+import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils';
+
+/**
+ * 입찰초대 결재를 거쳐 입찰등록을 처리하는 서버 액션
+ *
+ * ✅ 사용법 (클라이언트 컴포넌트에서):
+ * ```typescript
+ * const result = await requestBiddingInvitationWithApproval({
+ * biddingId: 123,
+ * vendors: [...],
+ * message: "입찰 초대 메시지",
+ * currentUser: { id: 1, epId: 'EP001', email: 'user@example.com' },
+ * approvers: ['EP002', 'EP003']
+ * });
+ *
+ * if (result.status === 'pending_approval') {
+ * toast.success(`입찰초대 결재가 상신되었습니다. (ID: ${result.approvalId})`);
+ * }
+ * ```
+ */
+/**
+ * 입찰초대 결재를 위한 공통 데이터 준비 헬퍼 함수
+ */
+export async function prepareBiddingApprovalData(data: {
+ biddingId: number;
+ vendors: Array<{
+ vendorId: number;
+ vendorName: string;
+ vendorCode?: string | null;
+ vendorCountry?: string;
+ vendorEmail?: string | null;
+ contactPerson?: string | null;
+ contactEmail?: string | null;
+ ndaYn?: boolean;
+ generalGtcYn?: boolean;
+ projectGtcYn?: boolean;
+ agreementYn?: boolean;
+ biddingCompanyId: number;
+ biddingId: number;
+ }>;
+ message?: string;
+}) {
+ // 1. 입찰 정보 조회 (템플릿 변수용)
+ debugLog('[BiddingInvitationApproval] 입찰 정보 조회 시작');
+ const { default: db } = await import('@/db/db');
+ const { biddings, prItemsForBidding } = 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,
+ submissionStartDate: biddings.submissionStartDate,
+ submissionEndDate: biddings.submissionEndDate,
+ hasSpecificationMeeting: biddings.hasSpecificationMeeting,
+ isUrgent: biddings.isUrgent,
+ remarks: biddings.remarks,
+ targetPrice: biddings.targetPrice,
+ })
+ .from(biddings)
+ .where(eq(biddings.id, data.biddingId))
+ .limit(1);
+
+ if (biddingInfo.length === 0) {
+ debugError('[BiddingInvitationApproval] 입찰 정보를 찾을 수 없음');
+ throw new Error('입찰 정보를 찾을 수 없습니다');
+ }
+
+ const bidding = biddingInfo[0];
+
+ // 입찰 대상 자재 정보 조회
+ const biddingItemsInfo = await db
+ .select({
+ id: prItemsForBidding.id,
+ projectName: prItemsForBidding.projectInfo,
+ materialGroup: prItemsForBidding.materialGroupNumber,
+ materialGroupName: prItemsForBidding.materialGroupInfo,
+ materialCode: prItemsForBidding.materialNumber,
+ materialCodeName: prItemsForBidding.materialInfo,
+ quantity: prItemsForBidding.quantity,
+ purchasingUnit: prItemsForBidding.purchaseUnit,
+ targetUnitPrice: prItemsForBidding.targetUnitPrice,
+ quantityUnit: prItemsForBidding.quantityUnit,
+ totalWeight: prItemsForBidding.totalWeight,
+ weightUnit: prItemsForBidding.weightUnit,
+ budget: prItemsForBidding.budgetAmount,
+ targetAmount: prItemsForBidding.targetAmount,
+ currency: prItemsForBidding.targetCurrency,
+ })
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.biddingId, data.biddingId));
+
+ debugLog('[BiddingInvitationApproval] 입찰 정보 조회 완료', {
+ biddingId: bidding.id,
+ title: bidding.title,
+ itemCount: biddingItemsInfo.length,
+ });
+
+ // 2. 템플릿 변수 매핑
+ debugLog('[BiddingInvitationApproval] 템플릿 변수 매핑 시작');
+ const requestedAt = new Date();
+ const { mapBiddingInvitationToTemplateVariables } = await import('./handlers');
+ const variables = await mapBiddingInvitationToTemplateVariables({
+ bidding,
+ biddingItems: biddingItemsInfo,
+ vendors: data.vendors,
+ message: data.message,
+ requestedAt,
+ });
+ debugLog('[BiddingInvitationApproval] 템플릿 변수 매핑 완료', {
+ variableKeys: Object.keys(variables),
+ });
+
+ return {
+ bidding,
+ biddingItems: biddingItemsInfo,
+ variables,
+ };
+}
+
+export async function requestBiddingInvitationWithApproval(data: {
+ biddingId: number;
+ vendors: Array<{
+ vendorId: number;
+ vendorName: string;
+ vendorCode?: string | null;
+ vendorCountry?: string;
+ vendorEmail?: string | null;
+ contactPerson?: string | null;
+ contactEmail?: string | null;
+ ndaYn?: boolean;
+ generalGtcYn?: boolean;
+ projectGtcYn?: boolean;
+ agreementYn?: boolean;
+ biddingCompanyId: number;
+ biddingId: number;
+ }>;
+ message?: string;
+ currentUser: { id: number; epId: string | null; email?: string };
+ approvers?: string[]; // Knox EP ID 배열 (결재선)
+}) {
+ debugLog('[BiddingInvitationApproval] 입찰초대 결재 서버 액션 시작', {
+ biddingId: data.biddingId,
+ vendorCount: data.vendors.length,
+ userId: data.currentUser.id,
+ hasEpId: !!data.currentUser.epId,
+ });
+
+ // 1. 입력 검증
+ if (!data.currentUser.epId) {
+ debugError('[BiddingInvitationApproval] Knox EP ID 없음');
+ throw new Error('Knox EP ID가 필요합니다');
+ }
+
+ if (data.vendors.length === 0) {
+ debugError('[BiddingInvitationApproval] 선정된 업체 없음');
+ throw new Error('입찰 초대할 업체를 선택해주세요');
+ }
+
+ // 2. 입찰 상태를 결재 진행중으로 변경
+ debugLog('[BiddingInvitationApproval] 입찰 상태 변경 시작');
+ const { default: db } = await import('@/db/db');
+ const { biddings, biddingCompanies, prItemsForBidding } = await import('@/db/schema');
+ const { eq } = await import('drizzle-orm');
+
+ await db
+ .update(biddings)
+ .set({
+ status: 'approval_pending', // 결재 진행중 상태
+ updatedBy: data.currentUser.epId,
+ updatedAt: new Date()
+ })
+ .where(eq(biddings.id, data.biddingId));
+
+ debugLog('[BiddingInvitationApproval] 입찰 상태 변경 완료', {
+ biddingId: data.biddingId,
+ newStatus: 'approval_pending'
+ });
+
+ // 3. 결재 데이터 준비
+ const { bidding, biddingItems: biddingItemsInfo, variables } = await prepareBiddingApprovalData({
+ biddingId: data.biddingId,
+ vendors: data.vendors,
+ message: data.message,
+ });
+
+ // 4. 결재 워크플로우 시작 (Saga 패턴)
+ debugLog('[BiddingInvitationApproval] ApprovalSubmissionSaga 생성');
+ const saga = new ApprovalSubmissionSaga(
+ // actionType: 핸들러를 찾을 때 사용할 키
+ 'bidding_invitation',
+
+ // actionPayload: 결재 승인 후 핸들러에 전달될 데이터 (최소 데이터만)
+ {
+ biddingId: data.biddingId,
+ vendors: data.vendors,
+ message: data.message,
+ currentUserId: data.currentUser.id, // ✅ 결재 승인 후 핸들러 실행 시 필요
+ },
+
+ // approvalConfig: 결재 상신 정보 (템플릿 포함)
+ {
+ title: `입찰초대 - ${bidding.title}`,
+ description: `${bidding.title} 입찰 초대 결재`,
+ templateName: '입찰초대 결재', // 한국어 템플릿명
+ variables, // 치환할 변수들
+ approvers: data.approvers,
+ currentUser: data.currentUser,
+ }
+ );
+
+ debugLog('[BiddingInvitationApproval] Saga 실행 시작');
+ const result = await saga.execute();
+
+ debugSuccess('[BiddingInvitationApproval] 입찰초대 결재 워크플로우 완료', {
+ approvalId: result.approvalId,
+ pendingActionId: result.pendingActionId,
+ status: result.status,
+ });
+
+ return result;
+}
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
index 6f35405d..80e50119 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
@@ -76,17 +76,21 @@ export function getBiddingDetailVendorColumns({
cell: ({ row }) => {
const hasAmount = row.original.quotationAmount && Number(row.original.quotationAmount) > 0
return (
- <div className="text-right font-mono">
+ <div className="text-right font-mono font-bold">
{hasAmount ? (
- <button
- onClick={() => onViewQuotationHistory?.(row.original)}
- className="text-primary hover:text-primary/80 hover:underline cursor-pointer"
- title="품목별 견적 상세 보기"
- >
- {Number(row.original.quotationAmount).toLocaleString()} {row.original.currency}
- </button>
+ <>
+ <button
+ onClick={() => onViewQuotationHistory?.(row.original)}
+ className="text-primary hover:text-primary/80 hover:underline cursor-pointer"
+ title="품목별 견적 상세 보기"
+ >
+ <span className="border-b-2 border-primary">
+ {Number(row.original.quotationAmount).toLocaleString()} {row.original.currency}
+ </span>
+ </button>
+ </>
) : (
- <span className="text-muted-foreground">- {row.original.currency}</span>
+ <span className="text-muted-foreground border-b-2 border-dashed font-bold">- {row.original.currency}</span>
)}
</div>
)
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
index c1677ae7..f2c23de9 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
@@ -4,6 +4,7 @@ import * as React from "react"
import { useRouter } from "next/navigation"
import { useTransition } from "react"
import { Button } from "@/components/ui/button"
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign, RotateCw } from "lucide-react"
import { registerBidding, markAsDisposal, createRebidding } from "@/lib/bidding/detail/service"
import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from "@/lib/bidding/pre-quote/service"
@@ -37,6 +38,7 @@ export function BiddingDetailVendorToolbarActions({
const [isPricesDialogOpen, setIsPricesDialogOpen] = React.useState(false)
const [isBiddingInvitationDialogOpen, setIsBiddingInvitationDialogOpen] = React.useState(false)
const [selectedVendors, setSelectedVendors] = React.useState<any[]>([])
+ const [isRoundIncreaseDialogOpen, setIsRoundIncreaseDialogOpen] = React.useState(false)
// 본입찰 초대 다이얼로그가 열릴 때 선정된 업체들 조회
React.useEffect(() => {
@@ -176,6 +178,32 @@ export function BiddingDetailVendorToolbarActions({
})
}
+ const handleRoundIncreaseWithNavigation = () => {
+ startTransition(async () => {
+ const result = await increaseRoundOrRebid(bidding.id, userId, 'round_increase')
+
+ if (result.success) {
+ toast({
+ title: "성공",
+ description: result.message,
+ })
+ // 새로 생성된 입찰의 상세 페이지로 이동
+ if (result.biddingId) {
+ router.push(`/evcp/bid/${result.biddingId}`)
+ } else {
+ router.push(`/evcp/bid`)
+ }
+ onSuccess()
+ } else {
+ toast({
+ title: "오류",
+ description: result.error || "차수증가 중 오류가 발생했습니다.",
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
return (
<>
<div className="flex items-center gap-2">
@@ -185,7 +213,7 @@ export function BiddingDetailVendorToolbarActions({
<Button
variant="outline"
size="sm"
- onClick={handleRoundIncrease}
+ onClick={() => setIsRoundIncreaseDialogOpen(true)}
disabled={isPending}
>
<RotateCw className="mr-2 h-4 w-4" />
@@ -250,6 +278,35 @@ export function BiddingDetailVendorToolbarActions({
onSuccess={onSuccess}
/>
+ {/* 차수증가 확인 다이얼로그 */}
+ <Dialog open={isRoundIncreaseDialogOpen} onOpenChange={setIsRoundIncreaseDialogOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>차수증가 확인</DialogTitle>
+ <DialogDescription>
+ 입찰을 차수증가 처리하시겠습니까? 차수증가 후 새로운 입찰 화면으로 이동합니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => setIsRoundIncreaseDialogOpen(false)}
+ >
+ 아니오
+ </Button>
+ <Button
+ onClick={async () => {
+ setIsRoundIncreaseDialogOpen(false)
+ await handleRoundIncreaseWithNavigation()
+ }}
+ disabled={isPending}
+ >
+ 예
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
</>
)
}
diff --git a/lib/bidding/failure/biddings-failure-table.tsx b/lib/bidding/failure/biddings-failure-table.tsx
index c80021ea..43020322 100644
--- a/lib/bidding/failure/biddings-failure-table.tsx
+++ b/lib/bidding/failure/biddings-failure-table.tsx
@@ -20,6 +20,7 @@ import {
} from "@/db/schema"
import { BiddingsClosureDialog } from "./biddings-closure-dialog"
import { Button } from "@/components/ui/button"
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { FileX, RefreshCw, Undo2 } from "lucide-react"
import { bidClosureAction, cancelDisposalAction } from "@/lib/bidding/actions"
import { increaseRoundOrRebid } from "@/lib/bidding/service"
@@ -85,6 +86,8 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) {
const [isCompact, setIsCompact] = React.useState<boolean>(false)
const [biddingClosureDialogOpen, setBiddingClosureDialogOpen] = React.useState(false)
const [selectedBidding, setSelectedBidding] = React.useState<BiddingFailureItem | null>(null)
+ const [isRebidDialogOpen, setIsRebidDialogOpen] = React.useState(false)
+ const [selectedBiddingForRebid, setSelectedBiddingForRebid] = React.useState<BiddingFailureItem | null>(null)
const { toast } = useToast()
const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingFailureItem> | null>(null)
@@ -103,9 +106,14 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) {
setSelectedBidding(rowAction.row.original)
switch (rowAction.type) {
+ case "view":
+ // 상세 페이지로 이동
+ router.push(`/evcp/bid/${rowAction.row.original.id}/info`)
+ break
case "rebid":
- // 재입찰
- handleRebid(rowAction.row.original)
+ // 재입찰 팝업 열기
+ setSelectedBiddingForRebid(rowAction.row.original)
+ setIsRebidDialogOpen(true)
break
case "closure":
// 폐찰
@@ -199,7 +207,7 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) {
setSelectedBidding(null)
}, [])
- const handleRebid = React.useCallback(async (bidding: BiddingFailureItem) => {
+ const handleRebidWithNavigation = React.useCallback(async (bidding: BiddingFailureItem) => {
if (!session?.user?.id) {
toast({
title: "오류",
@@ -215,10 +223,14 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) {
if (result.success) {
toast({
title: "성공",
- description: result.message,
+ description: (result as any).message || "재입찰이 완료되었습니다.",
})
- // 페이지 새로고침
- router.refresh()
+ // 새로 생성된 입찰의 상세 페이지로 이동
+ if ((result as any).biddingId) {
+ router.push(`/evcp/bid/${(result as any).biddingId}`)
+ } else {
+ router.refresh()
+ }
} else {
toast({
title: "오류",
@@ -352,7 +364,8 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) {
return
}
const bidding = selectedRows[0].original
- handleRebid(bidding)
+ setSelectedBiddingForRebid(bidding)
+ setIsRebidDialogOpen(true)
}}
disabled={table.getFilteredSelectedRowModel().rows.length !== 1 ||
(table.getFilteredSelectedRowModel().rows.length === 1 &&
@@ -407,7 +420,7 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) {
{/* 폐찰 다이얼로그 */}
{selectedBidding && session?.user?.id && (
- <BidClosureDialog
+ <BiddingsClosureDialog
open={biddingClosureDialogOpen}
onOpenChange={handleBiddingClosureDialogClose}
bidding={selectedBidding}
@@ -418,6 +431,40 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) {
}}
/>
)}
+
+ {/* 재입찰 확인 다이얼로그 */}
+ <Dialog open={isRebidDialogOpen} onOpenChange={setIsRebidDialogOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>재입찰 확인</DialogTitle>
+ <DialogDescription>
+ 입찰을 재입찰 처리하시겠습니까? 재입찰 후 새로운 입찰 화면으로 이동합니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => {
+ setIsRebidDialogOpen(false)
+ setSelectedBiddingForRebid(null)
+ }}
+ >
+ 아니오
+ </Button>
+ <Button
+ onClick={async () => {
+ if (selectedBiddingForRebid) {
+ await handleRebidWithNavigation(selectedBiddingForRebid)
+ }
+ setIsRebidDialogOpen(false)
+ setSelectedBiddingForRebid(null)
+ }}
+ >
+ 예
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
</>
)
}
diff --git a/lib/bidding/handlers.ts b/lib/bidding/handlers.ts
new file mode 100644
index 00000000..fc2951d4
--- /dev/null
+++ b/lib/bidding/handlers.ts
@@ -0,0 +1,283 @@
+/**
+ * 입찰초대 관련 결재 액션 핸들러
+ *
+ * ✅ 베스트 프랙티스:
+ * - 'use server' 지시어 없음 (순수 비즈니스 로직만)
+ * - 결재 승인 후 실행될 최소한의 데이터만 처리
+ * - DB 조작 및 실제 비즈니스 로직만 포함
+ */
+
+import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils';
+
+/**
+ * 입찰초대 핸들러 (결재 승인 후 실행됨)
+ *
+ * ✅ Internal 함수: 결재 워크플로우에서 자동 호출됨 (직접 호출 금지)
+ *
+ * @param payload - withApproval()에서 전달한 actionPayload (최소 데이터만)
+ */
+export async function requestBiddingInvitationInternal(payload: {
+ biddingId: number;
+ vendors: Array<{
+ vendorId: number;
+ vendorName: string;
+ vendorCode?: string | null;
+ vendorCountry?: string;
+ vendorEmail?: string | null;
+ contactPerson?: string | null;
+ contactEmail?: string | null;
+ ndaYn?: boolean;
+ generalGtcYn?: boolean;
+ projectGtcYn?: boolean;
+ agreementYn?: boolean;
+ biddingCompanyId: number;
+ biddingId: number;
+ }>;
+ message?: string;
+ currentUserId: number; // ✅ 결재 상신한 사용자 ID
+}) {
+ debugLog('[BiddingInvitationHandler] 입찰초대 핸들러 시작', {
+ biddingId: payload.biddingId,
+ vendorCount: payload.vendors.length,
+ currentUserId: payload.currentUserId,
+ });
+
+ // ✅ userId 검증: 핸들러에서 userId가 없으면 잘못된 상황 (예외 처리)
+ if (!payload.currentUserId || payload.currentUserId <= 0) {
+ const errorMessage = 'currentUserId가 없습니다. actionPayload에 currentUserId가 포함되지 않았습니다.';
+ debugError('[BiddingInvitationHandler]', errorMessage);
+ throw new Error(errorMessage);
+ }
+
+ try {
+ // 1. 기본계약 발송
+ const { sendBiddingBasicContracts } = await import('@/lib/bidding/pre-quote/service');
+
+ const vendorDataForContract = payload.vendors.map(vendor => ({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ vendorCode: vendor.vendorCode || undefined,
+ vendorCountry: vendor.vendorCountry,
+ selectedMainEmail: vendor.vendorEmail || '',
+ additionalEmails: [],
+ customEmails: [],
+ contractRequirements: {
+ ndaYn: vendor.ndaYn || false,
+ generalGtcYn: vendor.generalGtcYn || false,
+ projectGtcYn: vendor.projectGtcYn || false,
+ agreementYn: vendor.agreementYn || false,
+ },
+ biddingCompanyId: vendor.biddingCompanyId,
+ biddingId: vendor.biddingId,
+ hasExistingContracts: false, // 결재 후처리에서는 기존 계약 확인 생략
+ }));
+
+ const contractResult = await sendBiddingBasicContracts(
+ payload.biddingId,
+ vendorDataForContract,
+ [], // generatedPdfs - 결재 템플릿이므로 PDF는 빈 배열
+ payload.message
+ );
+
+ if (!contractResult.success) {
+ debugError('[BiddingInvitationHandler] 기본계약 발송 실패', contractResult.error);
+ throw new Error(contractResult.error || '기본계약 발송에 실패했습니다.');
+ }
+
+ debugLog('[BiddingInvitationHandler] 기본계약 발송 완료');
+
+ // 2. 입찰 등록 진행 (상태를 bidding_opened로 변경)
+ const { registerBidding } = await import('@/lib/bidding/detail/service');
+
+ const registerResult = await registerBidding(payload.biddingId, payload.currentUserId.toString());
+
+ if (!registerResult.success) {
+ debugError('[BiddingInvitationHandler] 입찰 등록 실패', registerResult.error);
+ throw new Error(registerResult.error || '입찰 등록에 실패했습니다.');
+ }
+
+ debugSuccess('[BiddingInvitationHandler] 입찰초대 완료', {
+ biddingId: payload.biddingId,
+ vendorCount: payload.vendors.length,
+ message: registerResult.message,
+ });
+
+ return {
+ success: true,
+ biddingId: payload.biddingId,
+ vendorCount: payload.vendors.length,
+ message: `기본계약 발송 및 본입찰 초대가 완료되었습니다.`,
+ };
+ } catch (error) {
+ debugError('[BiddingInvitationHandler] 입찰초대 중 에러', error);
+ throw error;
+ }
+}
+
+/**
+ * 입찰초대 데이터를 결재 템플릿 변수로 매핑
+ *
+ * @param payload - 입찰초대 데이터
+ * @returns 템플릿 변수 객체 (Record<string, string>)
+ */
+export async function mapBiddingInvitationToTemplateVariables(payload: {
+ bidding: {
+ id: number;
+ title: string;
+ biddingNumber: string;
+ projectName?: string;
+ itemName?: string;
+ biddingType: string;
+ bidPicName?: string;
+ supplyPicName?: string;
+ submissionStartDate?: Date;
+ submissionEndDate?: Date;
+ hasSpecificationMeeting?: boolean;
+ isUrgent?: boolean;
+ remarks?: string;
+ targetPrice?: number;
+ };
+ biddingItems: Array<{
+ id: number;
+ projectName?: string;
+ materialGroup?: string;
+ materialGroupName?: string;
+ materialCode?: string;
+ materialCodeName?: string;
+ quantity?: number;
+ purchasingUnit?: string;
+ targetUnitPrice?: number;
+ quantityUnit?: string;
+ totalWeight?: number;
+ weightUnit?: string;
+ budget?: number;
+ targetAmount?: number;
+ currency?: string;
+ }>;
+ vendors: Array<{
+ vendorId: number;
+ vendorName: string;
+ vendorCode?: string | null;
+ vendorCountry?: string;
+ vendorEmail?: string | null;
+ contactPerson?: string | null;
+ contactEmail?: string | null;
+ }>;
+ message?: string;
+ requestedAt: Date;
+}): Promise<Record<string, string>> {
+ const { bidding, biddingItems, vendors, message, requestedAt } = payload;
+
+ // 제목
+ const title = bidding.title || '입찰';
+
+ // 입찰명
+ const biddingTitle = bidding.title || '';
+
+ // 입찰번호
+ const biddingNumber = bidding.biddingNumber || '';
+
+ // 낙찰업체수
+ const winnerCount = '1'; // 기본값, 실제로는 bidding 설정에서 가져와야 함
+
+ // 계약구분
+ const contractType = bidding.biddingType || '';
+
+ // P/R번호 - bidding 테이블에 없으므로 빈 값
+ const prNumber = '';
+
+ // 예산
+ const budget = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : '';
+
+ // 내정가
+ const targetPrice = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : '';
+
+ // 입찰요청 시스템
+ const requestSystem = 'eVCP';
+
+ // 입찰담당자
+ const biddingManager = bidding.bidPicName || bidding.supplyPicName || '';
+
+ // 내정가 산정 기준 - bidding 테이블에 없으므로 빈 값
+ const targetPriceBasis = '';
+
+ // 입찰 개요
+ const biddingOverview = bidding.itemName || message || '';
+
+ // 입찰 공고문
+ const biddingNotice = message || '';
+
+ // 입찰담당자 (중복이지만 템플릿에 맞춤)
+ const biddingManagerDup = bidding.bidPicName || bidding.supplyPicName || '';
+
+ // 협력사 정보들
+ const vendorVariables: Record<string, string> = {};
+ vendors.forEach((vendor, index) => {
+ const num = index + 1;
+ vendorVariables[`협력사_코드_${num}`] = vendor.vendorCode || '';
+ vendorVariables[`협력사명_${num}`] = vendor.vendorName || '';
+ vendorVariables[`담당자_${num}`] = vendor.contactPerson || '';
+ vendorVariables[`이메일_${num}`] = vendor.contactEmail || vendor.vendorEmail || '';
+ vendorVariables[`전화번호_${num}`] = ''; // 연락처 정보가 없으므로 빈 값
+ });
+
+ // 사양설명회 정보
+ const hasSpecMeeting = bidding.hasSpecificationMeeting ? '예' : '아니오';
+ const specMeetingStart = bidding.submissionStartDate ? bidding.submissionStartDate.toLocaleString('ko-KR') : '';
+ const specMeetingEnd = bidding.submissionEndDate ? bidding.submissionEndDate.toLocaleString('ko-KR') : '';
+ const specMeetingStartDup = specMeetingStart;
+ const specMeetingEndDup = specMeetingEnd;
+
+ // 입찰서제출기간 정보
+ const submissionPeriodExecution = '예'; // 입찰 기간이 있으므로 예
+ const submissionPeriodStart = bidding.submissionStartDate ? bidding.submissionStartDate.toLocaleString('ko-KR') : '';
+ const submissionPeriodEnd = bidding.submissionEndDate ? bidding.submissionEndDate.toLocaleString('ko-KR') : '';
+
+ // 대상 자재 수
+ const targetMaterialCount = biddingItems.length.toString();
+
+ // 자재 정보들
+ const materialVariables: Record<string, string> = {};
+ biddingItems.forEach((item, index) => {
+ const num = index + 1;
+ materialVariables[`프로젝트_${num}`] = item.projectName || '';
+ materialVariables[`자재그룹_${num}`] = item.materialGroup || '';
+ materialVariables[`자재그룹명_${num}`] = item.materialGroupName || '';
+ materialVariables[`자재코드_${num}`] = item.materialCode || '';
+ materialVariables[`자재코드명_${num}`] = item.materialCodeName || '';
+ materialVariables[`수량_${num}`] = item.quantity ? item.quantity.toLocaleString() : '';
+ materialVariables[`구매단위_${num}`] = item.purchasingUnit || '';
+ materialVariables[`내정단가_${num}`] = item.targetUnitPrice ? item.targetUnitPrice.toLocaleString() : '';
+ materialVariables[`수량단위_${num}`] = item.quantityUnit || '';
+ materialVariables[`총중량_${num}`] = item.totalWeight ? item.totalWeight.toLocaleString() : '';
+ materialVariables[`중량단위_${num}`] = item.weightUnit || '';
+ materialVariables[`예산_${num}`] = item.budget ? item.budget.toLocaleString() : '';
+ materialVariables[`내정금액_${num}`] = item.targetAmount ? item.targetAmount.toLocaleString() : '';
+ materialVariables[`통화_${num}`] = item.currency || '';
+ });
+
+ return {
+ 제목: title,
+ 입찰명: biddingTitle,
+ 입찰번호: biddingNumber,
+ 낙찰업체수: winnerCount,
+ 계약구분: contractType,
+ 'P/R번호': prNumber,
+ 예산: budget,
+ 내정가: targetPrice,
+ 입찰요청_시스템: requestSystem,
+ 입찰담당자: biddingManager,
+ 내정가_산정_기준: targetPriceBasis,
+ 입찰개요: biddingOverview,
+ 입찰공고문: biddingNotice,
+ ...vendorVariables,
+ 사양설명회_실행여부: hasSpecMeeting,
+ 사양설명회_시작예정일시: specMeetingStart,
+ 사양설명회_종료예정일시: specMeetingEnd,
+ 입찰서제출기간_실행여부: submissionPeriodExecution,
+ 입찰서제출기간_시작예정일시: submissionPeriodStart,
+ 입찰서제출기간_종료예정일시: submissionPeriodEnd,
+ 대상_자재_수: targetMaterialCount,
+ ...materialVariables,
+ };
+}
diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx
index 92b2fe42..48c32302 100644
--- a/lib/bidding/list/biddings-table-columns.tsx
+++ b/lib/bidding/list/biddings-table-columns.tsx
@@ -96,11 +96,11 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
cell: ({ row }) => (
<div className="font-mono text-sm">
{row.original.biddingNumber}
- {row.original.revision > 0 && (
+ {/* {row.original.revision > 0 && (
<span className="ml-1 text-xs text-muted-foreground">
Rev.{row.original.revision}
</span>
- )}
+ )} */}
</div>
),
size: 120,
@@ -137,16 +137,15 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰명" />,
cell: ({ row }) => (
<div className="truncate max-w-[200px]" title={row.original.title}>
- {/* <Button
+ <Button
variant="link"
- className="p-0 h-auto text-left justify-start font-bold underline"
+ className="p-0 h-auto font-bold underline"
onClick={() => setRowAction({ row, type: "view" })}
>
<div className="whitespace-pre-line">
{row.original.title}
</div>
- </Button> */}
- {row.original.title}
+ </Button>
</div>
),
size: 200,
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index cbeeb24a..68ae016e 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -3177,22 +3177,28 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
}
}
- // 2. 입찰번호 파싱 및 차수 증가
- const currentBiddingNumber = existingBidding.biddingNumber
-
- // 현재 입찰번호에서 차수 추출 (예: E00025-02 -> 02)
- const match = currentBiddingNumber.match(/-(\d+)$/)
- let currentRound = match ? parseInt(match[1]) : 1
-
+ // 2. 입찰번호 생성 (타입에 따라 다르게 처리)
let newBiddingNumber: string
- if (currentRound >= 3) {
- // -03 이상이면 새로운 번호 생성
+ if (type === 'rebidding') {
+ // 재입찰: 완전히 새로운 입찰번호 생성
newBiddingNumber = await generateBiddingNumber(existingBidding.contractType, userId, tx)
} else {
- // -02까지는 차수만 증가
- const baseNumber = currentBiddingNumber.split('-')[0]
- newBiddingNumber = `${baseNumber}-${String(currentRound + 1).padStart(2, '0')}`
+ // 차수증가: 기존 입찰번호에서 차수 증가
+ const currentBiddingNumber = existingBidding.biddingNumber
+
+ // 현재 입찰번호에서 차수 추출 (예: E00025-02 -> 02)
+ const match = currentBiddingNumber.match(/-(\d+)$/)
+ let currentRound = match ? parseInt(match[1]) : 1
+
+ if (currentRound >= 3) {
+ // -03 이상이면 새로운 번호 생성
+ newBiddingNumber = await generateBiddingNumber(existingBidding.contractType, userId, tx)
+ } else {
+ // -02까지는 차수만 증가
+ const baseNumber = currentBiddingNumber.split('-')[0]
+ newBiddingNumber = `${baseNumber}-${String(currentRound + 1).padStart(2, '0')}`
+ }
}
// 3. 새로운 입찰 생성 (기존 정보 복제)
@@ -3200,7 +3206,7 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
.insert(biddings)
.values({
biddingNumber: newBiddingNumber,
- originalBiddingNumber: null, // 원입찰번호는 단순 정보이므로 null
+ originalBiddingNumber: existingBidding.biddingNumber, // 원입찰번호 설정
revision: 0,
biddingSourceType: existingBidding.biddingSourceType,
@@ -3419,26 +3425,36 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
})
}
}
- // 8. 입찰공고문 정보 복제 (있는 경우)
- if (existingBidding.hasBiddingNotice) {
- const [existingNotice] = await tx
- .select()
- .from(biddingNoticeTemplate)
- .where(eq(biddingNoticeTemplate.biddingId, biddingId))
- .limit(1)
- if (existingNotice) {
- await tx
- .insert(biddingNoticeTemplate)
- .values({
- biddingId: newBidding.id,
- title: existingNotice.title,
- content: existingNotice.content,
- })
- }
+ // 9. 기존 입찰 상태 변경 (타입에 따라 다르게 설정)
+ await tx
+ .update(biddings)
+ .set({
+ status: type === 'round_increase' ? 'round_increase' : 'rebidding',
+ updatedBy: userName,
+ updatedAt: new Date(),
+ })
+ .where(eq(biddings.id, biddingId))
+
+ // 10. 입찰공고문 정보 복제 (있는 경우)
+ const [existingNotice] = await tx
+ .select()
+ .from(biddingNoticeTemplate)
+ .where(eq(biddingNoticeTemplate.biddingId, biddingId))
+ .limit(1)
+
+ if (existingNotice) {
+ await tx
+ .insert(biddingNoticeTemplate)
+ .values({
+ biddingId: newBidding.id,
+ title: existingNotice.title,
+ content: existingNotice.content,
+ })
}
revalidatePath('/bidding')
+ revalidatePath(`/bidding/${biddingId}`) // 기존 입찰 페이지도 갱신
revalidatePath(`/bidding/${newBidding.id}`)
return {