summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-18 20:50:39 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-18 20:50:39 +0900
commit8070b9428918d9ae5e03ad17eef6d403d2a7aaba (patch)
treeae61ea52ae3dae5f2d084a16986b9733a4140047
parent8dba4d53d763bf9e1302a84a8fc65727425219ee (diff)
parentbe5d5ab488ae875e7c56306403aba923e1784021 (diff)
Merge branch 'dujinkim' of https://github.com/DTS-Development/SHI_EVCP into dujinkim
-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--components/common/legal/sslvw-pur-inq-req-dialog.tsx333
-rw-r--r--components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx6
-rw-r--r--components/drm/drmUtils.ts60
-rw-r--r--components/login/login-form.tsx19
-rw-r--r--components/login/partner-auth-form.tsx5
-rw-r--r--components/pq-input/pq-input-tabs.tsx136
-rw-r--r--components/vendor-regular-registrations/document-status-dialog.tsx852
-rw-r--r--components/vendor-regular-registrations/registration-request-dialog.tsx17
-rw-r--r--db/schema/bidding.ts1
-rw-r--r--db/schema/techSales.ts4
-rw-r--r--lib/approval/handlers-registry.ts15
-rw-r--r--lib/approval/templates/입찰초대 결재.html805
-rw-r--r--lib/basic-contract/sslvw-service.ts82
-rw-r--r--lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx135
-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
-rw-r--r--lib/mail/templates/tech-sales-rfq-invite-ko.hbs27
-rw-r--r--lib/mail/templates/vendor-invitation.hbs2
-rw-r--r--lib/rfq-last/shared/rfq-items-dialog.tsx4
-rw-r--r--lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx2
-rw-r--r--lib/rfq-last/vendor/rfq-vendor-table.tsx10
-rw-r--r--lib/techsales-rfq/approval-actions.ts244
-rw-r--r--lib/techsales-rfq/approval-handlers.ts313
-rw-r--r--lib/techsales-rfq/service.ts145
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx127
-rw-r--r--lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet-copy-1118.tsx710
-rw-r--r--lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx6
-rw-r--r--lib/vendor-regular-registrations/repository.ts614
44 files changed, 4757 insertions, 1502 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/components/common/legal/sslvw-pur-inq-req-dialog.tsx b/components/common/legal/sslvw-pur-inq-req-dialog.tsx
new file mode 100644
index 00000000..438b6582
--- /dev/null
+++ b/components/common/legal/sslvw-pur-inq-req-dialog.tsx
@@ -0,0 +1,333 @@
+"use client"
+
+import * as React from "react"
+import { Loader, Database, Check } from "lucide-react"
+import { toast } from "sonner"
+import {
+ useReactTable,
+ getCoreRowModel,
+ getPaginationRowModel,
+ getFilteredRowModel,
+ ColumnDef,
+ flexRender,
+} from "@tanstack/react-table"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { Checkbox } from "@/components/ui/checkbox"
+import { ScrollArea } from "@/components/ui/scroll-area"
+
+import { getSSLVWPurInqReqData } from "@/lib/basic-contract/sslvw-service"
+import { SSLVWPurInqReq } from "@/lib/basic-contract/sslvw-service"
+
+interface SSLVWPurInqReqDialogProps {
+ onConfirm?: (selectedRows: SSLVWPurInqReq[]) => void
+}
+
+export function SSLVWPurInqReqDialog({ onConfirm }: SSLVWPurInqReqDialogProps) {
+ const [open, setOpen] = React.useState(false)
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [data, setData] = React.useState<SSLVWPurInqReq[]>([])
+ const [error, setError] = React.useState<string | null>(null)
+ const [rowSelection, setRowSelection] = React.useState<Record<string, boolean>>({})
+
+ const loadData = async () => {
+ setIsLoading(true)
+ setError(null)
+ try {
+ const result = await getSSLVWPurInqReqData()
+ if (result.success) {
+ setData(result.data)
+ if (result.isUsingFallback) {
+ toast.info("테스트 데이터를 표시합니다.")
+ }
+ } else {
+ setError(result.error || "데이터 로딩 실패")
+ toast.error(result.error || "데이터 로딩 실패")
+ }
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류"
+ setError(errorMessage)
+ toast.error(errorMessage)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ React.useEffect(() => {
+ if (open) {
+ loadData()
+ } else {
+ // 다이얼로그 닫힐 때 데이터 초기화
+ setData([])
+ setError(null)
+ setRowSelection({})
+ }
+ }, [open])
+
+ // 테이블 컬럼 정의 (동적 생성)
+ const columns = React.useMemo<ColumnDef<SSLVWPurInqReq>[]>(() => {
+ if (data.length === 0) return []
+
+ const dataKeys = Object.keys(data[0])
+
+ return [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="모든 행 선택"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="행 선택"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+ ...dataKeys.map((key) => ({
+ accessorKey: key,
+ header: key,
+ cell: ({ getValue }: any) => {
+ const value = getValue()
+ return value !== null && value !== undefined ? String(value) : ""
+ },
+ })),
+ ]
+ }, [data])
+
+ // 테이블 인스턴스 생성
+ const table = useReactTable({
+ data,
+ columns,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ onRowSelectionChange: setRowSelection,
+ state: {
+ rowSelection,
+ },
+ })
+
+ // 선택된 행들 가져오기
+ const selectedRows = table.getFilteredSelectedRowModel().rows.map(row => row.original)
+
+ // 확인 버튼 핸들러
+ const handleConfirm = () => {
+ if (selectedRows.length === 0) {
+ toast.error("행을 선택해주세요.")
+ return
+ }
+
+ if (onConfirm) {
+ onConfirm(selectedRows)
+ toast.success(`${selectedRows.length}개의 행을 선택했습니다.`)
+ } else {
+ // 임시로 선택된 데이터 콘솔 출력
+ console.log("선택된 행들:", selectedRows)
+ toast.success(`${selectedRows.length}개의 행이 선택되었습니다. (콘솔 확인)`)
+ }
+
+ setOpen(false)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Database className="mr-2 size-4" aria-hidden="true" />
+ 법무검토 요청 데이터 조회
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-7xl max-h-[90vh] flex flex-col">
+ <DialogHeader>
+ <DialogTitle>법무검토 요청 데이터</DialogTitle>
+ <DialogDescription>
+ 법무검토 요청 데이터를 조회합니다.
+ {data.length > 0 && ` (${data.length}건, ${selectedRows.length}개 선택됨)`}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex-1 overflow-hidden flex flex-col">
+ {isLoading ? (
+ <div className="flex items-center justify-center flex-1">
+ <Loader className="mr-2 size-6 animate-spin" />
+ <span>데이터 로딩 중...</span>
+ </div>
+ ) : error ? (
+ <div className="flex items-center justify-center flex-1 text-red-500">
+ <span>오류: {error}</span>
+ </div>
+ ) : data.length === 0 ? (
+ <div className="flex items-center justify-center flex-1 text-muted-foreground">
+ <span>데이터가 없습니다.</span>
+ </div>
+ ) : (
+ <>
+ <ScrollArea className="flex-1">
+ <Table>
+ <TableHeader>
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id}>
+ {headerGroup.headers.map((header) => (
+ <TableHead key={header.id} className="font-medium">
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ </TableHead>
+ ))}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+ <TableRow
+ key={row.id}
+ data-state={row.getIsSelected() && "selected"}
+ >
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id} className="text-sm">
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
+ ))
+ ) : (
+ <TableRow>
+ <TableCell
+ colSpan={columns.length}
+ className="h-24 text-center"
+ >
+ 데이터가 없습니다.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </ScrollArea>
+
+ {/* 페이지네이션 컨트롤 */}
+ <div className="flex items-center justify-between px-2 py-4 border-t">
+ <div className="flex-1 text-sm text-muted-foreground">
+ {table.getFilteredSelectedRowModel().rows.length}개 행 선택됨
+ </div>
+ <div className="flex items-center space-x-6 lg:space-x-8">
+ <div className="flex items-center space-x-2">
+ <p className="text-sm font-medium">페이지당 행 수</p>
+ <select
+ value={table.getState().pagination.pageSize}
+ onChange={(e) => {
+ table.setPageSize(Number(e.target.value))
+ }}
+ className="h-8 w-[70px] rounded border border-input bg-transparent px-3 py-1 text-sm ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2"
+ >
+ {[10, 20, 30, 40, 50].map((pageSize) => (
+ <option key={pageSize} value={pageSize}>
+ {pageSize}
+ </option>
+ ))}
+ </select>
+ </div>
+ <div className="flex w-[100px] items-center justify-center text-sm font-medium">
+ {table.getState().pagination.pageIndex + 1} /{" "}
+ {table.getPageCount()}
+ </div>
+ <div className="flex items-center space-x-2">
+ <Button
+ variant="outline"
+ className="h-8 w-8 p-0"
+ onClick={() => table.setPageIndex(0)}
+ disabled={!table.getCanPreviousPage()}
+ >
+ <span className="sr-only">첫 페이지로</span>
+ {"<<"}
+ </Button>
+ <Button
+ variant="outline"
+ className="h-8 w-8 p-0"
+ onClick={() => table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ <span className="sr-only">이전 페이지</span>
+ {"<"}
+ </Button>
+ <Button
+ variant="outline"
+ className="h-8 w-8 p-0"
+ onClick={() => table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ <span className="sr-only">다음 페이지</span>
+ {">"}
+ </Button>
+ <Button
+ variant="outline"
+ className="h-8 w-8 p-0"
+ onClick={() => table.setPageIndex(table.getPageCount() - 1)}
+ disabled={!table.getCanNextPage()}
+ >
+ <span className="sr-only">마지막 페이지로</span>
+ {">>"}
+ </Button>
+ </div>
+ </div>
+ </div>
+ </>
+ )}
+ </div>
+
+ <DialogFooter className="gap-2">
+ <Button variant="outline" onClick={() => setOpen(false)}>
+ 닫기
+ </Button>
+ {/* <Button onClick={loadData} disabled={isLoading} variant="outline">
+ {isLoading ? (
+ <>
+ <Loader className="mr-2 size-4 animate-spin" />
+ 로딩 중...
+ </>
+ ) : (
+ "새로고침"
+ )}
+ </Button> */}
+ <Button onClick={handleConfirm} disabled={selectedRows.length === 0}>
+ <Check className="mr-2 size-4" />
+ 확인 ({selectedRows.length})
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx
index dab65780..90d4975b 100644
--- a/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx
+++ b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx
@@ -70,13 +70,13 @@ export interface ProcurementItemSelectorDialogSingleProps {
* ```
*/
export function ProcurementItemSelectorDialogSingle({
- triggerLabel = "품목 선택",
+ triggerLabel = "1회성 품목 선택",
triggerVariant = "outline",
triggerSize = "default",
selectedProcurementItem = null,
onProcurementItemSelect,
- title = "품목 선택",
- description = "품목을 검색하고 선택해주세요.",
+ title = "1회성 품목 선택",
+ description = "1회성 품목을 검색하고 선택해주세요.",
showConfirmButtons = false,
}: ProcurementItemSelectorDialogSingleProps) {
const [open, setOpen] = useState(false);
diff --git a/components/drm/drmUtils.ts b/components/drm/drmUtils.ts
index 4ba63090..00cc83b2 100644
--- a/components/drm/drmUtils.ts
+++ b/components/drm/drmUtils.ts
@@ -4,6 +4,7 @@
* 2. 복호화 서버액션을 제공한다.
*
* decryptWithServerAction(file): 서버액션을 사용하여 복호화한 파일을 응답하는 함수
+ * isDRMFile(file): 파일이 DRM 암호화되어 있는지 검출하는 함수
*
*/
@@ -126,4 +127,63 @@ export async function decryptBufferWithServerAction(
return fileBuffer;
}
+}
+
+/**
+ * 파일이 DRM 암호화되어 있는지 검출하는 함수
+ * DRM 복호화 시도를 통해 DRM 여부를 판단합니다.
+ *
+ * @param file - 검출할 파일
+ * @returns DRM 파일이면 true, 일반 파일이면 false
+ */
+export async function isDRMFile(file: File): Promise<boolean> {
+ try {
+ const formData = new FormData();
+ formData.append('file', file);
+
+ const backendUrl = "http://localhost:6543/api/drm-proxy/decrypt";
+
+ console.log(`[DRM Check] 파일 DRM 검출 시도: ${file.name} (크기: ${file.size} bytes)`);
+
+ // DRM 복호화 시도
+ const response = await fetch(backendUrl, {
+ method: "POST",
+ body: formData,
+ });
+
+ // 응답이 성공적이면 DRM 파일로 판단
+ if (response.ok) {
+ const decryptedData = await response.arrayBuffer();
+
+ // 복호화된 데이터 크기가 원본과 다르거나, 복호화 성공 로그가 있으면 DRM 파일
+ const isDrmFile = decryptedData.byteLength !== file.size ||
+ decryptedData.byteLength > 0;
+
+ console.log(`[DRM Check] DRM 파일로 판정: ${file.name} (원본: ${file.size} bytes, 복호화: ${decryptedData.byteLength} bytes)`);
+
+ return isDrmFile;
+ } else {
+ // 응답이 실패하면 일반 파일로 판단
+ const errorText = await response.text().catch(() => '응답 텍스트를 가져올 수 없음');
+ console.log(`[DRM Check] 일반 파일로 판정: ${file.name} (응답 상태: ${response.status}, 오류: ${errorText})`);
+ return false;
+ }
+ } catch (error) {
+ // 네트워크 오류나 서버 연결 실패 시 일반 파일로 판단
+ const errorMessage = error instanceof Error
+ ? `${error.name}: ${error.message}`
+ : String(error);
+
+ console.log(`[DRM Check] 일반 파일로 판정 (오류 발생): ${file.name}`, {
+ error: errorMessage,
+ remark: `
+ [DRM 검출 실패 케이스]
+ 1. DRM 백엔드 서버가 없는 경우 - 일반 파일로 판정
+ 2. DRM 중앙 서버와 통신 불가한 경우 - 일반 파일로 판정
+ 3. 네트워크 오류 - 일반 파일로 판정
+ `
+ });
+
+ return false;
+ }
} \ No newline at end of file
diff --git a/components/login/login-form.tsx b/components/login/login-form.tsx
index 5fe6ab51..ee84add2 100644
--- a/components/login/login-form.tsx
+++ b/components/login/login-form.tsx
@@ -950,15 +950,16 @@ export function LoginForm() {
{/* Terms - MFA 화면에서는 숨김 */}
{!showMfaForm && (
- <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary">
- {t("agreement")}{" "}
- <Link
- href={`/${lng}/privacy`}
- className="underline underline-offset-4 hover:text-primary"
- >
- {t("privacyPolicy")}
- </Link>
- </div>
+ // 1118 구매 파워유저 요구사항에 따라 삭제
+ // <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary">
+ // {t("agreement")}{" "}
+ // <Link
+ // href={`/${lng}/privacy`}
+ // className="underline underline-offset-4 hover:text-primary"
+ // >
+ // {t("privacyPolicy")}
+ // </Link>
+ // </div>
)}
</div>
</div>
diff --git a/components/login/partner-auth-form.tsx b/components/login/partner-auth-form.tsx
index 22917997..ebd2219c 100644
--- a/components/login/partner-auth-form.tsx
+++ b/components/login/partner-auth-form.tsx
@@ -300,13 +300,14 @@ export function CompanyAuthForm({ className, ...props }: React.HTMLAttributes<HT
</form>
</div>
<p className="px-8 text-center text-sm text-muted-foreground">
- {t("agreement")}{" "}
+ {/* 1118 구매 파워유저 요구사항에 따라 삭제 */}
+ {/* {t("agreement")}{" "}
<Link
href={`/${lng}/privacy`} // 개인정보처리방침만 남김
className="underline underline-offset-4 hover:text-primary"
>
{t("privacyPolicy")}
- </Link>
+ </Link> */}
{/* {t("privacyAgreement")}. */}
</p>
</div>
diff --git a/components/pq-input/pq-input-tabs.tsx b/components/pq-input/pq-input-tabs.tsx
index f0d44d04..df911d5e 100644
--- a/components/pq-input/pq-input-tabs.tsx
+++ b/components/pq-input/pq-input-tabs.tsx
@@ -276,6 +276,55 @@ export function PQInputTabs({
setAllSaved(allItemsSaved)
}, [form.watch()])
+ // ----------------------------------------------------------------------
+ // C-1) Calculate item counts for display
+ // ----------------------------------------------------------------------
+
+ // ----------------------------------------------------------------------
+ // C-2) Tab color mapping for better visual distinction
+ // ----------------------------------------------------------------------
+ const getTabColorClasses = (groupName: string) => {
+ switch (groupName.toLowerCase()) {
+ case 'general':
+ return {
+ tab: 'data-[state=active]:bg-blue-50 data-[state=active]:text-blue-700 data-[state=active]:border-blue-200',
+ badge: 'bg-blue-100 text-blue-800 border-blue-200'
+ }
+ case 'hsg':
+ return {
+ tab: 'data-[state=active]:bg-green-50 data-[state=active]:text-green-700 data-[state=active]:border-green-200',
+ badge: 'bg-green-100 text-green-800 border-green-200'
+ }
+ case 'qms':
+ return {
+ tab: 'data-[state=active]:bg-orange-50 data-[state=active]:text-orange-700 data-[state=active]:border-orange-200',
+ badge: 'bg-orange-100 text-orange-800 border-orange-200'
+ }
+ case 'warranty':
+ return {
+ tab: 'data-[state=active]:bg-red-50 data-[state=active]:text-red-700 data-[state=active]:border-red-200',
+ badge: 'bg-red-100 text-red-800 border-red-200'
+ }
+ default:
+ return {
+ tab: 'data-[state=active]:bg-gray-50 data-[state=active]:text-gray-700 data-[state=active]:border-gray-200',
+ badge: 'bg-gray-100 text-gray-800 border-gray-200'
+ }
+ }
+ }
+ const getItemCounts = () => {
+ const values = form.getValues()
+ const totalItems = values.answers.length
+ const savedItems = values.answers.filter(
+ (answer) => answer.saved && (answer.answer || answer.uploadedFiles.length > 0)
+ ).length
+ const notSavedItems = totalItems - savedItems
+
+ return { totalItems, savedItems, notSavedItems }
+ }
+
+ const { totalItems, savedItems, notSavedItems } = getItemCounts()
+
// Helper to find the array index by criteriaId
const getAnswerIndex = (criteriaId: number): number => {
return form.getValues().answers.findIndex((a) => a.criteriaId === criteriaId)
@@ -677,6 +726,30 @@ export function PQInputTabs({
<Tabs defaultValue={data[0]?.groupName || ""} className="w-full">
{/* Top Controls - Sticky Header */}
<div className="sticky top-0 z-10 bg-background border-b border-border mb-4 pb-4">
+ {/* Item Count Display */}
+ <div className="mb-3 flex items-center gap-6 text-sm">
+ <div className="flex items-center gap-4">
+ <span className="font-medium">총 항목:</span>
+ <Badge variant="outline" className="text-xs">
+ {totalItems}
+ </Badge>
+ </div>
+ <div className="flex items-center gap-2">
+ <CheckCircle2 className="h-4 w-4 text-green-600" />
+ <span className="text-green-600 font-medium">Saved:</span>
+ <Badge variant="outline" className="bg-green-50 text-green-700 border-green-200 text-xs">
+ {savedItems}
+ </Badge>
+ </div>
+ <div className="flex items-center gap-2">
+ <AlertTriangle className="h-4 w-4 text-amber-600" />
+ <span className="text-amber-600 font-medium">Not Saved:</span>
+ <Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-200 text-xs">
+ {notSavedItems}
+ </Badge>
+ </div>
+ </div>
+
{/* Filter Controls */}
<div className="mb-3 flex items-center gap-4">
<span className="text-sm font-medium">필터:</span>
@@ -702,8 +775,11 @@ export function PQInputTabs({
checked={filterOptions.showSaved}
onCheckedChange={(checked) => {
const newOptions = { ...filterOptions, showSaved: !!checked };
- if (!checked && !filterOptions.showAll && !filterOptions.showNotSaved) {
- // 최소 하나는 체크되어 있어야 함
+ // Save 항목이나 Not Save 항목을 선택하면 전체 항목 자동 해제
+ if (checked) {
+ newOptions.showAll = false;
+ } else if (!filterOptions.showNotSaved && !filterOptions.showAll) {
+ // 최소 하나는 체크되어 있어야 함 - 모두 해제되면 전체 항목 체크
newOptions.showAll = true;
}
setFilterOptions(newOptions);
@@ -717,8 +793,11 @@ export function PQInputTabs({
checked={filterOptions.showNotSaved}
onCheckedChange={(checked) => {
const newOptions = { ...filterOptions, showNotSaved: !!checked };
- if (!checked && !filterOptions.showAll && !filterOptions.showSaved) {
- // 최소 하나는 체크되어 있어야 함
+ // Save 항목이나 Not Save 항목을 선택하면 전체 항목 자동 해제
+ if (checked) {
+ newOptions.showAll = false;
+ } else if (!filterOptions.showSaved && !filterOptions.showAll) {
+ // 최소 하나는 체크되어 있어야 함 - 모두 해제되면 전체 항목 체크
newOptions.showAll = true;
}
setFilterOptions(newOptions);
@@ -731,27 +810,30 @@ export function PQInputTabs({
<div className="flex justify-between items-center">
<TabsList className="grid grid-cols-4">
- {data.map((group) => (
- <TabsTrigger
- key={group.groupName}
- value={group.groupName}
- className="truncate"
- >
- <div className="flex items-center gap-2">
- {/* Mobile: truncated version */}
- <span className="block sm:hidden">
- {group.groupName.length > 5
- ? group.groupName.slice(0, 5) + "..."
- : group.groupName}
- </span>
- {/* Desktop: full text */}
- <span className="hidden sm:block">{group.groupName}</span>
- <span className="inline-flex items-center justify-center h-5 min-w-5 px-1 rounded-full bg-muted text-xs font-medium">
- {group.items.length}
- </span>
- </div>
- </TabsTrigger>
- ))}
+ {data.map((group) => {
+ const colorClasses = getTabColorClasses(group.groupName)
+ return (
+ <TabsTrigger
+ key={group.groupName}
+ value={group.groupName}
+ className={`truncate ${colorClasses.tab}`}
+ >
+ <div className="flex items-center gap-2">
+ {/* Mobile: truncated version */}
+ <span className="block sm:hidden">
+ {group.groupName.length > 5
+ ? group.groupName.slice(0, 5) + "..."
+ : group.groupName}
+ </span>
+ {/* Desktop: full text */}
+ <span className="hidden sm:block">{group.groupName}</span>
+ <span className={`inline-flex items-center justify-center h-5 min-w-5 px-1 rounded-full text-xs font-medium ${colorClasses.badge}`}>
+ {group.items.length}
+ </span>
+ </div>
+ </TabsTrigger>
+ )
+ })}
</TabsList>
<div className="flex gap-2">
@@ -849,13 +931,13 @@ export function PQInputTabs({
{/* Save Status & Button */}
<div className="flex items-center gap-2">
{!isSaved && canSave && (
- <span className="text-amber-600 text-xs flex items-center">
+ <span className="text-amber-600 text-sm font-medium flex items-center">
<AlertTriangle className="h-4 w-4 mr-1" />
Not Saved
</span>
)}
{isSaved && (
- <span className="text-green-600 text-xs flex items-center">
+ <span className="text-green-600 text-sm font-medium flex items-center">
<CheckCircle2 className="h-4 w-4 mr-1" />
Saved
</span>
diff --git a/components/vendor-regular-registrations/document-status-dialog.tsx b/components/vendor-regular-registrations/document-status-dialog.tsx
index 5efee64e..02da19bf 100644
--- a/components/vendor-regular-registrations/document-status-dialog.tsx
+++ b/components/vendor-regular-registrations/document-status-dialog.tsx
@@ -1,426 +1,426 @@
-"use client";
-
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import { FileText, Download, CheckCircle, XCircle, Clock, RefreshCw } from "lucide-react";
-import { toast } from "sonner";
-
-import type { VendorRegularRegistration } from "@/config/vendorRegularRegistrationsColumnsConfig";
-import {
- documentStatusColumns,
-} from "@/config/vendorRegularRegistrationsColumnsConfig";
-
-interface DocumentStatusDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- registration: VendorRegularRegistration | null;
- onRefresh?: () => void;
- isVendorUser?: boolean;
-}
-
-const StatusIcon = ({ status }: { status: string | boolean }) => {
- if (typeof status === "boolean") {
- return status ? (
- <CheckCircle className="w-4 h-4 text-green-600" />
- ) : (
- <XCircle className="w-4 h-4 text-red-500" />
- );
- }
-
- switch (status) {
- case "completed":
- return <CheckCircle className="w-4 h-4 text-green-600" />;
- case "reviewing":
- return <Clock className="w-4 h-4 text-yellow-600" />;
- case "not_submitted":
- default:
- return <XCircle className="w-4 h-4 text-red-500" />;
- }
-};
-
-const StatusBadge = ({ status }: { status: string | boolean }) => {
- if (typeof status === "boolean") {
- return (
- <Badge variant={status ? "default" : "destructive"}>
- {status ? "제출완료" : "미제출"}
- </Badge>
- );
- }
-
- const statusConfig = {
- completed: { label: "완료", variant: "default" as const },
- reviewing: { label: "검토중", variant: "secondary" as const },
- not_submitted: { label: "미제출", variant: "destructive" as const },
- };
-
- const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.not_submitted;
-
- return <Badge variant={config.variant}>{config.label}</Badge>;
-};
-
-export function DocumentStatusDialog({
- open,
- onOpenChange,
- registration,
- onRefresh,
- isVendorUser = false,
-}: DocumentStatusDialogProps) {
- if (!registration) return null;
-
- // 파일 다운로드 핸들러
- const handleFileDownload = async (docKey: string, fileIndex: number = 0) => {
- try {
- console.log(`🔍 파일 다운로드 시도:`, {
- docKey,
- fileIndex,
- allDocumentFiles: registration.documentFiles,
- registrationId: registration.id,
- registrationKeys: Object.keys(registration),
- fullRegistration: registration
- });
- //isvendoruser인 경우는 실사 결과 파일 다운로드 불가능
- if (isVendorUser && docKey === "auditResult") {
- toast.error("실사 결과 파일은 다운로드할 수 없습니다.");
- return;
- }
-
- // documentFiles가 없는 경우 처리
- if (!registration.documentFiles) {
- console.error(`❌ documentFiles가 없음:`, {
- registration,
- hasDocumentFiles: !!registration.documentFiles,
- registrationKeys: Object.keys(registration)
- });
- toast.error("문서 파일 정보를 찾을 수 없습니다. 페이지를 새로고침 해주세요.");
- return;
- }
-
- const files = registration.documentFiles[docKey as keyof typeof registration.documentFiles];
- console.log(`📂 ${docKey} 파일 목록:`, files);
-
- if (!files || files.length === 0) {
- console.warn(`❌ ${docKey}에 파일이 없음:`, { files, length: files?.length });
- toast.error("다운로드할 파일이 없습니다.");
- return;
- }
-
- const file = files[fileIndex];
- console.log(`📄 선택된 파일 (index ${fileIndex}):`, file);
-
- if (!file) {
- console.warn(`❌ 파일 인덱스 ${fileIndex}에 파일이 없음`);
- toast.error("파일을 찾을 수 없습니다.");
- return;
- }
-
- // 파일 객체의 모든 속성 확인
- console.log(`🔍 파일 객체 전체 속성:`, Object.keys(file));
- console.log(`🔍 파일 상세 정보:`, {
- filePath: file.filePath,
- path: file.path,
- originalFileName: file.originalFileName,
- fileName: file.fileName,
- name: file.name,
- fullObject: file
- });
-
- // filePath와 fileName 추출
- const filePath = file.filePath || file.path;
- const fileName = file.originalFileName || file.fileName || file.name;
-
- console.log(`📝 추출된 파일 정보:`, { filePath, fileName });
-
- if (!filePath || !fileName) {
- console.error(`❌ 파일 정보 누락:`, {
- filePath,
- fileName,
- fileObject: file,
- availableKeys: Object.keys(file)
- });
- toast.error("파일 정보가 올바르지 않습니다.");
- return;
- }
-
- console.log(`📥 파일 다운로드 시작:`, { filePath, fileName, docKey });
-
- // downloadFile 함수를 동적으로 import하여 파일 다운로드
- const { downloadFile } = await import('@/lib/file-download');
- const result = await downloadFile(filePath, fileName, {
- showToast: true,
- onError: (error: any) => {
- console.error("파일 다운로드 오류:", error);
- toast.error(`파일 다운로드 실패: ${error}`);
- },
- onSuccess: (fileName: string, fileSize?: number) => {
- console.log(`✅ 파일 다운로드 성공:`, { fileName, fileSize });
- }
- });
-
- if (!result.success) {
- console.error("파일 다운로드 실패:", result.error);
- }
- } catch (error) {
- console.error("파일 다운로드 중 오류 발생:", error);
- toast.error("파일 다운로드 중 오류가 발생했습니다.");
- }
- };
-
- // 기본계약 파일 다운로드 핸들러
- const handleContractDownload = async (contractIndex: number) => {
- try {
- if (!registration.basicContracts || registration.basicContracts.length === 0) {
- toast.error("다운로드할 계약이 없습니다.");
- return;
- }
-
- const contract = registration.basicContracts[contractIndex];
- if (!contract) {
- toast.error("계약을 찾을 수 없습니다.");
- return;
- }
-
- if (contract.status !== "VENDOR_SIGNED" && contract.status !== "COMPLETED") {
- toast.error("완료된 계약서만 다운로드할 수 있습니다.");
- return;
- }
-
- // 서명된 계약서 파일 정보 확인
- const filePath = contract.filePath;
- const fileName = contract.fileName || `${contract.templateName || '기본계약'}_${registration.companyName}.docx`;
-
- if (!filePath) {
- toast.error("계약서 파일을 찾을 수 없습니다.");
- return;
- }
-
- console.log(`📥 기본계약 다운로드 시작:`, {
- filePath,
- fileName,
- templateName: contract.templateName
- });
-
- // downloadFile 함수를 사용하여 서명된 계약서 다운로드
- const { downloadFile } = await import('@/lib/file-download');
- const result = await downloadFile(filePath, fileName, {
- showToast: true,
- onError: (error: any) => {
- console.error("기본계약 다운로드 오류:", error);
- toast.error(`기본계약 다운로드 실패: ${error}`);
- },
- onSuccess: (fileName: string, fileSize?: number) => {
- console.log(`✅ 기본계약 다운로드 성공:`, { fileName, fileSize });
- }
- });
-
- if (!result.success) {
- console.error("기본계약 다운로드 실패:", result.error);
- }
- } catch (error) {
- console.error("기본계약 다운로드 중 오류 발생:", error);
- toast.error("기본계약 다운로드 중 오류가 발생했습니다.");
- }
- };
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-4xl max-h-[80vh]">
- <DialogHeader className="sticky top-0 z-10 border-b pr-4 pb-4 mb-4">
- <DialogTitle className="flex items-center justify-between">
- <div className="flex items-center gap-2">
- <FileText className="w-5 h-5" />
- 문서/자료 접수 현황 - {registration.companyName}
- </div>
- {onRefresh && (
- <Button
- variant="outline"
- size="sm"
- onClick={onRefresh}
- className="flex items-center gap-2"
- >
- <RefreshCw className="w-4 h-4" />
- 새로고침
- </Button>
- )}
- </DialogTitle>
- </DialogHeader>
-
- <div className="overflow-y-auto max-h-[calc(80vh-120px)] space-y-6">
- {/* 기본 정보 */}
- <div className="grid grid-cols-2 gap-4 p-4 bg-gray-50 rounded-lg">
- <div>
- <span className="text-sm font-medium text-gray-600">업체명:</span>
- <span className="ml-2">{registration.companyName}</span>
- </div>
- <div>
- <span className="text-sm font-medium text-gray-600">사업자번호:</span>
- <span className="ml-2">{registration.businessNumber}</span>
- </div>
- <div>
- <span className="text-sm font-medium text-gray-600">대표자:</span>
- <span className="ml-2">{registration.representative || "-"}</span>
- </div>
- <div>
- <span className="text-sm font-medium text-gray-600">현재상태:</span>
- <Badge className="ml-2">{registration.status}</Badge>
- </div>
- </div>
-
- {/* 문서 제출 현황 */}
- <div>
- <div className="flex items-center justify-between mb-4">
- <h3 className="text-lg font-semibold">문서 제출 현황</h3>
- </div>
- <div className="border rounded-lg">
- <div className="grid grid-cols-4 gap-4 p-4 bg-gray-50 font-medium text-sm">
- <div>문서유형</div>
- <div>상태</div>
- <div>제출일자</div>
- <div>액션</div>
- </div>
- {documentStatusColumns.map((doc) => {
- const isSubmitted = registration.documentSubmissions?.[
- doc.key as keyof typeof registration.documentSubmissions
- ] as boolean || false;
-
- // 내자인 경우 통장사본은 표시하지 않음
- const isForeign = registration.country !== 'KR';
- if (doc.key === 'bankCopy' && !isForeign) {
- return null;
- }
-
- return (
- <div
- key={doc.key}
- className="grid grid-cols-4 gap-4 p-4 border-t items-center"
- >
- <div className="flex items-center gap-2">
- <StatusIcon status={isSubmitted} />
- {doc.label}
- {doc.key === 'bankCopy' && isForeign && (
- <span className="text-xs text-blue-600">(외자 필수)</span>
- )}
- </div>
- <div>
- <StatusBadge status={isSubmitted} />
- </div>
- <div className="text-sm text-gray-600">
- {isSubmitted ? "2024.01.01" : "-"}
- </div>
- <div>
- {isSubmitted && (
- <Button
- size="sm"
- variant="outline"
- onClick={() => handleFileDownload(doc.key)}
- >
- <Download className="w-4 h-4 mr-1" />
- 다운로드
- </Button>
- )}
- </div>
- </div>
- );
- })}
- </div>
- </div>
-
- {/* 계약 동의 현황 */}
- <div>
- <div className="flex items-center justify-between mb-4">
- <h3 className="text-lg font-semibold">계약 동의 현황</h3>
- </div>
- <div className="border rounded-lg">
- <div className="grid grid-cols-4 gap-4 p-4 bg-gray-50 font-medium text-sm">
- <div>계약유형</div>
- <div>상태</div>
- <div>서약일자</div>
- <div>액션</div>
- </div>
- {!registration.basicContracts || registration.basicContracts.length === 0 ? (
- <div className="p-4 border-t text-center text-gray-500">
- 요청된 기본계약이 없습니다.
- </div>
- ) : (
- registration.basicContracts.map((contract, index) => {
- const isCompleted = contract.status === "VENDOR_SIGNED" || contract.status === "COMPLETED";
-
- return (
- <div
- key={`${contract.templateId}-${index}`}
- className="grid grid-cols-4 gap-4 p-4 border-t items-center"
- >
- <div className="flex items-center gap-2">
- <StatusIcon status={isCompleted} />
- {contract.templateName || "템플릿명 없음"}
- </div>
- <div>
- <StatusBadge status={isCompleted} />
- </div>
- <div className="text-sm text-gray-600">
- {isCompleted && contract.createdAt
- ? new Intl.DateTimeFormat('ko-KR').format(new Date(contract.createdAt))
- : "-"
- }
- </div>
- <div>
- {isCompleted && (
- <Button
- size="sm"
- variant="outline"
- onClick={() => handleContractDownload(index)}
- >
- <Download className="w-4 h-4 mr-1" />
- 다운로드
- </Button>
- )}
- </div>
- </div>
- );
- })
- )}
- </div>
- </div>
-
- {/* 안전적격성 평가 */}
- <div>
- <h3 className="text-lg font-semibold mb-4">안전적격성 평가</h3>
- <div className="p-4 border rounded-lg">
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-2">
- <StatusIcon status={!!registration.safetyQualificationContent} />
- <span>안전적격성 평가</span>
- </div>
- <StatusBadge status={!!registration.safetyQualificationContent} />
- </div>
- {registration.safetyQualificationContent && (
- <div className="mt-3 p-3 bg-gray-50 rounded">
- <p className="text-sm">{registration.safetyQualificationContent}</p>
- </div>
- )}
- </div>
- </div>
-
- {/* 추가 정보 */}
- <div>
- <h3 className="text-lg font-semibold mb-4">추가 정보</h3>
- <div className="p-4 border rounded-lg">
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-2">
- <StatusIcon status={registration.additionalInfo} />
- <span>추가 정보 등록</span>
- </div>
- <StatusBadge status={registration.additionalInfo} />
- </div>
- </div>
- </div>
- </div>
- </DialogContent>
- </Dialog>
- );
-}
+"use client";
+
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { FileText, Download, CheckCircle, XCircle, Clock, RefreshCw } from "lucide-react";
+import { toast } from "sonner";
+
+import type { VendorRegularRegistration } from "@/config/vendorRegularRegistrationsColumnsConfig";
+import {
+ documentStatusColumns,
+} from "@/config/vendorRegularRegistrationsColumnsConfig";
+
+interface DocumentStatusDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ registration: VendorRegularRegistration | null;
+ onRefresh?: () => void;
+ isVendorUser?: boolean;
+}
+
+const StatusIcon = ({ status }: { status: string | boolean }) => {
+ if (typeof status === "boolean") {
+ return status ? (
+ <CheckCircle className="w-4 h-4 text-green-600" />
+ ) : (
+ <XCircle className="w-4 h-4 text-red-500" />
+ );
+ }
+
+ switch (status) {
+ case "completed":
+ return <CheckCircle className="w-4 h-4 text-green-600" />;
+ case "reviewing":
+ return <Clock className="w-4 h-4 text-yellow-600" />;
+ case "not_submitted":
+ default:
+ return <XCircle className="w-4 h-4 text-red-500" />;
+ }
+};
+
+const StatusBadge = ({ status }: { status: string | boolean }) => {
+ if (typeof status === "boolean") {
+ return (
+ <Badge variant={status ? "default" : "destructive"}>
+ {status ? "제출완료" : "미제출"}
+ </Badge>
+ );
+ }
+
+ const statusConfig = {
+ completed: { label: "완료", variant: "default" as const },
+ reviewing: { label: "검토중", variant: "secondary" as const },
+ not_submitted: { label: "미제출", variant: "destructive" as const },
+ };
+
+ const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.not_submitted;
+
+ return <Badge variant={config.variant}>{config.label}</Badge>;
+};
+
+export function DocumentStatusDialog({
+ open,
+ onOpenChange,
+ registration,
+ onRefresh,
+ isVendorUser = false,
+}: DocumentStatusDialogProps) {
+ if (!registration) return null;
+
+ // 파일 다운로드 핸들러
+ const handleFileDownload = async (docKey: string, fileIndex: number = 0) => {
+ try {
+ console.log(`🔍 파일 다운로드 시도:`, {
+ docKey,
+ fileIndex,
+ allDocumentFiles: registration.documentFiles,
+ registrationId: registration.id,
+ registrationKeys: Object.keys(registration),
+ fullRegistration: registration
+ });
+ //isvendoruser인 경우는 실사 결과 파일 다운로드 불가능
+ if (isVendorUser && docKey === "auditResult") {
+ toast.error("실사 결과 파일은 다운로드할 수 없습니다.");
+ return;
+ }
+
+ // documentFiles가 없는 경우 처리
+ if (!registration.documentFiles) {
+ console.error(`❌ documentFiles가 없음:`, {
+ registration,
+ hasDocumentFiles: !!registration.documentFiles,
+ registrationKeys: Object.keys(registration)
+ });
+ toast.error("문서 파일 정보를 찾을 수 없습니다. 페이지를 새로고침 해주세요.");
+ return;
+ }
+
+ const files = registration.documentFiles[docKey as keyof typeof registration.documentFiles];
+ console.log(`📂 ${docKey} 파일 목록:`, files);
+
+ if (!files || files.length === 0) {
+ console.warn(`❌ ${docKey}에 파일이 없음:`, { files, length: files?.length });
+ toast.error("다운로드할 파일이 없습니다.");
+ return;
+ }
+
+ const file = files[fileIndex];
+ console.log(`📄 선택된 파일 (index ${fileIndex}):`, file);
+
+ if (!file) {
+ console.warn(`❌ 파일 인덱스 ${fileIndex}에 파일이 없음`);
+ toast.error("파일을 찾을 수 없습니다.");
+ return;
+ }
+
+ // 파일 객체의 모든 속성 확인
+ console.log(`🔍 파일 객체 전체 속성:`, Object.keys(file));
+ console.log(`🔍 파일 상세 정보:`, {
+ filePath: file.filePath,
+ path: file.path,
+ originalFileName: file.originalFileName,
+ fileName: file.fileName,
+ name: file.name,
+ fullObject: file
+ });
+
+ // filePath와 fileName 추출
+ const filePath = file.filePath || file.path;
+ const fileName = file.originalFileName || file.fileName || file.name;
+
+ console.log(`📝 추출된 파일 정보:`, { filePath, fileName });
+
+ if (!filePath || !fileName) {
+ console.error(`❌ 파일 정보 누락:`, {
+ filePath,
+ fileName,
+ fileObject: file,
+ availableKeys: Object.keys(file)
+ });
+ toast.error("파일 정보가 올바르지 않습니다.");
+ return;
+ }
+
+ console.log(`📥 파일 다운로드 시작:`, { filePath, fileName, docKey });
+
+ // downloadFile 함수를 동적으로 import하여 파일 다운로드
+ const { downloadFile } = await import('@/lib/file-download');
+ const result = await downloadFile(filePath, fileName, {
+ showToast: true,
+ onError: (error: any) => {
+ console.error("파일 다운로드 오류:", error);
+ toast.error(`파일 다운로드 실패: ${error}`);
+ },
+ onSuccess: (fileName: string, fileSize?: number) => {
+ console.log(`✅ 파일 다운로드 성공:`, { fileName, fileSize });
+ }
+ });
+
+ if (!result.success) {
+ console.error("파일 다운로드 실패:", result.error);
+ }
+ } catch (error) {
+ console.error("파일 다운로드 중 오류 발생:", error);
+ toast.error("파일 다운로드 중 오류가 발생했습니다.");
+ }
+ };
+
+ // 기본계약 파일 다운로드 핸들러
+ const handleContractDownload = async (contractIndex: number) => {
+ try {
+ if (!registration.basicContracts || registration.basicContracts.length === 0) {
+ toast.error("다운로드할 계약이 없습니다.");
+ return;
+ }
+
+ const contract = registration.basicContracts[contractIndex];
+ if (!contract) {
+ toast.error("계약을 찾을 수 없습니다.");
+ return;
+ }
+
+ if (contract.status !== "VENDOR_SIGNED" && contract.status !== "COMPLETED") {
+ toast.error("완료된 계약서만 다운로드할 수 있습니다.");
+ return;
+ }
+
+ // 서명된 계약서 파일 정보 확인
+ const filePath = contract.filePath;
+ const fileName = contract.fileName || `${contract.templateName || '기본계약'}_${registration.companyName}.docx`;
+
+ if (!filePath) {
+ toast.error("계약서 파일을 찾을 수 없습니다.");
+ return;
+ }
+
+ console.log(`📥 기본계약 다운로드 시작:`, {
+ filePath,
+ fileName,
+ templateName: contract.templateName
+ });
+
+ // downloadFile 함수를 사용하여 서명된 계약서 다운로드
+ const { downloadFile } = await import('@/lib/file-download');
+ const result = await downloadFile(filePath, fileName, {
+ showToast: true,
+ onError: (error: any) => {
+ console.error("기본계약 다운로드 오류:", error);
+ toast.error(`기본계약 다운로드 실패: ${error}`);
+ },
+ onSuccess: (fileName: string, fileSize?: number) => {
+ console.log(`✅ 기본계약 다운로드 성공:`, { fileName, fileSize });
+ }
+ });
+
+ if (!result.success) {
+ console.error("기본계약 다운로드 실패:", result.error);
+ }
+ } catch (error) {
+ console.error("기본계약 다운로드 중 오류 발생:", error);
+ toast.error("기본계약 다운로드 중 오류가 발생했습니다.");
+ }
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[80vh]">
+ <DialogHeader className="sticky top-0 z-10 border-b pr-4 pb-4 mb-4">
+ <DialogTitle className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <FileText className="w-5 h-5" />
+ 문서/자료 접수 현황 - {registration.companyName}
+ </div>
+ {onRefresh && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onRefresh}
+ className="flex items-center gap-2"
+ >
+ <RefreshCw className="w-4 h-4" />
+ 새로고침
+ </Button>
+ )}
+ </DialogTitle>
+ </DialogHeader>
+
+ <div className="overflow-y-auto max-h-[calc(80vh-120px)] space-y-6">
+ {/* 기본 정보 */}
+ <div className="grid grid-cols-2 gap-4 p-4 bg-gray-50 rounded-lg">
+ <div>
+ <span className="text-sm font-medium text-gray-600">업체명:</span>
+ <span className="ml-2">{registration.companyName}</span>
+ </div>
+ <div>
+ <span className="text-sm font-medium text-gray-600">사업자번호:</span>
+ <span className="ml-2">{registration.businessNumber}</span>
+ </div>
+ <div>
+ <span className="text-sm font-medium text-gray-600">대표자:</span>
+ <span className="ml-2">{registration.representative || "-"}</span>
+ </div>
+ <div>
+ <span className="text-sm font-medium text-gray-600">현재상태:</span>
+ <Badge className="ml-2">{registration.status}</Badge>
+ </div>
+ </div>
+
+ {/* 문서 제출 현황 */}
+ <div>
+ <div className="flex items-center justify-between mb-4">
+ <h3 className="text-lg font-semibold">문서 제출 현황</h3>
+ </div>
+ <div className="border rounded-lg">
+ <div className="grid grid-cols-4 gap-4 p-4 bg-gray-50 font-medium text-sm">
+ <div>문서유형</div>
+ <div>상태</div>
+ <div>제출일자</div>
+ <div>액션</div>
+ </div>
+ {documentStatusColumns.map((doc) => {
+ const isSubmitted = registration.documentSubmissions?.[
+ doc.key as keyof typeof registration.documentSubmissions
+ ] as boolean || false;
+
+ // 내자인 경우 통장사본은 표시하지 않음
+ const isForeign = registration.country !== 'KR';
+ if (doc.key === 'bankCopy' && !isForeign) {
+ return null;
+ }
+
+ return (
+ <div
+ key={doc.key}
+ className="grid grid-cols-4 gap-4 p-4 border-t items-center"
+ >
+ <div className="flex items-center gap-2">
+ <StatusIcon status={isSubmitted} />
+ {doc.label}
+ {doc.key === 'bankCopy' && isForeign && (
+ <span className="text-xs text-blue-600">(외자 필수)</span>
+ )}
+ </div>
+ <div>
+ <StatusBadge status={isSubmitted} />
+ </div>
+ <div className="text-sm text-gray-600">
+ {isSubmitted ? "2024.01.01" : "-"}
+ </div>
+ <div>
+ {isSubmitted && (
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={() => handleFileDownload(doc.key)}
+ >
+ <Download className="w-4 h-4 mr-1" />
+ 다운로드
+ </Button>
+ )}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ </div>
+
+ {/* 계약 동의 현황 */}
+ <div>
+ <div className="flex items-center justify-between mb-4">
+ <h3 className="text-lg font-semibold">계약 동의 현황</h3>
+ </div>
+ <div className="border rounded-lg">
+ <div className="grid grid-cols-4 gap-4 p-4 bg-gray-50 font-medium text-sm">
+ <div>계약유형</div>
+ <div>상태</div>
+ <div>서약일자</div>
+ <div>액션</div>
+ </div>
+ {!registration.basicContracts || registration.basicContracts.length === 0 ? (
+ <div className="p-4 border-t text-center text-gray-500">
+ 요청된 기본계약이 없습니다.
+ </div>
+ ) : (
+ registration.basicContracts.map((contract, index) => {
+ const isCompleted = contract.status === "VENDOR_SIGNED" || contract.status === "COMPLETED";
+
+ return (
+ <div
+ key={`${contract.templateId}-${index}`}
+ className="grid grid-cols-4 gap-4 p-4 border-t items-center"
+ >
+ <div className="flex items-center gap-2">
+ <StatusIcon status={isCompleted} />
+ {contract.templateName || "템플릿명 없음"}
+ </div>
+ <div>
+ <StatusBadge status={isCompleted} />
+ </div>
+ <div className="text-sm text-gray-600">
+ {isCompleted && contract.createdAt
+ ? new Intl.DateTimeFormat('ko-KR').format(new Date(contract.createdAt))
+ : "-"
+ }
+ </div>
+ <div>
+ {isCompleted && (
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={() => handleContractDownload(index)}
+ >
+ <Download className="w-4 h-4 mr-1" />
+ 다운로드
+ </Button>
+ )}
+ </div>
+ </div>
+ );
+ })
+ )}
+ </div>
+ </div>
+
+ {/* 안전적격성 평가 */}
+ <div>
+ <h3 className="text-lg font-semibold mb-4">안전적격성 평가</h3>
+ <div className="p-4 border rounded-lg">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <StatusIcon status={!!registration.safetyQualificationContent} />
+ <span>안전적격성 평가</span>
+ </div>
+ <StatusBadge status={!!registration.safetyQualificationContent} />
+ </div>
+ {registration.safetyQualificationContent && (
+ <div className="mt-3 p-3 bg-gray-50 rounded">
+ <p className="text-sm">{registration.safetyQualificationContent}</p>
+ </div>
+ )}
+ </div>
+ </div>
+
+ {/* 추가 정보 */}
+ <div>
+ <h3 className="text-lg font-semibold mb-4">추가 정보</h3>
+ <div className="p-4 border rounded-lg">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <StatusIcon status={registration.additionalInfo} />
+ <span>추가 정보 등록</span>
+ </div>
+ <StatusBadge status={registration.additionalInfo} />
+ </div>
+ </div>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/components/vendor-regular-registrations/registration-request-dialog.tsx b/components/vendor-regular-registrations/registration-request-dialog.tsx
index 99599ce5..d3aeb812 100644
--- a/components/vendor-regular-registrations/registration-request-dialog.tsx
+++ b/components/vendor-regular-registrations/registration-request-dialog.tsx
@@ -313,6 +313,16 @@ export function RegistrationRequestDialog({
return;
}
+ // 업무담당자 검증 (최소 하나의 담당자라도 이름과 이메일이 있어야 함)
+ const hasValidBusinessContact = Object.values(formData.businessContacts).some(contact =>
+ contact.name?.trim() && contact.email?.trim()
+ );
+
+ if (!hasValidBusinessContact) {
+ toast.error("업무담당자 정보를 최소 하나 이상 입력해주세요. (담당자명과 이메일 필수)");
+ return;
+ }
+
if (onSubmit) {
await onSubmit(formData);
}
@@ -599,7 +609,8 @@ export function RegistrationRequestDialog({
{/* 업무담당자 */}
<div>
- <h4 className="font-semibold mb-3">업무담당자</h4>
+ <h4 className="font-semibold mb-3">업무담당자 <span className="text-red-500">*</span></h4>
+ <p className="text-sm text-muted-foreground mb-4">최소 하나의 업무담당자 정보를 입력해주세요.</p>
<div className="space-y-4">
{Object.entries(formData.businessContacts).map(([type, contact]) => {
const labels = {
@@ -615,7 +626,7 @@ export function RegistrationRequestDialog({
<h5 className="font-medium text-sm">{labels[type as keyof typeof labels]} 담당자</h5>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
- <Label>담당자명</Label>
+ <Label>담당자명 <span className="text-red-500">*</span></Label>
<Input
value={contact.name}
onChange={(e) => handleBusinessContactChange(type as keyof typeof formData.businessContacts, 'name', e.target.value)}
@@ -646,7 +657,7 @@ export function RegistrationRequestDialog({
/>
</div>
<div>
- <Label>이메일</Label>
+ <Label>이메일 <span className="text-red-500">*</span></Label>
<Input
type="email"
value={contact.email}
diff --git a/db/schema/bidding.ts b/db/schema/bidding.ts
index 2f0dd07f..1d1fe50a 100644
--- a/db/schema/bidding.ts
+++ b/db/schema/bidding.ts
@@ -42,6 +42,7 @@ export const biddingStatusEnum = pgEnum('bidding_status', [
'bidding_opened', // 입찰공고
'bidding_closed', // 입찰마감
'evaluation_of_bidding', // 입찰평가중
+ 'approval_pending', // 결재 진행중
'bidding_disposal', // 유찰
'vendor_selected', // 업체선정
'bid_opening', // 개찰
diff --git a/db/schema/techSales.ts b/db/schema/techSales.ts
index c252a116..677e8cd3 100644
--- a/db/schema/techSales.ts
+++ b/db/schema/techSales.ts
@@ -48,7 +48,8 @@ import { techVendors, techVendorContacts, techVendorPossibleItems } from "./tech
// 기술영업 RFQ 상태
export const TECH_SALES_RFQ_STATUSES = {
RFQ_CREATED: "RFQ Created",
- RFQ_VENDOR_ASSIGNED: "RFQ Vendor Assignned",
+ RFQ_VENDOR_ASSIGNED: "RFQ Vendor Assignned",
+ APPROVAL_IN_PROGRESS: "결재 진행중",
RFQ_SENT: "RFQ Sent",
QUOTATION_ANALYSIS: "Quotation Analysis",
CLOSED: "Closed",
@@ -208,6 +209,7 @@ export const techSalesAttachments = pgTable(
fileSize: integer("file_size"),
fileType: varchar("file_type", { length: 100 }),
description: varchar("description", { length: 500 }),
+ drmEncrypted: boolean("drm_encrypted").default(false).notNull(), // DRM 암호화 여부
createdBy: integer("created_by")
.references(() => users.id, { onDelete: "set null" })
.notNull(),
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/basic-contract/sslvw-service.ts b/lib/basic-contract/sslvw-service.ts
new file mode 100644
index 00000000..9650d43a
--- /dev/null
+++ b/lib/basic-contract/sslvw-service.ts
@@ -0,0 +1,82 @@
+"use server"
+
+import { oracleKnex } from '@/lib/oracle-db/db'
+
+// SSLVW_PUR_INQ_REQ 테이블 데이터 타입 (실제 테이블 구조에 맞게 조정 필요)
+export interface SSLVWPurInqReq {
+ [key: string]: string | number | Date | null | undefined
+}
+
+// 테스트 환경용 폴백 데이터
+const FALLBACK_TEST_DATA: SSLVWPurInqReq[] = [
+ {
+ id: 1,
+ request_number: 'REQ001',
+ status: 'PENDING',
+ created_date: new Date('2025-01-01'),
+ description: '테스트 요청 1'
+ },
+ {
+ id: 2,
+ request_number: 'REQ002',
+ status: 'APPROVED',
+ created_date: new Date('2025-01-02'),
+ description: '테스트 요청 2'
+ }
+]
+
+/**
+ * SSLVW_PUR_INQ_REQ 테이블 전체 조회
+ * @returns 테이블 데이터 배열
+ */
+export async function getSSLVWPurInqReqData(): Promise<{
+ success: boolean
+ data: SSLVWPurInqReq[]
+ error?: string
+ isUsingFallback?: boolean
+}> {
+ try {
+ console.log('📋 [getSSLVWPurInqReqData] SSLVW_PUR_INQ_REQ 테이블 조회 시작...')
+
+ const result = await oracleKnex.raw(`
+ SELECT *
+ FROM SSLVW_PUR_INQ_REQ
+ WHERE ROWNUM < 100
+ ORDER BY 1
+ `)
+
+ // Oracle raw query의 결과는 rows 배열에 들어있음
+ const rows = (result.rows || result) as Array<Record<string, unknown>>
+
+ console.log(`✅ [getSSLVWPurInqReqData] 조회 성공 - ${rows.length}건`)
+
+ // 데이터 타입 변환 (필요에 따라 조정)
+ const cleanedResult = rows.map((item) => {
+ const convertedItem: SSLVWPurInqReq = {}
+ for (const [key, value] of Object.entries(item)) {
+ if (value instanceof Date) {
+ convertedItem[key] = value
+ } else if (value === null) {
+ convertedItem[key] = null
+ } else {
+ convertedItem[key] = String(value)
+ }
+ }
+ return convertedItem
+ })
+
+ return {
+ success: true,
+ data: cleanedResult,
+ isUsingFallback: false
+ }
+ } catch (error) {
+ console.error('❌ [getSSLVWPurInqReqData] 오류:', error)
+ console.log('🔄 [getSSLVWPurInqReqData] 폴백 테스트 데이터 사용')
+ return {
+ success: true,
+ data: FALLBACK_TEST_DATA,
+ isUsingFallback: true
+ }
+ }
+}
diff --git a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx
index 37ae135c..c71be9d1 100644
--- a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx
+++ b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx
@@ -2,7 +2,7 @@
import * as React from "react"
import { type Table } from "@tanstack/react-table"
-import { Download, FileDown, Mail, Scale, CheckCircle, AlertTriangle, Send, Gavel, Check, FileSignature } from "lucide-react"
+import { Download, FileDown, Mail, CheckCircle, AlertTriangle, Send, Check, FileSignature } from "lucide-react"
import { exportTableToExcel } from "@/lib/export"
import { downloadFile } from "@/lib/file-download"
@@ -18,11 +18,9 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
-import { Textarea } from "@/components/ui/textarea"
-import { Label } from "@/components/ui/label"
-import { Separator } from "@/components/ui/separator"
-import { prepareFinalApprovalAction, quickFinalApprovalAction, requestLegalReviewAction, resendContractsAction } from "../service"
+import { prepareFinalApprovalAction, quickFinalApprovalAction, resendContractsAction } from "../service"
import { BasicContractSignDialog } from "../vendor-table/basic-contract-sign-dialog"
+import { SSLVWPurInqReqDialog } from "@/components/common/legal/sslvw-pur-inq-req-dialog"
interface BasicContractDetailTableToolbarActionsProps {
table: Table<BasicContractView>
@@ -35,10 +33,8 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD
// 다이얼로그 상태
const [resendDialog, setResendDialog] = React.useState(false)
- const [legalReviewDialog, setLegalReviewDialog] = React.useState(false)
const [finalApproveDialog, setFinalApproveDialog] = React.useState(false)
const [loading, setLoading] = React.useState(false)
- const [reviewNote, setReviewNote] = React.useState("")
const [buyerSignDialog, setBuyerSignDialog] = React.useState(false)
const [contractsToSign, setContractsToSign] = React.useState<any[]>([])
@@ -49,10 +45,6 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD
const canBulkResend = hasSelectedRows
- const canRequestLegalReview = hasSelectedRows && selectedRows.some(row =>
- row.original.legalReviewRequired && !row.original.legalReviewRequestedAt
- )
-
const canFinalApprove = hasSelectedRows && selectedRows.some(row => {
const contract = row.original;
if (contract.completedAt !== null || !contract.signedFilePath) {
@@ -67,10 +59,6 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD
// 필터링된 계약서들 계산
const resendContracts = selectedRows.map(row => row.original)
- const legalReviewContracts = selectedRows
- .map(row => row.original)
- .filter(contract => contract.legalReviewRequired && !contract.legalReviewRequestedAt)
-
const finalApproveContracts = selectedRows
.map(row => row.original)
.filter(contract => {
@@ -204,15 +192,6 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD
})
}
- // 법무검토 요청
- const handleLegalReviewRequest = async () => {
- if (!canRequestLegalReview) {
- toast.error("법무검토 요청 가능한 계약서를 선택해주세요")
- return
- }
- setLegalReviewDialog(true)
- }
-
// 최종승인
const handleFinalApprove = async () => {
if (!canFinalApprove) {
@@ -241,26 +220,6 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD
}
}
- // 법무검토 요청 확인
- const confirmLegalReview = async () => {
- setLoading(true)
- try {
- // TODO: 서버액션 호출
- await requestLegalReviewAction(legalReviewContracts.map(c => c.id), reviewNote)
-
- console.log("법무검토 요청:", legalReviewContracts, "메모:", reviewNote)
- toast.success(`${legalReviewContracts.length}건의 법무검토 요청을 완료했습니다`)
- setLegalReviewDialog(false)
- setReviewNote("")
- table.toggleAllPageRowsSelected(false) // 선택 해제
- } catch (error) {
- toast.error("법무검토 요청 중 오류가 발생했습니다")
- console.error(error)
- } finally {
- setLoading(false)
- }
- }
-
// 최종승인 확인 (수정됨)
const confirmFinalApprove = async () => {
setLoading(true)
@@ -354,25 +313,8 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD
</span>
</Button>
- {/* 법무검토 요청 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={handleLegalReviewRequest}
- disabled={!canRequestLegalReview}
- className="gap-2"
- title={!hasSelectedRows
- ? "계약서를 선택해주세요"
- : !canRequestLegalReview
- ? "법무검토 요청 가능한 계약서가 없습니다"
- : `${legalReviewContracts.length}건 법무검토 요청`
- }
- >
- <Scale className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">
- 법무검토 {hasSelectedRows ? `(${selectedRows.length})` : ''}
- </span>
- </Button>
+ {/* 법무검토 버튼 (SSLVW 데이터 조회) */}
+ <SSLVWPurInqReqDialog />
{/* 최종승인 버튼 */}
<Button
@@ -471,73 +413,6 @@ export function BasicContractDetailTableToolbarActions({ table }: BasicContractD
</DialogContent>
</Dialog>
- {/* 법무검토 요청 다이얼로그 */}
- <Dialog open={legalReviewDialog} onOpenChange={setLegalReviewDialog}>
- <DialogContent className="max-w-2xl">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <Gavel className="size-5" />
- 법무검토 요청
- </DialogTitle>
- <DialogDescription>
- 선택한 {legalReviewContracts.length}건의 계약서에 대한 법무검토를 요청합니다.
- </DialogDescription>
- </DialogHeader>
-
- <div className="space-y-4">
- <div className="max-h-48 overflow-y-auto">
- <div className="space-y-3">
- {legalReviewContracts.map((contract, index) => (
- <div key={contract.id} className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
- <div className="flex-1">
- <div className="font-medium">{contract.vendorName || '업체명 없음'}</div>
- <div className="text-sm text-gray-500">
- {contract.vendorCode || '코드 없음'} | {contract.templateName || '템플릿명 없음'}
- </div>
- </div>
- <Badge variant="secondary">{contract.status}</Badge>
- </div>
- ))}
- </div>
- </div>
-
- <Separator />
-
- <div className="space-y-2">
- <Label htmlFor="review-note">검토 요청 메모 (선택사항)</Label>
- <Textarea
- id="review-note"
- placeholder="법무팀에게 전달할 특별한 요청사항이나 검토 포인트를 입력해주세요..."
- value={reviewNote}
- onChange={(e) => setReviewNote(e.target.value)}
- rows={3}
- />
- </div>
- </div>
-
- <DialogFooter>
- <Button
- variant="outline"
- onClick={() => {
- setLegalReviewDialog(false)
- setReviewNote("")
- }}
- disabled={loading}
- >
- 취소
- </Button>
- <Button
- onClick={confirmLegalReview}
- disabled={loading}
- className="gap-2"
- >
- <Gavel className="size-4" />
- {loading ? "요청 중..." : `${legalReviewContracts.length}건 검토요청`}
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
-
{/* 최종승인 다이얼로그 */}
<Dialog open={finalApproveDialog} onOpenChange={setFinalApproveDialog}>
<DialogContent className="max-w-2xl">
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 {
diff --git a/lib/mail/templates/tech-sales-rfq-invite-ko.hbs b/lib/mail/templates/tech-sales-rfq-invite-ko.hbs
index 37521960..2604e223 100644
--- a/lib/mail/templates/tech-sales-rfq-invite-ko.hbs
+++ b/lib/mail/templates/tech-sales-rfq-invite-ko.hbs
@@ -55,9 +55,8 @@
<h2 style="font-size:20px; margin-bottom:12px;">가. 거래조건</h2>
<p style="font-size:16px; line-height:24px; margin-bottom:8px;">
<strong>1) 프로젝트 : {{project.name}}</strong>
- <br>* 프로젝트 코드 : {{rfq.projectCode}}
- {{#if project.sector}}
- <br>* 부문 : {{project.sector}}
+ {{#if project.shipType}}
+ <br>* 선종 : {{project.shipType}}
{{/if}}
{{#if project.shipCount}}
<br>* 척수 : {{project.shipCount}}척
@@ -76,40 +75,32 @@
<p style="font-size:16px; line-height:24px; margin-bottom:8px;">
<strong>2) 자재명</strong>
{{#each items}}
- <br>* {{itemList}} ({{itemCode}})
- {{#if workType}}<br> - 작업유형: {{workType}}{{/if}}
- {{#if shipType}}<br> - 선종: {{shipType}}{{/if}}
+ <br>* {{itemList}}
{{/each}}
- {{#if rfq.materialCode}}
- <br>* 자재그룹 코드 : {{rfq.materialCode}}
- {{/if}}
</p>
{{else}}
<p style="font-size:16px; line-height:24px; margin-bottom:8px;">
<strong>2) 자재명 : {{rfq.title}}</strong>
- {{#if rfq.materialCode}}
- <br>* 자재그룹 코드 : {{rfq.materialCode}}
- {{/if}}
- {{#if project.shipType}}
- <br>* 선종 : {{project.shipType}}
- {{/if}}
</p>
{{/if}}
<p style="font-size:16px; line-height:24px; margin-bottom:8px;">
- <strong>3) Spec, & Scope of Supply : 첨부 사양서 참조</strong>
+ <strong>3) Spec, & Scope of Supply : 시스템 내 첨부 사양서 또는 본문 참조</strong>
</p>
<p style="font-size:16px; line-height:24px; margin-bottom:8px;">
<strong>4) Class / Flag : {{#if project.className}}{{project.className}}{{else}}첨부 사양서 참조{{/if}}</strong>
</p>
<p style="font-size:16px; line-height:24px; margin-bottom:8px;">
- <strong>5) 예상 납기일 : 프로젝트 일정에 따라 결정</strong>
+ <strong>5) 예상 납기일 :</strong>
+ <br>* K/L Date : 2027.3Q 기준 프로젝트 일정에 따라 결정
<br>* 상세 납기는 조선소 스케쥴에 따라 변경될 수 있으며, 당사 기술영업/조달/현업/생산 부서와 협의하여 결정됨.
<br>* 안전보건에 관한 사항을 고려하여 납기(또는 계약기간)의 적정성을 검토하고,
<br>안전보건확보를 위해 납기의 조정이 필요한 경우 기간 조정을 신청하시기 바랍니다.
<br>* (당사 사업장 내에서 수행하는 작업이 포함된 계약)
</p>
<p style="font-size:16px; line-height:24px; margin-bottom:8px;">
- <strong>6) Warranty : 선박 인도 후 12개월 시점까지 납품한 "자재" 또는 "용역"이 계약 내용과 동일함을 보증하며,</strong>
+ <strong>6) Warranty :</strong>
+ <br>* 별도 요구 기간이 있을 경우 그 기준을 준수할 것
+ <br>* 별도 요구 기간이 없을 경우, 선박 인도 후 12개월 시점까지 납품한 "자재" 또는 "용역"이 계약 내용과 동일함을 보증하며,
<br>Repair 시 6개월 추가되나, 총 인도 후 18개월을 넘지 않음.
</p>
<p style="font-size:16px; line-height:24px; margin-bottom:8px;">
diff --git a/lib/mail/templates/vendor-invitation.hbs b/lib/mail/templates/vendor-invitation.hbs
index d80f223a..193fcaa0 100644
--- a/lib/mail/templates/vendor-invitation.hbs
+++ b/lib/mail/templates/vendor-invitation.hbs
@@ -44,7 +44,7 @@
</p>
<div style="background-color: #f3f4f6; border-radius: 4px; padding: 15px; margin: 20px 0;">
- <p style="font-size:16px; margin:4px 0;">등록을 완료하려면 아래 버튼을 클릭해 주세요. 이 버튼을 클릭하면 계정을 설정하고 필요한 정보를 제공할 수 있는 보안 등록 포털로 이동합니다.</p>
+ <p style="font-size:16px; margin:4px 0;"><strong>신규가입 진행:</strong> 등록을 완료하려면 아래 버튼을 클릭해 주세요. 이 버튼을 클릭하면 계정을 설정하고 필요한 정보를 제공할 수 있는 보안 등록 포털로 이동합니다.</p>
</div>
<p style="text-align: center;">
diff --git a/lib/rfq-last/shared/rfq-items-dialog.tsx b/lib/rfq-last/shared/rfq-items-dialog.tsx
index f3095c98..e4f71e79 100644
--- a/lib/rfq-last/shared/rfq-items-dialog.tsx
+++ b/lib/rfq-last/shared/rfq-items-dialog.tsx
@@ -328,11 +328,11 @@ export function RfqItemsDialog({
<TableCell>
<div className="flex flex-col items-center gap-1">
<span className="text-xs font-mono">#{index + 1}</span>
- {item.majorYn && (
+ {/* {item.majorYn && (
<Badge variant="default" className="text-xs px-1 py-0">
주요
</Badge>
- )}
+ )} */}
</div>
</TableCell>
<TableCell>
diff --git a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx
index abd2b516..8c70b8dd 100644
--- a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx
+++ b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx
@@ -322,7 +322,7 @@ export default function VendorResponseEditor({
if (errors.quotationItems) {
toast.error("견적 품목 정보를 확인해주세요. 모든 품목의 단가와 총액을 입력해야 합니다.")
} else {
- toast.error("입력 정보를 확인해주세요.")
+ toast.error("기본계약 또는 상업조건 정보를 확인해주세요.")
}
}
}
diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx
index 2ee2cb73..577ae492 100644
--- a/lib/rfq-last/vendor/rfq-vendor-table.tsx
+++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx
@@ -1539,10 +1539,10 @@ export function RfqVendorTable({
)}
{/* 기본계약 수정 메뉴 추가 */}
- <DropdownMenuItem onClick={() => handleAction("edit-contract", vendor)}>
+ {/* <DropdownMenuItem onClick={() => handleAction("edit-contract", vendor)}>
<FileText className="mr-2 h-4 w-4" />
기본계약 수정
- </DropdownMenuItem>
+ </DropdownMenuItem> */}
{emailSentAt && (
<>
@@ -1824,9 +1824,6 @@ export function RfqVendorTable({
<Plus className="h-4 w-4 mr-2" />
벤더 추가
</Button>
-
- {selectedRows.length > 0 && (
- <>
{/* 정보 일괄 입력 버튼 - 취소되지 않은 벤더만 */}
<Button
variant="outline"
@@ -1837,7 +1834,8 @@ export function RfqVendorTable({
<Settings2 className="h-4 w-4 mr-2" />
협력업체 조건 설정 ({nonCancelledRows.length})
</Button>
-
+ {selectedRows.length > 0 && (
+ <>
{/* RFQ 발송 버튼 - 취소되지 않은 벤더만 */}
<Button
variant="outline"
diff --git a/lib/techsales-rfq/approval-actions.ts b/lib/techsales-rfq/approval-actions.ts
new file mode 100644
index 00000000..175bca1d
--- /dev/null
+++ b/lib/techsales-rfq/approval-actions.ts
@@ -0,0 +1,244 @@
+/**
+ * 기술영업 RFQ 발송 결재 서버 액션
+ *
+ * DRM 파일이 있는 기술영업 RFQ를 발송할 때 결재를 거치는 서버 액션
+ */
+
+'use server';
+
+import { ApprovalSubmissionSaga } from '@/lib/approval';
+import { mapTechSalesRfqSendToTemplateVariables } from './approval-handlers';
+
+interface TechSalesRfqSendApprovalData {
+ // RFQ 기본 정보
+ rfqId: number;
+ rfqCode?: string;
+
+ // 발송 데이터
+ vendorIds: number[];
+ selectedContacts?: Array<{
+ vendorId: number;
+ contactId: number;
+ contactEmail: string;
+ contactName: string;
+ }>;
+ drmAttachmentIds: number[];
+
+ // 첨부파일 정보 (파일명, 크기 등)
+ drmAttachments: Array<{
+ fileName?: string | null;
+ fileSize?: number | null;
+ }>;
+
+ // 신청 사유
+ applicationReason: string;
+
+ // 결재 정보
+ currentUser: {
+ id: number;
+ epId: string | null;
+ name?: string;
+ email?: string;
+ };
+ approvers?: string[]; // Knox EP ID 배열
+}
+
+/**
+ * 기술영업 RFQ 발송 결재 상신 (초기 발송)
+ *
+ * DRM 파일이 있는 경우 결재를 거쳐 RFQ를 발송합니다.
+ */
+export async function requestTechSalesRfqSendWithApproval(data: TechSalesRfqSendApprovalData) {
+ // 1. 입력 검증
+ if (!data.currentUser.epId) {
+ throw new Error('Knox EP ID가 필요합니다. 시스템 관리자에게 문의하세요.');
+ }
+
+ if (!data.vendorIds || data.vendorIds.length === 0) {
+ throw new Error('발송할 벤더를 선택해주세요.');
+ }
+
+ if (!data.drmAttachmentIds || data.drmAttachmentIds.length === 0) {
+ throw new Error('DRM 첨부파일이 없습니다. 결재가 필요하지 않습니다.');
+ }
+
+ console.log('[TechSales RFQ Approval] Starting approval process for RFQ send');
+ console.log('[TechSales RFQ Approval] RFQ ID:', data.rfqId);
+ console.log('[TechSales RFQ Approval] Vendors:', data.vendorIds.length);
+ console.log('[TechSales RFQ Approval] DRM Attachments:', data.drmAttachmentIds.length);
+
+ try {
+ // 2. RFQ 상태를 "결재 진행중"으로 변경
+ const db = (await import('@/db/db')).default;
+ const { techSalesRfqs, TECH_SALES_RFQ_STATUSES } = await import('@/db/schema/techSales');
+ const { eq } = await import('drizzle-orm');
+
+ await db.update(techSalesRfqs)
+ .set({
+ status: TECH_SALES_RFQ_STATUSES.APPROVAL_IN_PROGRESS,
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesRfqs.id, data.rfqId));
+
+ console.log('[TechSales RFQ Approval] RFQ status updated to APPROVAL_IN_PROGRESS');
+
+ // 3. 벤더 이름 조회
+ const { getTechSalesRfqVendors } = await import('./service');
+ const vendorsResult = await getTechSalesRfqVendors(data.rfqId);
+ const vendorNames = vendorsResult.data?.filter(v => data.vendorIds.includes(v.vendorId))
+ .map(v => v.vendorName) || [];
+
+ // 4. 템플릿 변수 매핑
+ const variables = await mapTechSalesRfqSendToTemplateVariables({
+ attachments: data.drmAttachments,
+ vendorNames: vendorNames,
+ applicationReason: data.applicationReason,
+ });
+
+ // 5. 결재 상신용 payload 구성
+ const approvalPayload = {
+ rfqId: data.rfqId,
+ rfqCode: data.rfqCode,
+ vendorIds: data.vendorIds,
+ selectedContacts: data.selectedContacts,
+ drmAttachmentIds: data.drmAttachmentIds,
+ currentUser: {
+ id: data.currentUser.id,
+ name: data.currentUser.name,
+ email: data.currentUser.email,
+ epId: data.currentUser.epId,
+ },
+ };
+
+ // 6. Saga로 결재 상신
+ const saga = new ApprovalSubmissionSaga(
+ 'tech_sales_rfq_send_with_drm', // 핸들러 키
+ approvalPayload, // 결재 승인 후 실행될 데이터
+ {
+ title: `암호화해제 신청 - ${data.rfqCode || 'RFQ'}`,
+ description: `${vendorNames.length}개 업체에 DRM 첨부파일 ${data.drmAttachmentIds.length}개를 포함한 암호화해제 신청`,
+ templateName: '암호화해제 신청', // DB에 있어야 함
+ variables,
+ approvers: data.approvers,
+ currentUser: {
+ id: data.currentUser.id,
+ epId: data.currentUser.epId,
+ email: data.currentUser.email,
+ },
+ }
+ );
+
+ const result = await saga.execute();
+
+ console.log('[TechSales RFQ Approval] ✅ Approval submitted successfully');
+ console.log('[TechSales RFQ Approval] Approval ID:', result.approvalId);
+ console.log('[TechSales RFQ Approval] Pending Action ID:', result.pendingActionId);
+
+ return {
+ success: true,
+ ...result,
+ message: `결재가 상신되었습니다. (결재 ID: ${result.approvalId})`,
+ };
+
+ } catch (error) {
+ console.error('[TechSales RFQ Approval] ❌ Failed to submit approval:', error);
+ throw new Error(
+ error instanceof Error
+ ? error.message
+ : '기술영업 RFQ 발송 결재 상신에 실패했습니다.'
+ );
+ }
+}
+
+/**
+ * 기술영업 RFQ 재발송 결재 상신
+ *
+ * 이미 발송된 RFQ에 DRM 파일이 추가된 경우 재발송을 위한 결재 상신
+ */
+export async function requestRfqResendWithDrmApproval(data: {
+ rfqId: number;
+ rfqCode?: string;
+ drmFiles: Array<{
+ file: File;
+ attachmentType: string;
+ description?: string;
+ }>;
+ applicationReason: string;
+ currentUser: {
+ id: number;
+ epId: string | null;
+ name?: string;
+ email?: string;
+ };
+ approvers?: string[];
+}) {
+ if (!data.currentUser.epId) {
+ throw new Error('Knox EP ID가 필요합니다.');
+ }
+
+ console.log('[RFQ Resend Approval] Starting resend approval process');
+ console.log('[RFQ Resend Approval] RFQ ID:', data.rfqId);
+ console.log('[RFQ Resend Approval] DRM Files:', data.drmFiles.length);
+
+ try {
+ // 템플릿 변수 매핑
+ const variables = await mapTechSalesRfqSendToTemplateVariables({
+ attachments: data.drmFiles.map(f => ({
+ fileName: f.file.name,
+ fileSize: f.file.size,
+ })),
+ vendorNames: [], // 기존 벤더 목록은 후처리에서 조회
+ applicationReason: data.applicationReason,
+ });
+
+ // 결재 payload 구성
+ const approvalPayload = {
+ rfqId: data.rfqId,
+ rfqCode: data.rfqCode,
+ drmFiles: data.drmFiles,
+ currentUser: {
+ id: data.currentUser.id,
+ name: data.currentUser.name,
+ email: data.currentUser.email,
+ epId: data.currentUser.epId,
+ },
+ };
+
+ // Saga로 결재 상신
+ const saga = new ApprovalSubmissionSaga(
+ 'tech_sales_rfq_resend_with_drm', // 핸들러 키
+ approvalPayload,
+ {
+ title: `DRM 파일 재발송 결재 - ${data.rfqCode || 'RFQ'}`,
+ description: `이미 발송된 RFQ에 ${data.drmFiles.length}개의 DRM 파일이 추가되어 재발송을 요청합니다.`,
+ templateName: '암호화해제 신청',
+ variables,
+ approvers: data.approvers,
+ currentUser: {
+ id: data.currentUser.id,
+ epId: data.currentUser.epId,
+ email: data.currentUser.email,
+ },
+ }
+ );
+
+ const result = await saga.execute();
+
+ console.log('[RFQ Resend Approval] ✅ Resend approval submitted successfully');
+
+ return {
+ success: true,
+ ...result,
+ message: `재발송 결재가 상신되었습니다. (결재 ID: ${result.approvalId})`,
+ };
+
+ } catch (error) {
+ console.error('[RFQ Resend Approval] ❌ Failed to submit resend approval:', error);
+ throw new Error(
+ error instanceof Error
+ ? error.message
+ : 'RFQ 재발송 결재 상신에 실패했습니다.'
+ );
+ }
+}
+
diff --git a/lib/techsales-rfq/approval-handlers.ts b/lib/techsales-rfq/approval-handlers.ts
new file mode 100644
index 00000000..6ffd9fb4
--- /dev/null
+++ b/lib/techsales-rfq/approval-handlers.ts
@@ -0,0 +1,313 @@
+/**
+ * 기술영업 RFQ 발송 결재 핸들러
+ *
+ * DRM 파일이 있는 기술영업 RFQ 발송 시 결재 승인 후 실제 발송을 처리하는 핸들러
+ */
+
+'use server';
+
+import db from '@/db/db';
+import { eq, and } from 'drizzle-orm';
+import { techSalesAttachments, techSalesRfqs, TECH_SALES_RFQ_STATUSES } from '@/db/schema/techSales';
+import { sendTechSalesRfqToVendors } from './service';
+import { decryptWithServerAction } from '@/components/drm/drmUtils';
+import { saveFile, deleteFile } from '@/lib/file-stroage';
+
+/**
+ * 기술영업 RFQ 발송 핸들러 (결재 승인 후 자동 실행)
+ *
+ * @param payload - 결재 상신 시 저장한 RFQ 발송 데이터
+ */
+export async function sendTechSalesRfqWithApprovalInternal(payload: {
+ rfqId: number;
+ rfqCode?: string;
+ vendorIds: number[];
+ selectedContacts?: Array<{
+ vendorId: number;
+ contactId: number;
+ contactEmail: string;
+ contactName: string;
+ }>;
+ drmAttachmentIds: number[];
+ currentUser: {
+ id: string | number;
+ name?: string | null;
+ email?: string | null;
+ epId?: string | null;
+ };
+}) {
+ console.log('[TechSales RFQ Approval Handler] Starting RFQ send after approval');
+ console.log('[TechSales RFQ Approval Handler] RFQ ID:', payload.rfqId);
+ console.log('[TechSales RFQ Approval Handler] Vendors count:', payload.vendorIds.length);
+ console.log('[TechSales RFQ Approval Handler] DRM Attachments count:', payload.drmAttachmentIds.length);
+
+ try {
+ // 1. DRM 파일들 복호화 및 재저장
+ const drmAttachments = await db.query.techSalesAttachments.findMany({
+ where: and(
+ eq(techSalesAttachments.techSalesRfqId, payload.rfqId),
+ eq(techSalesAttachments.drmEncrypted, true)
+ )
+ });
+
+ console.log(`[TechSales RFQ Approval Handler] Found ${drmAttachments.length} DRM files to decrypt`);
+
+ for (const attachment of drmAttachments) {
+ try {
+ // DRM 파일 다운로드
+ const fileResponse = await fetch(attachment.filePath);
+ if (!fileResponse.ok) {
+ console.error(`[TechSales RFQ Approval Handler] Failed to fetch file: ${attachment.filePath}`);
+ continue;
+ }
+
+ const fileBlob = await fileResponse.blob();
+ const file = new File([fileBlob], attachment.originalFileName);
+
+ // DRM 복호화
+ console.log(`[TechSales RFQ Approval Handler] Decrypting: ${attachment.originalFileName}`);
+ const decryptedBuffer = await decryptWithServerAction(file);
+
+ // 복호화된 파일로 재저장
+ const saveResult = await saveFile({
+ file: new File([decryptedBuffer], attachment.originalFileName),
+ directory: `techsales-rfq/${payload.rfqId}`,
+ userId: String(payload.currentUser.id),
+ });
+
+ if (!saveResult.success) {
+ throw new Error(saveResult.error || '파일 저장 실패');
+ }
+
+ // 기존 파일 삭제
+ await deleteFile(attachment.filePath);
+
+ // DB 업데이트: drmEncrypted = false, filePath 업데이트
+ await db.update(techSalesAttachments)
+ .set({
+ drmEncrypted: false,
+ filePath: saveResult.publicPath!,
+ fileName: saveResult.fileName!,
+ })
+ .where(eq(techSalesAttachments.id, attachment.id));
+
+ console.log(`[TechSales RFQ Approval Handler] ✅ Decrypted and saved: ${attachment.originalFileName}`);
+ } catch (error) {
+ console.error(`[TechSales RFQ Approval Handler] ❌ Failed to decrypt ${attachment.originalFileName}:`, error);
+ throw error;
+ }
+ }
+
+ // 2. RFQ 상태를 "RFQ Sent"로 변경
+ await db.update(techSalesRfqs)
+ .set({
+ status: TECH_SALES_RFQ_STATUSES.RFQ_SENT,
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesRfqs.id, payload.rfqId));
+
+ // 3. 실제 RFQ 발송 실행
+ const sendResult = await sendTechSalesRfqToVendors({
+ rfqId: payload.rfqId,
+ vendorIds: payload.vendorIds,
+ selectedContacts: payload.selectedContacts,
+ });
+
+ console.log('[TechSales RFQ Approval Handler] ✅ RFQ sent successfully after DRM decryption');
+
+ return {
+ success: true,
+ ...sendResult,
+ };
+ } catch (error) {
+ console.error('[TechSales RFQ Approval Handler] ❌ Failed to send RFQ:', error);
+ throw new Error(
+ error instanceof Error
+ ? `RFQ 발송 실패: ${error.message}`
+ : 'RFQ 발송 중 오류가 발생했습니다.'
+ );
+ }
+}
+
+/**
+ * 기술영업 RFQ 재발송 핸들러 (결재 승인 후 자동 실행)
+ *
+ * 이미 발송된 RFQ에 DRM 파일이 추가된 경우 재발송 처리
+ */
+export async function resendTechSalesRfqWithDrmInternal(payload: {
+ rfqId: number;
+ rfqCode?: string;
+ drmFiles: Array<{
+ file: File;
+ attachmentType: string;
+ description?: string;
+ }>;
+ currentUser: {
+ id: string | number;
+ name?: string | null;
+ email?: string | null;
+ epId?: string | null;
+ };
+}) {
+ console.log('[TechSales RFQ Resend Handler] Starting DRM resend after approval');
+ console.log('[TechSales RFQ Resend Handler] RFQ ID:', payload.rfqId);
+ console.log('[TechSales RFQ Resend Handler] DRM Files:', payload.drmFiles.length);
+
+ try {
+ // 1. 새로 추가된 DRM 파일들 복호화 및 저장
+ for (const drmFile of payload.drmFiles) {
+ try {
+ // DRM 복호화
+ console.log(`[TechSales RFQ Resend Handler] Decrypting: ${drmFile.file.name}`);
+ const decryptedBuffer = await decryptWithServerAction(drmFile.file);
+
+ // 복호화된 파일 저장
+ const saveResult = await saveFile({
+ file: new File([decryptedBuffer], drmFile.file.name),
+ directory: `techsales-rfq/${payload.rfqId}`,
+ userId: String(payload.currentUser.id),
+ });
+
+ if (!saveResult.success) {
+ throw new Error(saveResult.error || '파일 저장 실패');
+ }
+
+ // 기존 DRM 파일 레코드 찾기 및 업데이트
+ const existingAttachment = await db.query.techSalesAttachments.findFirst({
+ where: and(
+ eq(techSalesAttachments.techSalesRfqId, payload.rfqId),
+ eq(techSalesAttachments.originalFileName, drmFile.file.name),
+ eq(techSalesAttachments.drmEncrypted, true)
+ )
+ });
+
+ if (existingAttachment) {
+ // 기존 파일 삭제
+ await deleteFile(existingAttachment.filePath);
+
+ // DB 업데이트: drmEncrypted = false, filePath 업데이트
+ await db.update(techSalesAttachments)
+ .set({
+ drmEncrypted: false,
+ filePath: saveResult.publicPath!,
+ fileName: saveResult.fileName!,
+ })
+ .where(eq(techSalesAttachments.id, existingAttachment.id));
+ } else {
+ // 새 레코드 생성
+ await db.insert(techSalesAttachments).values({
+ techSalesRfqId: payload.rfqId,
+ attachmentType: drmFile.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT",
+ fileName: saveResult.fileName!,
+ originalFileName: drmFile.file.name,
+ filePath: saveResult.publicPath!,
+ fileSize: decryptedBuffer.byteLength,
+ fileType: drmFile.file.type || undefined,
+ description: drmFile.description,
+ drmEncrypted: false, // DRM 해제됨
+ createdBy: Number(payload.currentUser.id),
+ });
+ }
+
+ console.log(`[TechSales RFQ Resend Handler] ✅ Decrypted and saved: ${drmFile.file.name}`);
+ } catch (error) {
+ console.error(`[TechSales RFQ Resend Handler] ❌ Failed to decrypt ${drmFile.file.name}:`, error);
+ throw error;
+ }
+ }
+
+ // 2. RFQ 상태를 "RFQ Sent"로 변경
+ await db.update(techSalesRfqs)
+ .set({
+ status: TECH_SALES_RFQ_STATUSES.RFQ_SENT,
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesRfqs.id, payload.rfqId));
+
+ // 3. RFQ 재발송 실행 (기존에 할당된 모든 벤더에게)
+ const { getTechSalesRfqVendors } = await import('./service');
+ const vendorsResult = await getTechSalesRfqVendors(payload.rfqId);
+ const vendorIds = vendorsResult.data?.map(v => v.vendorId) || [];
+
+ const sendResult = await sendTechSalesRfqToVendors({
+ rfqId: payload.rfqId,
+ vendorIds: vendorIds,
+ });
+
+ console.log('[TechSales RFQ Resend Handler] ✅ RFQ resent successfully after DRM decryption');
+
+ return {
+ success: true,
+ ...sendResult,
+ };
+ } catch (error) {
+ console.error('[TechSales RFQ Resend Handler] ❌ Failed to resend RFQ:', error);
+ throw new Error(
+ error instanceof Error
+ ? `RFQ 재발송 실패: ${error.message}`
+ : 'RFQ 재발송 중 오류가 발생했습니다.'
+ );
+ }
+}
+
+/**
+ * 템플릿 변수 매핑 함수
+ * 기술영업 RFQ 발송 정보를 결재 템플릿 변수로 변환
+ */
+export async function mapTechSalesRfqSendToTemplateVariables(data: {
+ attachments: Array<{
+ fileName?: string | null;
+ fileSize?: number | null;
+ }>;
+ vendorNames: string[];
+ applicationReason: string;
+}) {
+ const { htmlTableConverter, htmlListConverter } = await import('@/lib/approval/template-utils');
+
+ // 파일 크기를 읽기 쉬운 형식으로 변환
+ const formatFileSize = (bytes?: number | null): string => {
+ if (!bytes || bytes === 0) return '-';
+
+ const units = ['B', 'KB', 'MB', 'GB'];
+ let size = bytes;
+ let unitIndex = 0;
+
+ while (size >= 1024 && unitIndex < units.length - 1) {
+ size /= 1024;
+ unitIndex++;
+ }
+
+ return `${size.toFixed(2)} ${units[unitIndex]}`;
+ };
+
+ // 첨부파일 테이블 데이터 준비 (순번, 파일명, 파일크기만)
+ const attachmentTableData = data.attachments.map((att, index) => ({
+ '순번': String(index + 1),
+ '파일명': att.fileName || '-',
+ '파일 크기': formatFileSize(att.fileSize),
+ }));
+
+ // 첨부파일 테이블 HTML 생성
+ const attachmentTableHtml = await htmlTableConverter(
+ attachmentTableData.length > 0 ? attachmentTableData : [],
+ [
+ { key: '순번', label: '순번' },
+ { key: '파일명', label: '파일명' },
+ { key: '파일 크기', label: '파일 크기' },
+ ]
+ );
+
+ // 제출처 (벤더 이름들) HTML 생성
+ const vendorListHtml = await htmlListConverter(
+ data.vendorNames.length > 0
+ ? data.vendorNames
+ : ['제출처가 없습니다.']
+ );
+
+ return {
+ '파일 테이블': attachmentTableHtml,
+ '제출처': vendorListHtml,
+ '신청사유': data.applicationReason || '사유 없음',
+ };
+}
+
diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts
index 9a198ee5..dc5950e0 100644
--- a/lib/techsales-rfq/service.ts
+++ b/lib/techsales-rfq/service.ts
@@ -36,8 +36,9 @@ import { sendEmail } from "../mail/sendEmail";
import { formatDate } from "../utils";
import { itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items";
import { techVendors, techVendorPossibleItems, techVendorContacts } from "@/db/schema/techVendors";
-import { deleteFile, saveDRMFile, saveFile } from "@/lib/file-stroage";
-import { decryptWithServerAction } from "@/components/drm/drmUtils";
+import { deleteFile, saveFile } from "@/lib/file-stroage";
+import { decryptWithServerAction, isDRMFile } from "@/components/drm/drmUtils";
+import { TECH_SALES_RFQ_STATUSES } from "@/db/schema/techSales";
// RFQ 아이템 정보 타입
interface RfqItemInfo {
itemCode: string;
@@ -558,15 +559,25 @@ export async function sendTechSalesRfqToVendors(input: {
};
}
- // 발송 가능한 상태인지 확인
- if (rfq.status !== "RFQ Vendor Assignned" && rfq.status !== "RFQ Sent") {
+ // 발송 가능한 상태인지 확인 (결재 진행중 상태는 제외)
+ if (rfq.status !== TECH_SALES_RFQ_STATUSES.RFQ_VENDOR_ASSIGNED &&
+ rfq.status !== TECH_SALES_RFQ_STATUSES.RFQ_SENT &&
+ rfq.status !== TECH_SALES_RFQ_STATUSES.APPROVAL_IN_PROGRESS) {
return {
success: false,
message: "벤더가 할당된 RFQ 또는 이미 전송된 RFQ만 다시 전송할 수 있습니다",
};
}
+
+ // 결재 진행중 상태에서는 결재 승인 후처리 핸들러에서만 발송 가능
+ if (rfq.status === TECH_SALES_RFQ_STATUSES.APPROVAL_IN_PROGRESS) {
+ return {
+ success: false,
+ message: "결재 진행 중인 RFQ는 결재 승인 후 자동으로 발송됩니다",
+ };
+ }
- const isResend = rfq.status === "RFQ Sent";
+ const isResend = rfq.status === TECH_SALES_RFQ_STATUSES.RFQ_SENT;
// 현재 사용자 정보 조회
const sender = await db.query.users.findFirst({
@@ -615,11 +626,39 @@ export async function sendTechSalesRfqToVendors(input: {
};
}
+ // RFQ 첨부파일 중 DRM 파일 확인
+ const attachments = await db.query.techSalesAttachments.findMany({
+ where: eq(techSalesAttachments.techSalesRfqId, input.rfqId),
+ columns: {
+ id: true,
+ drmEncrypted: true,
+ fileName: true,
+ fileSize: true,
+ originalFileName: true,
+ }
+ });
+
+ const drmAttachments = attachments.filter(att => att.drmEncrypted === true);
+
+ // DRM 파일이 있으면 결재 프로세스 필요 (이 함수는 직접 발송하지 않고 결재 필요 신호 반환)
+ if (drmAttachments.length > 0) {
+ return {
+ success: false,
+ requiresApproval: true,
+ message: "DRM 파일이 포함되어 있어 결재가 필요합니다",
+ drmAttachmentIds: drmAttachments.map(att => att.id),
+ drmAttachments: drmAttachments.map(att => ({
+ fileName: att.originalFileName || att.fileName,
+ fileSize: att.fileSize,
+ })),
+ };
+ }
+
// 트랜잭션 시작
await db.transaction(async (tx) => {
// 1. RFQ 상태 업데이트 (최초 발송인 경우 rfqSendDate 설정)
const updateData: Partial<typeof techSalesRfqs.$inferInsert> = {
- status: "RFQ Sent",
+ status: TECH_SALES_RFQ_STATUSES.RFQ_SENT,
sentBy: Number(session.user.id),
updatedBy: Number(session.user.id),
updatedAt: new Date(),
@@ -1544,13 +1583,15 @@ export async function createTechSalesRfqAttachments(params: {
await db.transaction(async (tx) => {
for (const file of files) {
-
+ // DRM 파일 검출
+ const isDrmFile = await isDRMFile(file);
- const saveResult = await saveDRMFile(
+ // saveFile로 변경 (DRM 복호화하지 않고 원본 저장)
+ const saveResult = await saveFile({
file,
- decryptWithServerAction,
- `techsales-rfq/${techSalesRfqId}`
- );
+ directory: `techsales-rfq/${techSalesRfqId}`,
+ userId: String(createdBy),
+ });
if (!saveResult.success) {
throw new Error(saveResult.error || "파일 저장에 실패했습니다.");
@@ -1566,6 +1607,7 @@ export async function createTechSalesRfqAttachments(params: {
fileSize: file.size,
fileType: file.type || undefined,
description: description || undefined,
+ drmEncrypted: isDrmFile, // DRM 파일 여부 저장
createdBy,
}).returning();
@@ -1653,6 +1695,39 @@ export async function getTechSalesRfqAttachmentsByType(
}
/**
+ * 벤더용 RFQ 첨부파일 조회 (DRM 해제된 파일만 반환)
+ */
+export async function getRfqAttachmentsForVendor(techSalesRfqId: number) {
+ unstable_noStore();
+ try {
+ // DRM 해제된 파일만 조회 (drmEncrypted = false)
+ const attachments = await db.query.techSalesAttachments.findMany({
+ where: and(
+ eq(techSalesAttachments.techSalesRfqId, techSalesRfqId),
+ eq(techSalesAttachments.drmEncrypted, false)
+ ),
+ orderBy: [desc(techSalesAttachments.createdAt)],
+ columns: {
+ id: true,
+ fileName: true,
+ originalFileName: true,
+ filePath: true,
+ fileSize: true,
+ fileType: true,
+ attachmentType: true,
+ description: true,
+ createdAt: true,
+ }
+ });
+
+ return { data: attachments, error: null };
+ } catch (err) {
+ console.error("벤더용 기술영업 RFQ 첨부파일 조회 오류:", err);
+ return { data: [], error: getErrorMessage(err) };
+ }
+}
+
+/**
* 기술영업 RFQ 첨부파일 삭제
*/
export async function deleteTechSalesRfqAttachment(attachmentId: number) {
@@ -1834,17 +1909,23 @@ export async function processTechSalesRfqAttachments(params: {
}
// 2. 새 파일 업로드 처리
- if (newFiles.length > 0) {
- for (const { file, attachmentType, description } of newFiles) {
- const saveResult = await saveDRMFile(
- file,
- decryptWithServerAction,
- `techsales-rfq/${techSalesRfqId}`
- );
+ if (newFiles.length > 0) {
+ const drmFiles: Array<{ file: File; attachmentType: string; description?: string }> = [];
+
+ for (const { file, attachmentType, description } of newFiles) {
+ // DRM 파일 검출
+ const isDrmFile = await isDRMFile(file);
+
+ // saveFile로 변경 (DRM 복호화하지 않고 원본 저장)
+ const saveResult = await saveFile({
+ file,
+ directory: `techsales-rfq/${techSalesRfqId}`,
+ userId: String(createdBy),
+ });
- if (!saveResult.success) {
- throw new Error(saveResult.error || "파일 저장에 실패했습니다.");
- }
+ if (!saveResult.success) {
+ throw new Error(saveResult.error || "파일 저장에 실패했습니다.");
+ }
// DB에 첨부파일 레코드 생성
const [newAttachment] = await tx.insert(techSalesAttachments).values({
@@ -1856,10 +1937,22 @@ export async function processTechSalesRfqAttachments(params: {
fileSize: file.size,
fileType: file.type || undefined,
description: description || undefined,
+ drmEncrypted: isDrmFile, // DRM 파일 여부 저장
createdBy,
}).returning();
results.uploaded.push(newAttachment);
+
+ // DRM 파일인 경우 목록에 추가
+ if (isDrmFile) {
+ drmFiles.push({ file, attachmentType, description });
+ }
+ }
+
+ // RFQ 상태가 "RFQ Sent"이고 DRM 파일이 추가된 경우 재발송 결재 트리거
+ if (rfq.status === TECH_SALES_RFQ_STATUSES.RFQ_SENT && drmFiles.length > 0) {
+ // 트랜잭션 외부에서 처리하기 위해 에러로 전달
+ throw new Error("DRM_FILE_ADDED_TO_SENT_RFQ");
}
}
});
@@ -1878,6 +1971,16 @@ export async function processTechSalesRfqAttachments(params: {
};
} catch (err) {
console.error("기술영업 RFQ 첨부파일 일괄 처리 오류:", err);
+
+ // DRM 파일 추가로 인한 재발송 결재 필요 에러
+ if (err instanceof Error && err.message === "DRM_FILE_ADDED_TO_SENT_RFQ") {
+ return {
+ data: null,
+ error: "DRM_FILE_ADDED_TO_SENT_RFQ",
+ rfqId: techSalesRfqId,
+ };
+ }
+
return { data: null, error: getErrorMessage(err) };
}
}
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
index 8ce55d56..52758412 100644
--- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
@@ -22,6 +22,11 @@ import { TechSalesQuotationAttachmentsSheet, type QuotationAttachment } from "..
import type { QuotationInfo } from "./rfq-detail-column"
import { VendorContactSelectionDialog } from "./vendor-contact-selection-dialog"
import { QuotationContactsViewDialog } from "./quotation-contacts-view-dialog"
+import { ApprovalPreviewDialog } from "@/lib/approval/client"
+import { ApplicationReasonDialog } from "@/lib/rfq-last/vendor/application-reason-dialog"
+import { requestTechSalesRfqSendWithApproval } from "@/lib/techsales-rfq/approval-actions"
+import { mapTechSalesRfqSendToTemplateVariables } from "@/lib/techsales-rfq/approval-handlers"
+import { useSession } from "next-auth/react"
// 기본적인 RFQ 타입 정의
interface TechSalesRfq {
@@ -48,6 +53,8 @@ interface RfqDetailTablesProps {
export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps) {
// console.log("selectedRfq", selectedRfq)
+ const session = useSession()
+
// 상태 관리
const [isLoading, setIsLoading] = useState(false)
const [details, setDetails] = useState<RfqDetailView[]>([])
@@ -89,6 +96,29 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
const [contactsDialogOpen, setContactsDialogOpen] = useState(false)
const [selectedQuotationForContacts, setSelectedQuotationForContacts] = useState<{ id: number; vendorName?: string } | null>(null)
+ // 결재 관련 상태 관리
+ const [showApplicationReasonDialog, setShowApplicationReasonDialog] = useState(false)
+ const [showApprovalPreview, setShowApprovalPreview] = useState(false)
+ const [approvalPreviewData, setApprovalPreviewData] = useState<{
+ vendors: Array<{
+ vendorId: number
+ vendorName: string
+ }>
+ drmAttachments: Array<{
+ fileName?: string | null
+ fileSize?: number | null
+ }>
+ drmAttachmentIds: number[]
+ selectedContacts?: Array<{
+ vendorId: number
+ contactId: number
+ contactEmail: string
+ contactName: string
+ }>
+ templateVariables?: Record<string, string>
+ applicationReason?: string
+ } | null>(null)
+
// selectedRfq ID 메모이제이션 (객체 참조 변경 방지)
const selectedRfqId = useMemo(() => selectedRfq?.id, [selectedRfq?.id])
@@ -239,6 +269,25 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
selectedContacts: selectedContacts
});
+ // DRM 파일이 있어서 결재가 필요한 경우
+ if (!result.success && result.requiresApproval) {
+ // 결재 데이터 저장
+ setApprovalPreviewData({
+ vendors: selectedRows.map(row => ({
+ vendorId: row.vendorId!,
+ vendorName: row.vendorName || "",
+ })),
+ drmAttachments: result.drmAttachments || [],
+ drmAttachmentIds: result.drmAttachmentIds || [],
+ selectedContacts: selectedContacts,
+ });
+
+ // 신청사유 입력 다이얼로그 표시
+ setShowApplicationReasonDialog(true);
+ setIsSendingRfq(false);
+ return;
+ }
+
if (result.success) {
toast.success(result.message || `${selectedContacts.length}명의 연락처에게 RFQ가 발송되었습니다.`);
} else {
@@ -412,6 +461,84 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
setContactsDialogOpen(true)
}, [])
+ // 신청사유 입력 완료 핸들러
+ const handleApplicationReasonConfirm = useCallback(async (reason: string) => {
+ if (!approvalPreviewData) {
+ toast.error("결재 데이터가 없습니다.");
+ return;
+ }
+
+ try {
+ // 템플릿 변수 생성 (신청사유 포함)
+ const templateVariables = await mapTechSalesRfqSendToTemplateVariables({
+ attachments: approvalPreviewData.drmAttachments,
+ vendorNames: approvalPreviewData.vendors.map(v => v.vendorName),
+ applicationReason: reason,
+ });
+
+ // 결재 미리보기 데이터 업데이트
+ setApprovalPreviewData({
+ ...approvalPreviewData,
+ templateVariables,
+ applicationReason: reason,
+ });
+
+ // 신청사유 다이얼로그 닫고 결재 미리보기 열기
+ setShowApplicationReasonDialog(false);
+ setShowApprovalPreview(true);
+ } catch (error) {
+ console.error("템플릿 변수 생성 실패:", error);
+ toast.error("결재 문서 생성에 실패했습니다.");
+ }
+ }, [approvalPreviewData]);
+
+ // 결재 미리보기 확인 핸들러
+ const handleApprovalConfirm = useCallback(async (approvalData: {
+ approvers: string[];
+ title: string;
+ description?: string;
+ }) => {
+ if (!approvalPreviewData || !selectedRfq || !session.data?.user) {
+ toast.error("결재 데이터가 없습니다.");
+ return;
+ }
+
+ if (!session.data.user.epId) {
+ toast.error("Knox EP ID가 필요합니다.");
+ return;
+ }
+
+ try {
+ const result = await requestTechSalesRfqSendWithApproval({
+ rfqId: selectedRfq.id,
+ rfqCode: selectedRfq.rfqCode || undefined,
+ vendorIds: approvalPreviewData.vendors.map(v => v.vendorId),
+ selectedContacts: approvalPreviewData.selectedContacts,
+ drmAttachmentIds: approvalPreviewData.drmAttachmentIds,
+ drmAttachments: approvalPreviewData.drmAttachments,
+ applicationReason: approvalPreviewData.applicationReason || '',
+ currentUser: {
+ id: Number(session.data.user.id),
+ epId: session.data.user.epId || null,
+ name: session.data.user.name || undefined,
+ email: session.data.user.email || undefined,
+ },
+ approvers: approvalData.approvers,
+ });
+
+ if (result.success) {
+ toast.success(result.message);
+ setShowApprovalPreview(false);
+ setApprovalPreviewData(null);
+ setSelectedRows([]);
+ await handleRefreshData();
+ }
+ } catch (error) {
+ console.error("결재 상신 실패:", error);
+ toast.error(error instanceof Error ? error.message : "결재 상신에 실패했습니다.");
+ }
+ }, [approvalPreviewData, selectedRfq, session, handleRefreshData]);
+
// 칼럼 정의 - unreadMessages 상태 전달 (메모이제이션)
const columns = useMemo(() =>
getRfqDetailColumns({
diff --git a/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet-copy-1118.tsx b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet-copy-1118.tsx
new file mode 100644
index 00000000..82f83b7c
--- /dev/null
+++ b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet-copy-1118.tsx
@@ -0,0 +1,710 @@
+"use client"
+
+import * as React from "react"
+import { z } from "zod"
+import { useForm, useFieldArray } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetDescription,
+ SheetFooter,
+ SheetClose,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription
+} from "@/components/ui/form"
+import { Loader, Download, X, Eye, AlertCircle } from "lucide-react"
+import { toast } from "sonner"
+import { Badge } from "@/components/ui/badge"
+
+import {
+ Dropzone,
+ DropzoneDescription,
+ DropzoneInput,
+ DropzoneTitle,
+ DropzoneUploadIcon,
+ DropzoneZone,
+} from "@/components/ui/dropzone"
+import {
+ FileList,
+ FileListAction,
+ FileListDescription,
+ FileListHeader,
+ FileListIcon,
+ FileListInfo,
+ FileListItem,
+ FileListName,
+} from "@/components/ui/file-list"
+
+import prettyBytes from "pretty-bytes"
+import { formatDate } from "@/lib/utils"
+import { processTechSalesRfqAttachments } from "@/lib/techsales-rfq/service"
+import { useSession } from "next-auth/react"
+import { ApprovalPreviewDialog } from "@/lib/approval/client"
+import { ApplicationReasonDialog } from "@/lib/rfq-last/vendor/application-reason-dialog"
+import { requestRfqResendWithDrmApproval } from "@/lib/techsales-rfq/approval-actions"
+import { mapTechSalesRfqSendToTemplateVariables } from "@/lib/techsales-rfq/approval-handlers"
+
+const MAX_FILE_SIZE = 6e8 // 600MB
+
+/** 기존 첨부 파일 정보 (techSalesAttachments 테이블 구조) */
+export interface ExistingTechSalesAttachment {
+ id: number
+ techSalesRfqId: number
+ fileName: string
+ originalFileName: string
+ filePath: string
+ fileSize?: number
+ fileType?: string
+ attachmentType: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
+ description?: string
+ createdBy: number
+ createdAt: Date
+}
+
+/** 새로 업로드할 파일 */
+const newUploadSchema = z.object({
+ fileObj: z.any().optional(), // 실제 File
+ attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]).default("RFQ_COMMON"),
+ description: z.string().optional(),
+})
+
+/** 기존 첨부 (react-hook-form에서 관리) */
+const existingAttachSchema = z.object({
+ id: z.number(),
+ techSalesRfqId: z.number(),
+ fileName: z.string(),
+ originalFileName: z.string(),
+ filePath: z.string(),
+ fileSize: z.number().optional(),
+ fileType: z.string().optional(),
+ attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]),
+ description: z.string().optional(),
+ createdBy: z.number(),
+ createdAt: z.custom<Date>(),
+})
+
+/** RHF 폼 전체 스키마 */
+const attachmentsFormSchema = z.object({
+ techSalesRfqId: z.number().int(),
+ existing: z.array(existingAttachSchema),
+ newUploads: z.array(newUploadSchema),
+})
+
+type AttachmentsFormValues = z.infer<typeof attachmentsFormSchema>
+
+// TechSalesRfq 타입 (간단 버전)
+interface TechSalesRfq {
+ id: number
+ rfqCode: string | null
+ status: string
+ // 필요한 다른 필드들...
+}
+
+interface TechSalesRfqAttachmentsSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ defaultAttachments?: ExistingTechSalesAttachment[]
+ rfq: TechSalesRfq | null
+ /** 첨부파일 타입 */
+ attachmentType?: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
+ /** 읽기 전용 모드 (벤더용) */
+ readOnly?: boolean
+ /** 업로드/삭제 후 상위 테이블에 attachmentCount 등을 업데이트하기 위한 콜백 */
+ // onAttachmentsUpdated?: (rfqId: number, newAttachmentCount: number) => void
+
+}
+
+export function TechSalesRfqAttachmentsSheet({
+ defaultAttachments = [],
+ // onAttachmentsUpdated,
+ rfq,
+ attachmentType = "RFQ_COMMON",
+ readOnly = false,
+ ...props
+}: TechSalesRfqAttachmentsSheetProps) {
+ const [isPending, setIsPending] = React.useState(false)
+ const session = useSession()
+
+ // 재발송 결재 관련 상태
+ const [showResendApprovalDialog, setShowResendApprovalDialog] = React.useState(false)
+ const [showApplicationReasonDialog, setShowApplicationReasonDialog] = React.useState(false)
+ const [resendApprovalData, setResendApprovalData] = React.useState<{
+ rfqId: number
+ drmFiles: Array<{
+ file: File
+ attachmentType: string
+ description?: string
+ }>
+ } | null>(null)
+ const [approvalPreviewData, setApprovalPreviewData] = React.useState<{
+ templateVariables: Record<string, string>
+ applicationReason: string
+ } | null>(null)
+
+ // 파일 다운로드 핸들러
+ const handleDownloadClick = React.useCallback(async (filePath: string, fileName: string) => {
+ try {
+ const { downloadFile } = await import('@/lib/file-download')
+ await downloadFile(filePath, fileName, {
+ showToast: true,
+ onError: (error) => {
+ console.error('다운로드 오류:', error)
+ toast.error(error)
+ },
+ onSuccess: (fileName, fileSize) => {
+ console.log(`다운로드 성공: ${fileName} (${fileSize} bytes)`)
+ }
+ })
+ } catch (error) {
+ console.error('다운로드 오류:', error)
+ toast.error('파일 다운로드 중 오류가 발생했습니다.')
+ }
+ }, [])
+ // 첨부파일 타입별 제목과 설명 설정
+ const attachmentConfig = React.useMemo(() => {
+ switch (attachmentType) {
+ case "TBE_RESULT":
+ return {
+ title: "TBE 결과 첨부파일",
+ description: "기술 평가(TBE) 결과 파일을 관리합니다.",
+ fileTypeLabel: "TBE 결과",
+ canEdit: !readOnly
+ }
+ case "CBE_RESULT":
+ return {
+ title: "CBE 결과 첨부파일",
+ description: "상업성 평가(CBE) 결과 파일을 관리합니다.",
+ fileTypeLabel: "CBE 결과",
+ canEdit: !readOnly
+ }
+ default: // RFQ_COMMON
+ return {
+ title: "RFQ 첨부파일",
+ description: readOnly ? "RFQ 공통 첨부파일을 조회합니다." : "RFQ 공통 첨부파일을 관리합니다.",
+ fileTypeLabel: "공통",
+ canEdit: !readOnly
+ }
+ }
+ }, [attachmentType, readOnly])
+
+ // // RFQ 상태에 따른 편집 가능 여부 결정 (readOnly prop이 true면 항상 false)
+ // const isEditable = React.useMemo(() => {
+ // if (!rfq) return false
+ // return attachmentConfig.canEdit
+ // }, [rfq, attachmentConfig.canEdit])
+
+ const form = useForm<AttachmentsFormValues>({
+ resolver: zodResolver(attachmentsFormSchema),
+ defaultValues: {
+ techSalesRfqId: rfq?.id || 0,
+ existing: defaultAttachments.map(att => ({
+ id: att.id,
+ techSalesRfqId: att.techSalesRfqId,
+ fileName: att.fileName,
+ originalFileName: att.originalFileName,
+ filePath: att.filePath,
+ fileSize: att.fileSize || undefined,
+ fileType: att.fileType || undefined,
+ attachmentType: att.attachmentType,
+ description: att.description || undefined,
+ createdBy: att.createdBy,
+ createdAt: att.createdAt,
+ })),
+ newUploads: [],
+ },
+ })
+
+ // useFieldArray for existing and new uploads
+ const {
+ fields: existingFields,
+ remove: removeExisting,
+ } = useFieldArray({
+ control: form.control,
+ name: "existing",
+ })
+
+ const {
+ fields: newUploadFields,
+ append: appendNewUpload,
+ remove: removeNewUpload,
+ } = useFieldArray({
+ control: form.control,
+ name: "newUploads",
+ })
+
+ // Reset form when defaultAttachments changes
+ React.useEffect(() => {
+ if (defaultAttachments) {
+ form.reset({
+ techSalesRfqId: rfq?.id || 0,
+ existing: defaultAttachments.map(att => ({
+ id: att.id,
+ techSalesRfqId: att.techSalesRfqId,
+ fileName: att.fileName,
+ originalFileName: att.originalFileName,
+ filePath: att.filePath,
+ fileSize: att.fileSize || undefined,
+ fileType: att.fileType || undefined,
+ attachmentType: att.attachmentType,
+ description: att.description || undefined,
+ createdBy: att.createdBy,
+ createdAt: att.createdAt,
+ })),
+ newUploads: [],
+ })
+ }
+ }, [defaultAttachments, rfq?.id, form])
+
+ // Handle dropzone accept
+ const handleDropAccepted = React.useCallback((acceptedFiles: File[]) => {
+ acceptedFiles.forEach((file) => {
+ appendNewUpload({
+ fileObj: file,
+ attachmentType: "RFQ_COMMON",
+ description: "",
+ })
+ })
+ }, [appendNewUpload])
+
+ // Handle dropzone reject
+ const handleDropRejected = React.useCallback(() => {
+ toast.error("파일 크기가 너무 크거나 지원하지 않는 파일 형식입니다.")
+ }, [])
+
+ // Handle remove existing attachment
+ const handleRemoveExisting = React.useCallback((index: number) => {
+ removeExisting(index)
+ }, [removeExisting])
+
+ // Handle form submission
+ const onSubmit = async (data: AttachmentsFormValues) => {
+ if (!rfq) {
+ toast.error("RFQ 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ setIsPending(true)
+ try {
+ // 삭제할 첨부파일 ID 수집
+ const deleteAttachmentIds = defaultAttachments
+ .filter((original) => !data.existing.find(existing => existing.id === original.id))
+ .map(attachment => attachment.id)
+
+ // 새 파일 정보 수집
+ const newFiles = data.newUploads
+ .filter(upload => upload.fileObj)
+ .map(upload => ({
+ file: upload.fileObj as File,
+ attachmentType: attachmentType,
+ description: upload.description,
+ }))
+
+ // 실제 API 호출
+ const result = await processTechSalesRfqAttachments({
+ techSalesRfqId: rfq.id,
+ newFiles,
+ deleteAttachmentIds,
+ createdBy: parseInt(session.data?.user.id || "0"),
+ })
+
+ if (result.error) {
+ // DRM 파일 추가로 인한 재발송 결재 필요
+ if (result.error === "DRM_FILE_ADDED_TO_SENT_RFQ") {
+ // DRM 파일만 필터링
+ const drmFiles = newFiles.filter((_, index) => {
+ // DRM 파일 검출은 서버에서 이미 완료되었으므로, 업로드된 파일 중 DRM 파일만 추출
+ // 실제로는 서버에서 반환된 정보를 사용해야 하지만, 여기서는 업로드된 파일을 그대로 사용
+ return true // 임시로 모든 새 파일을 DRM 파일로 간주 (실제로는 서버에서 필터링 필요)
+ })
+
+ setResendApprovalData({
+ rfqId: rfq.id,
+ drmFiles: newFiles, // 모든 새 파일을 DRM 파일로 간주
+ })
+ setShowApplicationReasonDialog(true)
+ setIsPending(false)
+ return
+ } else {
+ toast.error(result.error)
+ return
+ }
+ }
+
+ // 성공 메시지 표시 (업로드된 파일 수 포함)
+ const uploadedCount = newFiles.length
+ const deletedCount = deleteAttachmentIds.length
+
+ let successMessage = "첨부파일이 저장되었습니다."
+ if (uploadedCount > 0 && deletedCount > 0) {
+ successMessage = `${uploadedCount}개 파일 업로드, ${deletedCount}개 파일 삭제 완료`
+ } else if (uploadedCount > 0) {
+ successMessage = `${uploadedCount}개 파일이 업로드되었습니다.`
+ } else if (deletedCount > 0) {
+ successMessage = `${deletedCount}개 파일이 삭제되었습니다.`
+ }
+
+ toast.success(successMessage)
+
+ // 다이얼로그 자동 닫기
+ props.onOpenChange?.(false)
+
+ // // 즉시 첨부파일 목록 새로고침
+ // const refreshResult = await getTechSalesRfqAttachments(rfq.id)
+ // if (refreshResult.error) {
+ // console.error("첨부파일 목록 새로고침 실패:", refreshResult.error)
+ // toast.warning("첨부파일 목록 새로고침에 실패했습니다. 시트를 다시 열어주세요.")
+ // } else {
+ // // 새로운 첨부파일 목록으로 폼 업데이트
+ // const refreshedAttachments = refreshResult.data.map(att => ({
+ // id: att.id,
+ // techSalesRfqId: att.techSalesRfqId || rfq.id,
+ // fileName: att.fileName,
+ // originalFileName: att.originalFileName,
+ // filePath: att.filePath,
+ // fileSize: att.fileSize,
+ // fileType: att.fileType,
+ // attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT",
+ // description: att.description,
+ // createdBy: att.createdBy,
+ // createdAt: att.createdAt,
+ // }))
+
+ // // 폼을 새로운 데이터로 리셋 (새 업로드 목록은 비움)
+ // form.reset({
+ // techSalesRfqId: rfq.id,
+ // existing: refreshedAttachments.map(att => ({
+ // ...att,
+ // fileSize: att.fileSize || undefined,
+ // fileType: att.fileType || undefined,
+ // description: att.description || undefined,
+ // })),
+ // newUploads: [],
+ // })
+
+ // // 즉시 UI 업데이트를 위한 추가 피드백
+ // if (uploadedCount > 0) {
+ // toast.success("첨부파일 목록이 업데이트되었습니다.", { duration: 2000 })
+ // }
+ // }
+
+ // // 콜백으로 상위 컴포넌트에 변경사항 알림
+ // const newAttachmentCount = refreshResult.error ?
+ // (data.existing.length + newFiles.length - deleteAttachmentIds.length) :
+ // refreshResult.data.length
+ // onAttachmentsUpdated?.(rfq.id, newAttachmentCount)
+
+ } catch (error) {
+ console.error("첨부파일 저장 오류:", error)
+ toast.error("첨부파일 저장 중 오류가 발생했습니다.")
+ } finally {
+ setIsPending(false)
+ }
+ }
+
+ // 신청사유 입력 완료 핸들러
+ const handleApplicationReasonConfirm = React.useCallback(async (reason: string) => {
+ if (!resendApprovalData) {
+ toast.error("결재 데이터가 없습니다.")
+ return
+ }
+
+ try {
+ // 템플릿 변수 생성 (신청사유 포함)
+ const templateVariables = await mapTechSalesRfqSendToTemplateVariables({
+ attachments: resendApprovalData.drmFiles.map(f => ({
+ fileName: f.file.name,
+ fileSize: f.file.size,
+ })),
+ vendorNames: [], // 기존 벤더 목록은 후처리에서 조회
+ applicationReason: reason,
+ })
+
+ // 결재 미리보기 데이터 업데이트
+ setApprovalPreviewData({
+ templateVariables,
+ applicationReason: reason,
+ })
+
+ // 신청사유 다이얼로그 닫고 결재 미리보기 열기
+ setShowApplicationReasonDialog(false)
+ setShowResendApprovalDialog(true)
+ } catch (error) {
+ console.error("템플릿 변수 생성 실패:", error)
+ toast.error("결재 문서 생성에 실패했습니다.")
+ }
+ }, [resendApprovalData])
+
+ // 결재 미리보기 확인 핸들러
+ const handleApprovalConfirm = React.useCallback(async (approvalData: {
+ approvers: string[]
+ title: string
+ description?: string
+ }) => {
+ if (!resendApprovalData || !approvalPreviewData || !session?.data?.user) {
+ toast.error("결재 데이터가 없습니다.")
+ return
+ }
+
+ try {
+ const result = await requestRfqResendWithDrmApproval({
+ rfqId: resendApprovalData.rfqId,
+ rfqCode: rfq?.rfqCode || undefined,
+ drmFiles: resendApprovalData.drmFiles,
+ applicationReason: approvalPreviewData.applicationReason,
+ currentUser: {
+ id: Number(session.data.user.id),
+ epId: session.data.user.epId || null,
+ name: session.data.user.name || undefined,
+ email: session.data.user.email || undefined,
+ },
+ approvers: approvalData.approvers,
+ })
+
+ if (result.success) {
+ toast.success(result.message)
+ setShowResendApprovalDialog(false)
+ setResendApprovalData(null)
+ setApprovalPreviewData(null)
+ props.onOpenChange?.(false)
+ }
+ } catch (error) {
+ console.error("재발송 결재 상신 실패:", error)
+ toast.error(error instanceof Error ? error.message : "재발송 결재 상신에 실패했습니다.")
+ }
+ }, [resendApprovalData, approvalPreviewData, session, rfq, props])
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>{attachmentConfig.title}</SheetTitle>
+ <SheetDescription>
+ <div>RFQ: {rfq?.rfqCode || "N/A"}</div>
+ <div className="mt-1">{attachmentConfig.description}</div>
+ {!attachmentConfig.canEdit && (
+ <div className="mt-2 flex items-center gap-2 text-amber-600">
+ <AlertCircle className="h-4 w-4" />
+ <span className="text-sm">현재 상태에서는 편집할 수 없습니다</span>
+ </div>
+ )}
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-1 flex-col gap-6">
+ {/* 1) Existing attachments */}
+ <div className="grid gap-4">
+ <h6 className="font-semibold leading-none tracking-tight">
+ 기존 첨부파일 ({existingFields.length}개)
+ </h6>
+ {existingFields.map((field, index) => {
+ const typeLabel = attachmentConfig.fileTypeLabel
+ const sizeText = field.fileSize ? prettyBytes(field.fileSize) : "알 수 없음"
+ const dateText = field.createdAt ? formatDate(field.createdAt, "KR") : ""
+
+ return (
+ <div key={field.id} className="flex items-start justify-between p-3 border rounded-md gap-3">
+ <div className="flex-1 min-w-0 overflow-hidden">
+ <div className="flex items-center gap-2 mb-1 flex-wrap">
+ <p className="text-sm font-medium break-words leading-tight">
+ {field.originalFileName || field.fileName}
+ </p>
+ <Badge variant="outline" className="text-xs shrink-0">
+ {typeLabel}
+ </Badge>
+ </div>
+ <p className="text-xs text-muted-foreground">
+ {sizeText} • {dateText}
+ </p>
+ {field.description && (
+ <p className="text-xs text-muted-foreground mt-1 break-words">
+ {field.description}
+ </p>
+ )}
+ </div>
+
+ <div className="flex items-center gap-1 shrink-0">
+ {/* Download button */}
+ {field.filePath && (
+ <Button
+ variant="ghost"
+ size="icon"
+ type="button"
+ className="h-8 w-8"
+ onClick={() => handleDownloadClick(field.filePath, field.originalFileName || field.fileName)}
+ title="다운로드"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ )}
+ {/* Remove button - 편집 가능할 때만 표시 */}
+ {attachmentConfig.canEdit && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ onClick={() => handleRemoveExisting(index)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ )
+ })}
+ </div>
+
+ {/* 2) Dropzone for new uploads - 편집 가능할 때만 표시 */}
+ {attachmentConfig.canEdit ? (
+ <>
+ <Dropzone
+ maxSize={MAX_FILE_SIZE}
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={handleDropRejected}
+ >
+ {({ maxSize }) => (
+ <FormField
+ control={form.control}
+ name="newUploads"
+ render={() => (
+ <FormItem>
+ <FormLabel>새 파일 업로드</FormLabel>
+ <DropzoneZone className="flex justify-center">
+ <FormControl>
+ <DropzoneInput />
+ </FormControl>
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>파일을 드래그하거나 클릭하세요</DropzoneTitle>
+ <DropzoneDescription>
+ 최대 크기: {maxSize ? prettyBytes(maxSize) : "600MB"}
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ <FormDescription>파일을 여러 개 선택할 수 있습니다.</FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+ </Dropzone>
+
+ {/* newUpload fields -> FileList */}
+ {newUploadFields.length > 0 && (
+ <div className="grid gap-4">
+ <h6 className="font-semibold leading-none tracking-tight">
+ 새 파일 ({newUploadFields.length}개)
+ </h6>
+ <FileList>
+ {newUploadFields.map((field, idx) => {
+ const fileObj = form.getValues(`newUploads.${idx}.fileObj`)
+ if (!fileObj) return null
+
+ const fileName = fileObj.name
+ const fileSize = fileObj.size
+ return (
+ <FileListItem key={field.id}>
+ <FileListHeader>
+ <FileListIcon />
+ <FileListInfo>
+ <FileListName>{fileName}</FileListName>
+ <FileListDescription>
+ {prettyBytes(fileSize)}
+ </FileListDescription>
+ </FileListInfo>
+ <FileListAction onClick={() => removeNewUpload(idx)}>
+ <X />
+ <span className="sr-only">제거</span>
+ </FileListAction>
+ </FileListHeader>
+
+ </FileListItem>
+ )
+ })}
+ </FileList>
+ </div>
+ )}
+ </>
+ ) : (
+ <div className="p-3 bg-muted rounded-md flex items-center justify-center">
+ <div className="text-center text-sm text-muted-foreground">
+ <Eye className="h-4 w-4 mx-auto mb-2" />
+ <p>보기 모드에서는 파일 첨부를 할 수 없습니다.</p>
+ </div>
+ </div>
+ )}
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ {attachmentConfig.canEdit ? "취소" : "닫기"}
+ </Button>
+ </SheetClose>
+ {attachmentConfig.canEdit && (
+ <Button
+ type="submit"
+ disabled={
+ isPending ||
+ (
+ form.getValues().newUploads.length === 0 &&
+ form.getValues().existing.length === defaultAttachments.length &&
+ form.getValues().existing.every(existing =>
+ defaultAttachments.some(original => original.id === existing.id)
+ )
+ )
+ }
+ >
+ {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}
+ {isPending ? "저장 중..." : "저장"}
+ </Button>
+ )}
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+
+ {/* 신청사유 입력 다이얼로그 */}
+ {resendApprovalData && (
+ <ApplicationReasonDialog
+ open={showApplicationReasonDialog}
+ onOpenChange={setShowApplicationReasonDialog}
+ onConfirm={handleApplicationReasonConfirm}
+ vendorCount={0} // 재발송이므로 기존 벤더에게 발송
+ attachmentCount={resendApprovalData.drmFiles.length}
+ />
+ )}
+
+ {/* 결재 미리보기 다이얼로그 */}
+ {resendApprovalData && session?.data?.user?.epId && approvalPreviewData && (
+ <ApprovalPreviewDialog
+ open={showResendApprovalDialog}
+ onOpenChange={setShowResendApprovalDialog}
+ templateName="암호화해제 신청"
+ variables={approvalPreviewData.templateVariables}
+ title={`DRM 파일 재발송 결재 - ${rfq?.rfqCode || 'RFQ'}`}
+ currentUser={{
+ id: Number(session.data.user.id),
+ epId: session.data.user.epId,
+ name: session.data.user.name || undefined,
+ email: session.data.user.email || undefined,
+ }}
+ onConfirm={handleApprovalConfirm}
+ allowTitleEdit={false}
+ />
+ )}
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
index 214e2b89..62d2c073 100644
--- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
+++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
@@ -234,8 +234,9 @@ export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTab
return
}
- // 실제 첨부파일 목록 조회 API 호출
- const result = await getTechSalesRfqAttachments(rfqId)
+ // 벤더용 첨부파일 목록 조회 API 호출 (DRM 해제된 파일만 반환)
+ const { getRfqAttachmentsForVendor } = await import("@/lib/techsales-rfq/service")
+ const result = await getRfqAttachmentsForVendor(rfqId)
if (result.error) {
toast.error(result.error)
@@ -243,6 +244,7 @@ export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTab
}
// API 응답을 ExistingTechSalesAttachment 형식으로 변환하고 RFQ_COMMON 타입만 필터링
+ // getRfqAttachmentsForVendor는 이미 DRM 해제된 파일만 반환하므로 추가 필터링 불필요
const attachments: ExistingTechSalesAttachment[] = result.data
.filter(att => att.attachmentType === "RFQ_COMMON") // 벤더는 RFQ_COMMON 타입만 조회
.map(att => ({
diff --git a/lib/vendor-regular-registrations/repository.ts b/lib/vendor-regular-registrations/repository.ts
index 314afb6c..3713f628 100644
--- a/lib/vendor-regular-registrations/repository.ts
+++ b/lib/vendor-regular-registrations/repository.ts
@@ -1,307 +1,307 @@
-import db from "@/db/db";
-import {
- vendorRegularRegistrations,
- vendors,
- vendorAttachments,
- vendorInvestigationAttachments,
- basicContract,
- basicContractTemplates,
- vendorPQSubmissions,
- vendorInvestigations,
- vendorBusinessContacts,
- vendorAdditionalInfo,
-} from "@/db/schema";
-import { eq, desc, and, sql, inArray } from "drizzle-orm";
-import type { VendorRegularRegistration } from "@/config/vendorRegularRegistrationsColumnsConfig";
-
-export async function getVendorRegularRegistrations(
-): Promise<VendorRegularRegistration[]> {
- try {
- // DB 레코드 기준으로 정규업체등록 데이터를 가져옴
- const registrations = await db
- .select({
- // 정규업체등록 정보
- id: vendorRegularRegistrations.id,
- vendorId: vendorRegularRegistrations.vendorId,
- status: vendorRegularRegistrations.status,
- potentialCode: vendorRegularRegistrations.potentialCode,
- majorItems: vendorRegularRegistrations.majorItems,
- registrationRequestDate: vendorRegularRegistrations.registrationRequestDate,
- assignedDepartment: vendorRegularRegistrations.assignedDepartment,
- assignedUser: vendorRegularRegistrations.assignedUser,
- remarks: vendorRegularRegistrations.remarks,
- // 새로 추가된 필드들
- safetyQualificationContent: vendorRegularRegistrations.safetyQualificationContent,
- gtcSkipped: vendorRegularRegistrations.gtcSkipped,
- // 벤더 기본 정보
- businessNumber: vendors.taxId,
- companyName: vendors.vendorName,
- establishmentDate: vendors.createdAt,
- representative: vendors.representativeName,
- // 국가 정보 추가
- country: vendors.country,
- })
- .from(vendorRegularRegistrations)
- .innerJoin(vendors, eq(vendorRegularRegistrations.vendorId, vendors.id))
- .orderBy(desc(vendorRegularRegistrations.createdAt));
-
- // 벤더 ID 배열 생성
- const vendorIds = registrations.map(r => r.vendorId);
-
- // 벤더 첨부파일 정보 조회 - 벤더별로 그룹화
- const vendorAttachmentsList = vendorIds.length > 0 ? await db
- .select()
- .from(vendorAttachments)
- .where(inArray(vendorAttachments.vendorId, vendorIds)) : [];
-
- // 실사 첨부파일 정보 조회 - 실사 ID를 통해 벤더 ID 매핑
- const investigationAttachmentsList = vendorIds.length > 0 ? await db
- .select({
- vendorId: vendorInvestigations.vendorId,
- attachmentId: vendorInvestigationAttachments.id,
- fileName: vendorInvestigationAttachments.fileName,
- attachmentType: vendorInvestigationAttachments.attachmentType,
- createdAt: vendorInvestigationAttachments.createdAt,
- })
- .from(vendorInvestigationAttachments)
- .innerJoin(vendorInvestigations, eq(vendorInvestigationAttachments.investigationId, vendorInvestigations.id))
- .where(inArray(vendorInvestigations.vendorId, vendorIds)) : [];
-
- // 기본 계약 정보 조회 (템플릿별로 가장 최신 것만)
- const basicContractsList = vendorIds.length > 0 ? await db
- .select({
- vendorId: basicContract.vendorId,
- templateId: basicContract.templateId,
- status: basicContract.status,
- templateName: basicContractTemplates.templateName,
- createdAt: basicContract.createdAt,
- filePath: basicContract.filePath,
- fileName: basicContract.fileName,
- })
- .from(basicContract)
- .leftJoin(basicContractTemplates, eq(basicContract.templateId, basicContractTemplates.id))
- .where(inArray(basicContract.vendorId, vendorIds))
- .orderBy(desc(basicContract.createdAt)) : [];
-
- // 추가정보 입력 상태 조회 (업무담당자 정보)
- const businessContactsList = vendorIds.length > 0 ? await db
- .select({
- vendorId: vendorBusinessContacts.vendorId,
- contactType: vendorBusinessContacts.contactType,
- })
- .from(vendorBusinessContacts)
- .where(inArray(vendorBusinessContacts.vendorId, vendorIds)) : [];
-
- // 추가정보 테이블 조회
- const additionalInfoList = vendorIds.length > 0 ? await db
- .select({
- vendorId: vendorAdditionalInfo.vendorId,
- })
- .from(vendorAdditionalInfo)
- .where(inArray(vendorAdditionalInfo.vendorId, vendorIds)) : [];
-
- // 각 등록 레코드별로 데이터를 매핑하여 결과 반환
- return registrations.map((registration) => {
- // 벤더별 첨부파일 필터링
- const vendorFiles = vendorAttachmentsList.filter(att => att.vendorId === registration.vendorId);
- const investigationFiles = investigationAttachmentsList.filter(att => att.vendorId === registration.vendorId);
- const allVendorContracts = basicContractsList.filter(contract => contract.vendorId === registration.vendorId);
- const vendorContacts = businessContactsList.filter(contact => contact.vendorId === registration.vendorId);
- const vendorAdditionalInfoData = additionalInfoList.filter(info => info.vendorId === registration.vendorId);
-
- // 기술자료 동의서, 비밀유지계약서 제외 필터링
- const filteredContracts = allVendorContracts.filter(contract => {
- const templateName = contract.templateName?.toLowerCase() || '';
- return !templateName.includes('기술자료') && !templateName.includes('비밀유지');
- });
-
- // 템플릿명 기준으로 가장 최신 계약만 유지 (중복 제거)
- const vendorContracts = filteredContracts.reduce((acc, contract) => {
- const existing = acc.find(c => c.templateName === contract.templateName);
- if (!existing || (contract.createdAt && existing.createdAt && contract.createdAt > existing.createdAt)) {
- // 기존에 같은 템플릿명이 없거나, 더 최신인 경우 추가/교체
- return acc.filter(c => c.templateName !== contract.templateName).concat(contract);
- }
- return acc;
- }, [] as typeof filteredContracts);
-
- // 문서 제출 현황 - 국가별 요구사항 적용
- const isForeign = registration.country !== 'KR';
- const documentSubmissionsStatus = {
- businessRegistration: vendorFiles.some(f => f.attachmentType === "BUSINESS_REGISTRATION"),
- creditEvaluation: vendorFiles.some(f => f.attachmentType === "CREDIT_REPORT"),
- bankCopy: isForeign ? vendorFiles.some(f => f.attachmentType === "BANK_ACCOUNT_COPY") : true, // 내자는 통장사본 불필요
- auditResult: investigationFiles.length > 0, // 실사 첨부파일이 하나라도 있으면 true
- };
-
- // 문서별 파일 정보 (다운로드용)
- const documentFiles = {
- businessRegistration: vendorFiles.filter(f => f.attachmentType === "BUSINESS_REGISTRATION"),
- creditEvaluation: vendorFiles.filter(f => f.attachmentType === "CREDIT_REPORT"),
- bankCopy: vendorFiles.filter(f => f.attachmentType === "BANK_ACCOUNT_COPY"),
- auditResult: investigationFiles,
- };
-
- // 계약 동의 현황 - 실제 기본 계약 데이터 기반으로 단순화
- const contractAgreementsStatus = {
- cp: vendorContracts.some(c => c.status === "COMPLETED") ? "completed" : "not_submitted",
- gtc: registration.gtcSkipped ? "completed" : (vendorContracts.some(c => c.templateName?.includes("GTC") && c.status === "COMPLETED") ? "completed" : "not_submitted"),
- standardSubcontract: vendorContracts.some(c => c.templateName?.includes("표준하도급") && c.status === "COMPLETED") ? "completed" : "not_submitted",
- safetyHealth: vendorContracts.some(c => c.templateName?.includes("안전보건") && c.status === "COMPLETED") ? "completed" : "not_submitted",
- ethics: vendorContracts.some(c => c.templateName?.includes("윤리") && c.status === "COMPLETED") ? "completed" : "not_submitted",
- domesticCredit: vendorContracts.some(c => c.templateName?.includes("내국신용장") && c.status === "COMPLETED") ? "completed" : "not_submitted",
- };
-
- // 추가정보 입력 완료 여부 - 5개 필수 업무담당자 타입 + 추가정보 테이블 모두 입력되었는지 확인
- const requiredContactTypes = ["sales", "design", "delivery", "quality", "tax_invoice"];
- const contactsCompleted = requiredContactTypes.every(type =>
- vendorContacts.some(contact => contact.contactType === type)
- );
- const additionalInfoTableCompleted = vendorAdditionalInfoData.length > 0;
- const additionalInfoCompleted = contactsCompleted && additionalInfoTableCompleted;
-
- // 모든 조건 충족 여부 확인
- const allDocumentsSubmitted = Object.values(documentSubmissionsStatus).every(status => status === true);
- // 진행현황과 dialog에서 VENDOR_SIGNED도 완료로 간주하므로, 조건충족 체크도 동일하게 처리
- const allContractsCompleted = vendorContracts.length > 0 && vendorContracts.every(c => c.status === "COMPLETED" || c.status === "VENDOR_SIGNED");
- const safetyQualificationCompleted = !!registration.safetyQualificationContent;
-
- // 모든 조건이 충족되면 status를 "approval_ready"(조건충족)로 자동 변경
- const shouldUpdateStatus = allDocumentsSubmitted && allContractsCompleted && safetyQualificationCompleted && additionalInfoCompleted;
-
- // 현재 상태가 조건충족이 아닌데 모든 조건이 충족되면 상태 업데이트
- // 단, 결재진행중(pending_approval) 또는 등록요청완료(registration_completed), 등록실패(registration_failed) 상태인 경우 무시
- if (shouldUpdateStatus && registration.status !== "approval_ready" && registration.status !== "pending_approval" && registration.status !== "registration_completed" && registration.status !== "registration_failed") {
- // 비동기 업데이트 (백그라운드에서 실행)
- updateVendorRegularRegistration(registration.id, {
- status: "approval_ready"
- }).catch(error => {
- console.error(`상태 자동 업데이트 실패 (벤더 ID: ${registration.vendorId}):`, error);
- });
- }
-
- return {
- id: registration.id,
- vendorId: registration.vendorId,
- status: registration.status || "audit_pass",
- potentialCode: registration.potentialCode,
- businessNumber: registration.businessNumber || "",
- companyName: registration.companyName || "",
- majorItems: registration.majorItems,
- establishmentDate: registration.establishmentDate?.toISOString() || null,
- representative: registration.representative,
- country: registration.country,
- documentSubmissions: documentSubmissionsStatus,
- documentFiles: documentFiles, // 파일 정보 추가
- contractAgreements: contractAgreementsStatus,
- // 새로 추가된 필드들
- safetyQualificationContent: registration.safetyQualificationContent,
- gtcSkipped: registration.gtcSkipped || false,
- additionalInfo: additionalInfoCompleted,
- // 기본계약 정보
- basicContracts: vendorContracts.map((contract: any) => ({
- templateId: contract.templateId,
- templateName: contract.templateName,
- status: contract.status,
- createdAt: contract.createdAt,
- filePath: contract.filePath,
- fileName: contract.fileName,
- })),
- registrationRequestDate: registration.registrationRequestDate || null,
- assignedDepartment: registration.assignedDepartment,
- assignedUser: registration.assignedUser,
- remarks: registration.remarks,
- };
- });
- } catch (error) {
- console.error("Error fetching vendor regular registrations:", error);
- throw new Error("정규업체 등록 목록을 가져오는 중 오류가 발생했습니다.");
- }
-}
-
-export async function createVendorRegularRegistration(data: {
- vendorId: number;
- status?: string;
- potentialCode?: string;
- majorItems?: string;
- assignedDepartment?: string;
- assignedDepartmentCode?: string;
- assignedUser?: string;
- assignedUserCode?: string;
- remarks?: string;
- safetyQualificationContent?: string;
- gtcSkipped?: boolean;
-}) {
- try {
- const [registration] = await db
- .insert(vendorRegularRegistrations)
- .values({
- vendorId: data.vendorId,
- status: data.status || "under_review",
- potentialCode: data.potentialCode,
- majorItems: data.majorItems,
- assignedDepartment: data.assignedDepartment,
- assignedDepartmentCode: data.assignedDepartmentCode,
- assignedUser: data.assignedUser,
- assignedUserCode: data.assignedUserCode,
- remarks: data.remarks,
- safetyQualificationContent: data.safetyQualificationContent,
- gtcSkipped: data.gtcSkipped || false,
- })
- .returning();
-
- return registration;
- } catch (error) {
- console.error("Error creating vendor regular registration:", error);
- throw new Error("정규업체 등록을 생성하는 중 오류가 발생했습니다.");
- }
-}
-
-export async function updateVendorRegularRegistration(
- id: number,
- data: Partial<{
- status: string;
- potentialCode: string;
- majorItems: string;
- registrationRequestDate: string;
- assignedDepartment: string;
- assignedDepartmentCode: string;
- assignedUser: string;
- assignedUserCode: string;
- remarks: string;
- safetyQualificationContent: string;
- gtcSkipped: boolean;
- }>
-) {
- try {
- const [registration] = await db
- .update(vendorRegularRegistrations)
- .set({
- ...data,
- updatedAt: new Date(),
- })
- .where(eq(vendorRegularRegistrations.id, id))
- .returning();
-
- return registration;
- } catch (error) {
- console.error("Error updating vendor regular registration:", error);
- throw new Error("정규업체 등록을 업데이트하는 중 오류가 발생했습니다.");
- }
-}
-
-export async function getVendorRegularRegistrationById(id: number) {
- try {
- const [registration] = await db
- .select()
- .from(vendorRegularRegistrations)
- .where(eq(vendorRegularRegistrations.id, id));
-
- return registration;
- } catch (error) {
- console.error("Error fetching vendor regular registration by id:", error);
- throw new Error("정규업체 등록 정보를 가져오는 중 오류가 발생했습니다.");
- }
-}
-
-
+import db from "@/db/db";
+import {
+ vendorRegularRegistrations,
+ vendors,
+ vendorAttachments,
+ vendorInvestigationAttachments,
+ basicContract,
+ basicContractTemplates,
+ vendorPQSubmissions,
+ vendorInvestigations,
+ vendorBusinessContacts,
+ vendorAdditionalInfo,
+} from "@/db/schema";
+import { eq, desc, and, sql, inArray } from "drizzle-orm";
+import type { VendorRegularRegistration } from "@/config/vendorRegularRegistrationsColumnsConfig";
+
+export async function getVendorRegularRegistrations(
+): Promise<VendorRegularRegistration[]> {
+ try {
+ // DB 레코드 기준으로 정규업체등록 데이터를 가져옴
+ const registrations = await db
+ .select({
+ // 정규업체등록 정보
+ id: vendorRegularRegistrations.id,
+ vendorId: vendorRegularRegistrations.vendorId,
+ status: vendorRegularRegistrations.status,
+ potentialCode: vendorRegularRegistrations.potentialCode,
+ majorItems: vendorRegularRegistrations.majorItems,
+ registrationRequestDate: vendorRegularRegistrations.registrationRequestDate,
+ assignedDepartment: vendorRegularRegistrations.assignedDepartment,
+ assignedUser: vendorRegularRegistrations.assignedUser,
+ remarks: vendorRegularRegistrations.remarks,
+ // 새로 추가된 필드들
+ safetyQualificationContent: vendorRegularRegistrations.safetyQualificationContent,
+ gtcSkipped: vendorRegularRegistrations.gtcSkipped,
+ // 벤더 기본 정보
+ businessNumber: vendors.taxId,
+ companyName: vendors.vendorName,
+ establishmentDate: vendors.createdAt,
+ representative: vendors.representativeName,
+ // 국가 정보 추가
+ country: vendors.country,
+ })
+ .from(vendorRegularRegistrations)
+ .innerJoin(vendors, eq(vendorRegularRegistrations.vendorId, vendors.id))
+ .orderBy(desc(vendorRegularRegistrations.createdAt));
+
+ // 벤더 ID 배열 생성
+ const vendorIds = registrations.map(r => r.vendorId);
+
+ // 벤더 첨부파일 정보 조회 - 벤더별로 그룹화
+ const vendorAttachmentsList = vendorIds.length > 0 ? await db
+ .select()
+ .from(vendorAttachments)
+ .where(inArray(vendorAttachments.vendorId, vendorIds)) : [];
+
+ // 실사 첨부파일 정보 조회 - 실사 ID를 통해 벤더 ID 매핑
+ const investigationAttachmentsList = vendorIds.length > 0 ? await db
+ .select({
+ vendorId: vendorInvestigations.vendorId,
+ attachmentId: vendorInvestigationAttachments.id,
+ fileName: vendorInvestigationAttachments.fileName,
+ attachmentType: vendorInvestigationAttachments.attachmentType,
+ createdAt: vendorInvestigationAttachments.createdAt,
+ })
+ .from(vendorInvestigationAttachments)
+ .innerJoin(vendorInvestigations, eq(vendorInvestigationAttachments.investigationId, vendorInvestigations.id))
+ .where(inArray(vendorInvestigations.vendorId, vendorIds)) : [];
+
+ // 기본 계약 정보 조회 (템플릿별로 가장 최신 것만)
+ const basicContractsList = vendorIds.length > 0 ? await db
+ .select({
+ vendorId: basicContract.vendorId,
+ templateId: basicContract.templateId,
+ status: basicContract.status,
+ templateName: basicContractTemplates.templateName,
+ createdAt: basicContract.createdAt,
+ filePath: basicContract.filePath,
+ fileName: basicContract.fileName,
+ })
+ .from(basicContract)
+ .leftJoin(basicContractTemplates, eq(basicContract.templateId, basicContractTemplates.id))
+ .where(inArray(basicContract.vendorId, vendorIds))
+ .orderBy(desc(basicContract.createdAt)) : [];
+
+ // 추가정보 입력 상태 조회 (업무담당자 정보)
+ const businessContactsList = vendorIds.length > 0 ? await db
+ .select({
+ vendorId: vendorBusinessContacts.vendorId,
+ contactType: vendorBusinessContacts.contactType,
+ })
+ .from(vendorBusinessContacts)
+ .where(inArray(vendorBusinessContacts.vendorId, vendorIds)) : [];
+
+ // 추가정보 테이블 조회
+ const additionalInfoList = vendorIds.length > 0 ? await db
+ .select({
+ vendorId: vendorAdditionalInfo.vendorId,
+ })
+ .from(vendorAdditionalInfo)
+ .where(inArray(vendorAdditionalInfo.vendorId, vendorIds)) : [];
+
+ // 각 등록 레코드별로 데이터를 매핑하여 결과 반환
+ return registrations.map((registration) => {
+ // 벤더별 첨부파일 필터링
+ const vendorFiles = vendorAttachmentsList.filter(att => att.vendorId === registration.vendorId);
+ const investigationFiles = investigationAttachmentsList.filter(att => att.vendorId === registration.vendorId);
+ const allVendorContracts = basicContractsList.filter(contract => contract.vendorId === registration.vendorId);
+ const vendorContacts = businessContactsList.filter(contact => contact.vendorId === registration.vendorId);
+ const vendorAdditionalInfoData = additionalInfoList.filter(info => info.vendorId === registration.vendorId);
+
+ // 기술자료 동의서, 비밀유지계약서 제외 필터링
+ const filteredContracts = allVendorContracts.filter(contract => {
+ const templateName = contract.templateName?.toLowerCase() || '';
+ return !templateName.includes('기술자료') && !templateName.includes('비밀유지');
+ });
+
+ // 템플릿명 기준으로 가장 최신 계약만 유지 (중복 제거)
+ const vendorContracts = filteredContracts.reduce((acc, contract) => {
+ const existing = acc.find(c => c.templateName === contract.templateName);
+ if (!existing || (contract.createdAt && existing.createdAt && contract.createdAt > existing.createdAt)) {
+ // 기존에 같은 템플릿명이 없거나, 더 최신인 경우 추가/교체
+ return acc.filter(c => c.templateName !== contract.templateName).concat(contract);
+ }
+ return acc;
+ }, [] as typeof filteredContracts);
+
+ // 문서 제출 현황 - 국가별 요구사항 적용
+ const isForeign = registration.country !== 'KR';
+ const documentSubmissionsStatus = {
+ businessRegistration: vendorFiles.some(f => f.attachmentType === "BUSINESS_REGISTRATION"),
+ creditEvaluation: vendorFiles.some(f => f.attachmentType === "CREDIT_REPORT"),
+ bankCopy: isForeign ? vendorFiles.some(f => f.attachmentType === "BANK_ACCOUNT_COPY") : true, // 내자는 통장사본 불필요
+ auditResult: investigationFiles.length > 0, // 실사 첨부파일이 하나라도 있으면 true
+ };
+
+ // 문서별 파일 정보 (다운로드용)
+ const documentFiles = {
+ businessRegistration: vendorFiles.filter(f => f.attachmentType === "BUSINESS_REGISTRATION"),
+ creditEvaluation: vendorFiles.filter(f => f.attachmentType === "CREDIT_REPORT"),
+ bankCopy: vendorFiles.filter(f => f.attachmentType === "BANK_ACCOUNT_COPY"),
+ auditResult: investigationFiles,
+ };
+
+ // 계약 동의 현황 - 실제 기본 계약 데이터 기반으로 단순화
+ const contractAgreementsStatus = {
+ cp: vendorContracts.some(c => c.status === "COMPLETED") ? "completed" : "not_submitted",
+ gtc: registration.gtcSkipped ? "completed" : (vendorContracts.some(c => c.templateName?.includes("GTC") && c.status === "COMPLETED") ? "completed" : "not_submitted"),
+ standardSubcontract: vendorContracts.some(c => c.templateName?.includes("표준하도급") && c.status === "COMPLETED") ? "completed" : "not_submitted",
+ safetyHealth: vendorContracts.some(c => c.templateName?.includes("안전보건") && c.status === "COMPLETED") ? "completed" : "not_submitted",
+ ethics: vendorContracts.some(c => c.templateName?.includes("윤리") && c.status === "COMPLETED") ? "completed" : "not_submitted",
+ domesticCredit: vendorContracts.some(c => c.templateName?.includes("내국신용장") && c.status === "COMPLETED") ? "completed" : "not_submitted",
+ };
+
+ // 추가정보 입력 완료 여부 - 5개 필수 업무담당자 타입 + 추가정보 테이블 모두 입력되었는지 확인
+ const requiredContactTypes = ["sales", "design", "delivery", "quality", "tax_invoice"];
+ const contactsCompleted = requiredContactTypes.every(type =>
+ vendorContacts.some(contact => contact.contactType === type)
+ );
+ const additionalInfoTableCompleted = vendorAdditionalInfoData.length > 0;
+ const additionalInfoCompleted = contactsCompleted && additionalInfoTableCompleted;
+
+ // 모든 조건 충족 여부 확인
+ const allDocumentsSubmitted = Object.values(documentSubmissionsStatus).every(status => status === true);
+ // 진행현황과 dialog에서 VENDOR_SIGNED도 완료로 간주하므로, 조건충족 체크도 동일하게 처리
+ const allContractsCompleted = vendorContracts.length > 0 && vendorContracts.every(c => c.status === "COMPLETED" || c.status === "VENDOR_SIGNED");
+ const safetyQualificationCompleted = !!registration.safetyQualificationContent;
+
+ // 모든 조건이 충족되면 status를 "approval_ready"(조건충족)로 자동 변경
+ const shouldUpdateStatus = allDocumentsSubmitted && allContractsCompleted && safetyQualificationCompleted && additionalInfoCompleted;
+
+ // 현재 상태가 조건충족이 아닌데 모든 조건이 충족되면 상태 업데이트
+ // 단, 결재진행중(pending_approval) 또는 등록요청완료(registration_completed), 등록실패(registration_failed) 상태인 경우 무시
+ if (shouldUpdateStatus && registration.status !== "approval_ready" && registration.status !== "pending_approval" && registration.status !== "registration_completed" && registration.status !== "registration_failed") {
+ // 비동기 업데이트 (백그라운드에서 실행)
+ updateVendorRegularRegistration(registration.id, {
+ status: "approval_ready"
+ }).catch(error => {
+ console.error(`상태 자동 업데이트 실패 (벤더 ID: ${registration.vendorId}):`, error);
+ });
+ }
+
+ return {
+ id: registration.id,
+ vendorId: registration.vendorId,
+ status: registration.status || "audit_pass",
+ potentialCode: registration.potentialCode,
+ businessNumber: registration.businessNumber || "",
+ companyName: registration.companyName || "",
+ majorItems: registration.majorItems,
+ establishmentDate: registration.establishmentDate?.toISOString() || null,
+ representative: registration.representative,
+ country: registration.country,
+ documentSubmissions: documentSubmissionsStatus,
+ documentFiles: documentFiles, // 파일 정보 추가
+ contractAgreements: contractAgreementsStatus,
+ // 새로 추가된 필드들
+ safetyQualificationContent: registration.safetyQualificationContent,
+ gtcSkipped: registration.gtcSkipped || false,
+ additionalInfo: additionalInfoCompleted,
+ // 기본계약 정보
+ basicContracts: vendorContracts.map((contract: any) => ({
+ templateId: contract.templateId,
+ templateName: contract.templateName,
+ status: contract.status,
+ createdAt: contract.createdAt,
+ filePath: contract.filePath,
+ fileName: contract.fileName,
+ })),
+ registrationRequestDate: registration.registrationRequestDate || null,
+ assignedDepartment: registration.assignedDepartment,
+ assignedUser: registration.assignedUser,
+ remarks: registration.remarks,
+ };
+ });
+ } catch (error) {
+ console.error("Error fetching vendor regular registrations:", error);
+ throw new Error("정규업체 등록 목록을 가져오는 중 오류가 발생했습니다.");
+ }
+}
+
+export async function createVendorRegularRegistration(data: {
+ vendorId: number;
+ status?: string;
+ potentialCode?: string;
+ majorItems?: string;
+ assignedDepartment?: string;
+ assignedDepartmentCode?: string;
+ assignedUser?: string;
+ assignedUserCode?: string;
+ remarks?: string;
+ safetyQualificationContent?: string;
+ gtcSkipped?: boolean;
+}) {
+ try {
+ const [registration] = await db
+ .insert(vendorRegularRegistrations)
+ .values({
+ vendorId: data.vendorId,
+ status: data.status || "under_review",
+ potentialCode: data.potentialCode,
+ majorItems: data.majorItems,
+ assignedDepartment: data.assignedDepartment,
+ assignedDepartmentCode: data.assignedDepartmentCode,
+ assignedUser: data.assignedUser,
+ assignedUserCode: data.assignedUserCode,
+ remarks: data.remarks,
+ safetyQualificationContent: data.safetyQualificationContent,
+ gtcSkipped: data.gtcSkipped || false,
+ })
+ .returning();
+
+ return registration;
+ } catch (error) {
+ console.error("Error creating vendor regular registration:", error);
+ throw new Error("정규업체 등록을 생성하는 중 오류가 발생했습니다.");
+ }
+}
+
+export async function updateVendorRegularRegistration(
+ id: number,
+ data: Partial<{
+ status: string;
+ potentialCode: string;
+ majorItems: string;
+ registrationRequestDate: string;
+ assignedDepartment: string;
+ assignedDepartmentCode: string;
+ assignedUser: string;
+ assignedUserCode: string;
+ remarks: string;
+ safetyQualificationContent: string;
+ gtcSkipped: boolean;
+ }>
+) {
+ try {
+ const [registration] = await db
+ .update(vendorRegularRegistrations)
+ .set({
+ ...data,
+ updatedAt: new Date(),
+ })
+ .where(eq(vendorRegularRegistrations.id, id))
+ .returning();
+
+ return registration;
+ } catch (error) {
+ console.error("Error updating vendor regular registration:", error);
+ throw new Error("정규업체 등록을 업데이트하는 중 오류가 발생했습니다.");
+ }
+}
+
+export async function getVendorRegularRegistrationById(id: number) {
+ try {
+ const [registration] = await db
+ .select()
+ .from(vendorRegularRegistrations)
+ .where(eq(vendorRegularRegistrations.id, id));
+
+ return registration;
+ } catch (error) {
+ console.error("Error fetching vendor regular registration by id:", error);
+ throw new Error("정규업체 등록 정보를 가져오는 중 오류가 발생했습니다.");
+ }
+}
+
+