summaryrefslogtreecommitdiff
path: root/lib/bidding/detail/table
diff options
context:
space:
mode:
Diffstat (limited to 'lib/bidding/detail/table')
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-columns.tsx78
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-table.tsx46
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx228
-rw-r--r--lib/bidding/detail/table/price-adjustment-dialog.tsx195
-rw-r--r--lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx324
5 files changed, 709 insertions, 162 deletions
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
index 5368b287..05c1a93d 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
@@ -31,6 +31,7 @@ interface GetVendorColumnsProps {
}
export function getBiddingDetailVendorColumns({
+ onViewPriceAdjustment,
onViewItemDetails,
onSendBidding,
onUpdateParticipation,
@@ -239,6 +240,83 @@ export function getBiddingDetailVendorColumns({
),
},
{
+ accessorKey: 'priceAdjustmentResponse',
+ header: '연동제 응답',
+ cell: ({ row }) => {
+ const vendor = row.original
+ const response = vendor.priceAdjustmentResponse
+
+ // 버튼 형태로 표시, 클릭 시 상세 다이얼로그 열기
+ const getBadgeVariant = () => {
+ if (response === null || response === undefined) return 'outline'
+ return response ? 'default' : 'secondary'
+ }
+
+ const getBadgeClass = () => {
+ if (response === true) return 'bg-green-600 hover:bg-green-700 cursor-pointer'
+ if (response === false) return 'hover:bg-gray-300 cursor-pointer'
+ return ''
+ }
+
+ const getLabel = () => {
+ if (response === null || response === undefined) return '해당없음'
+ return response ? '예' : '아니오'
+ }
+
+ return (
+ <Badge
+ variant={getBadgeVariant()}
+ className={getBadgeClass()}
+ onClick={() => onViewPriceAdjustment?.(vendor)}
+ >
+ {getLabel()}
+ </Badge>
+ )
+ },
+ },
+ {
+ accessorKey: 'shiPriceAdjustmentApplied',
+ header: 'SHI연동제적용',
+ cell: ({ row }) => {
+ const applied = row.original.shiPriceAdjustmentApplied
+ if (applied === null || applied === undefined) {
+ return <Badge variant="outline">미정</Badge>
+ }
+ return (
+ <Badge variant={applied ? 'default' : 'secondary'} className={applied ? 'bg-green-600' : ''}>
+ {applied ? '적용' : '미적용'}
+ </Badge>
+ )
+ },
+ },
+ {
+ accessorKey: 'priceAdjustmentNote',
+ header: '연동제 Note',
+ cell: ({ row }) => {
+ const note = row.original.priceAdjustmentNote
+ return (
+ <div className="text-sm max-w-[150px] truncate" title={note || ''}>
+ {note || '-'}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: 'hasChemicalSubstance',
+ header: '화학물질',
+ cell: ({ row }) => {
+ const hasChemical = row.original.hasChemicalSubstance
+ if (hasChemical === null || hasChemical === undefined) {
+ return <Badge variant="outline">미정</Badge>
+ }
+ return (
+ <Badge variant={hasChemical ? 'destructive' : 'secondary'}>
+ {hasChemical ? '해당' : '해당없음'}
+ </Badge>
+ )
+ },
+ },
+ {
id: 'actions',
header: '작업',
cell: ({ row }) => {
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
index fffac0c1..407cc51c 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
@@ -10,9 +10,9 @@ import { BiddingDetailVendorToolbarActions } from './bidding-detail-vendor-toolb
import { BiddingDetailVendorEditDialog } from './bidding-detail-vendor-edit-dialog'
import { BiddingAwardDialog } from './bidding-award-dialog'
import { getBiddingDetailVendorColumns } from './bidding-detail-vendor-columns'
-import { QuotationVendor, getPriceAdjustmentFormByBiddingCompanyId } from '@/lib/bidding/detail/service'
+import { QuotationVendor } from '@/lib/bidding/detail/service'
import { Bidding } from '@/db/schema'
-import { PriceAdjustmentDialog } from '@/components/bidding/price-adjustment-dialog'
+import { VendorPriceAdjustmentViewDialog } from './vendor-price-adjustment-view-dialog'
import { QuotationHistoryDialog } from './quotation-history-dialog'
import { ApprovalPreviewDialog } from '@/lib/approval/approval-preview-dialog'
import { ApplicationReasonDialog } from '@/lib/rfq-last/vendor/application-reason-dialog'
@@ -27,6 +27,7 @@ interface BiddingDetailVendorTableContentProps {
onOpenSelectionReasonDialog: () => void
onViewItemDetails?: (vendor: QuotationVendor) => void
onViewQuotationHistory?: (vendor: QuotationVendor) => void
+ readOnly?: boolean
}
const filterFields: DataTableFilterField<QuotationVendor>[] = [
@@ -86,7 +87,8 @@ export function BiddingDetailVendorTableContent({
vendors,
onRefresh,
onViewItemDetails,
- onViewQuotationHistory
+ onViewQuotationHistory,
+ readOnly = false
}: BiddingDetailVendorTableContentProps) {
const { data: session } = useSession()
const { toast } = useToast()
@@ -96,8 +98,7 @@ export function BiddingDetailVendorTableContent({
const [selectedVendor, setSelectedVendor] = React.useState<QuotationVendor | null>(null)
const [isAwardDialogOpen, setIsAwardDialogOpen] = React.useState(false)
const [isAwardRatioDialogOpen, setIsAwardRatioDialogOpen] = React.useState(false)
- const [priceAdjustmentData, setPriceAdjustmentData] = React.useState<any>(null)
- const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false)
+ const [isVendorPriceAdjustmentDialogOpen, setIsVendorPriceAdjustmentDialogOpen] = React.useState(false)
const [quotationHistoryData, setQuotationHistoryData] = React.useState<any>(null)
const [isQuotationHistoryDialogOpen, setIsQuotationHistoryDialogOpen] = React.useState(false)
const [approvalPreviewData, setApprovalPreviewData] = React.useState<{
@@ -114,28 +115,9 @@ export function BiddingDetailVendorTableContent({
} | null>(null)
const [isApprovalPreviewDialogOpen, setIsApprovalPreviewDialogOpen] = React.useState(false)
- const handleViewPriceAdjustment = async (vendor: QuotationVendor) => {
- try {
- const priceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(vendor.id)
- if (priceAdjustmentForm) {
- setPriceAdjustmentData(priceAdjustmentForm)
- setSelectedVendor(vendor)
- setIsPriceAdjustmentDialogOpen(true)
- } else {
- toast({
- title: '연동제 정보 없음',
- description: '해당 업체의 연동제 정보가 없습니다.',
- variant: 'default',
- })
- }
- } catch (error) {
- console.error('Failed to load price adjustment form:', error)
- toast({
- title: '오류',
- description: '연동제 정보를 불러오는데 실패했습니다.',
- variant: 'destructive',
- })
- }
+ const handleViewPriceAdjustment = (vendor: QuotationVendor) => {
+ setSelectedVendor(vendor)
+ setIsVendorPriceAdjustmentDialogOpen(true)
}
const handleViewQuotationHistory = async (vendor: QuotationVendor) => {
@@ -269,6 +251,7 @@ export function BiddingDetailVendorTableContent({
onSuccess={onRefresh}
winnerVendor={vendors.find(v => v.awardRatio === 100)}
singleSelectedVendor={singleSelectedVendor}
+ readOnly={readOnly}
/>
</DataTableAdvancedToolbar>
</DataTable>
@@ -296,11 +279,12 @@ export function BiddingDetailVendorTableContent({
}}
/>
- <PriceAdjustmentDialog
- open={isPriceAdjustmentDialogOpen}
- onOpenChange={setIsPriceAdjustmentDialogOpen}
- data={priceAdjustmentData}
+ <VendorPriceAdjustmentViewDialog
+ open={isVendorPriceAdjustmentDialogOpen}
+ onOpenChange={setIsVendorPriceAdjustmentDialogOpen}
vendorName={selectedVendor?.vendorName || ''}
+ priceAdjustmentResponse={selectedVendor?.priceAdjustmentResponse ?? null}
+ biddingCompanyId={selectedVendor?.id || 0}
/>
<QuotationHistoryDialog
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 8df29289..e934a5fe 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
@@ -5,13 +5,14 @@ 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 { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign, RotateCw, Link2 } from "lucide-react"
import { registerBidding, markAsDisposal, cancelAwardRatio } from "@/lib/bidding/detail/service"
import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from "@/lib/bidding/pre-quote/service"
import { increaseRoundOrRebid } from "@/lib/bidding/service"
import { BiddingDetailVendorCreateDialog } from "../../../../components/bidding/manage/bidding-detail-vendor-create-dialog"
import { BiddingDocumentUploadDialog } from "./bidding-document-upload-dialog"
+import { PriceAdjustmentDialog } from "./price-adjustment-dialog"
import { Bidding } from "@/db/schema"
import { useToast } from "@/hooks/use-toast"
import { QuotationVendor } from "@/lib/bidding/detail/service"
@@ -25,6 +26,7 @@ interface BiddingDetailVendorToolbarActionsProps {
onSuccess: () => void
winnerVendor?: QuotationVendor | null // 100% 낙찰된 벤더
singleSelectedVendor?: QuotationVendor | null // single select된 벤더
+ readOnly?: boolean
}
export function BiddingDetailVendorToolbarActions({
@@ -35,7 +37,8 @@ export function BiddingDetailVendorToolbarActions({
onOpenAwardRatioDialog,
onSuccess,
winnerVendor,
- singleSelectedVendor
+ singleSelectedVendor,
+ readOnly = false
}: BiddingDetailVendorToolbarActionsProps) {
const router = useRouter()
const { toast } = useToast()
@@ -47,6 +50,7 @@ export function BiddingDetailVendorToolbarActions({
const [selectedVendors, setSelectedVendors] = React.useState<any[]>([])
const [isRoundIncreaseDialogOpen, setIsRoundIncreaseDialogOpen] = React.useState(false)
const [isCancelAwardDialogOpen, setIsCancelAwardDialogOpen] = React.useState(false)
+ const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false)
// 본입찰 초대 다이얼로그가 열릴 때 선정된 업체들 조회
React.useEffect(() => {
@@ -82,53 +86,6 @@ export function BiddingDetailVendorToolbarActions({
setIsBiddingInvitationDialogOpen(true)
}
- // const handleBiddingInvitationSend = async (data: any) => {
- // try {
- // // 1. 기본계약 발송
- // const contractResult = await sendBiddingBasicContracts(
- // biddingId,
- // data.vendors,
- // data.generatedPdfs,
- // data.message
- // )
-
- // if (!contractResult.success) {
- // toast({
- // title: '기본계약 발송 실패',
- // description: contractResult.error,
- // variant: 'destructive',
- // })
- // return
- // }
-
- // // 2. 입찰 등록 진행
- // const registerResult = await registerBidding(bidding.id, userId)
-
- // if (registerResult.success) {
- // toast({
- // title: '본입찰 초대 완료',
- // description: '기본계약 발송 및 본입찰 초대가 완료되었습니다.',
- // })
- // setIsBiddingInvitationDialogOpen(false)
- // router.refresh()
- // onSuccess()
- // } else {
- // toast({
- // title: '오류',
- // description: registerResult.error,
- // variant: 'destructive',
- // })
- // }
- // } catch (error) {
- // console.error('본입찰 초대 실패:', error)
- // toast({
- // title: '오류',
- // description: '본입찰 초대에 실패했습니다.',
- // variant: 'destructive',
- // })
- // }
- // }
-
// 선정된 업체들 조회 (서버 액션 함수 사용)
const getSelectedVendors = async () => {
try {
@@ -165,27 +122,6 @@ export function BiddingDetailVendorToolbarActions({
})
}
- const handleRoundIncrease = () => {
- startTransition(async () => {
- const result = await increaseRoundOrRebid(bidding.id, userId, 'round_increase')
-
- if (result.success) {
- toast({
- title: "성공",
- description: result.message,
- })
- router.push(`/evcp/bid`)
- onSuccess()
- } else {
- toast({
- title: "오류",
- description: result.error || "차수증가 중 오류가 발생했습니다.",
- variant: 'destructive',
- })
- }
- })
- }
-
const handleCancelAward = () => {
if (!winnerVendor) return
@@ -218,8 +154,12 @@ export function BiddingDetailVendorToolbarActions({
title: "성공",
description: '차수증가가 완료되었습니다.',
})
- router.push(`/evcp/bid`)
- onSuccess()
+ if (result.biddingId) {
+ router.push(`/evcp/bid/${result.biddingId}/info`)
+ } else {
+ router.push(`/evcp/bid`)
+ }
+ // onSuccess()
} else {
toast({
title: "오류",
@@ -233,69 +173,87 @@ export function BiddingDetailVendorToolbarActions({
return (
<>
<div className="flex items-center gap-2">
- {/* 상태별 액션 버튼 */}
- {/* 차수증가: 입찰공고 또는 입찰평가중 상태에서만 */}
- {(bidding.status === 'evaluation_of_bidding' || bidding.status === 'bidding_opened') && (
- <Button
- variant="outline"
- size="sm"
- onClick={() => setIsRoundIncreaseDialogOpen(true)}
- disabled={isPending}
- >
- <RotateCw className="mr-2 h-4 w-4" />
- 차수증가
- </Button>
- )}
-
- {/* 발주비율 산정: single select 시에만 활성화 */}
- {(bidding.status === 'evaluation_of_bidding') && (
- <Button
- variant="outline"
- size="sm"
- onClick={onOpenAwardRatioDialog}
- disabled={!singleSelectedVendor || isPending || singleSelectedVendor.isBiddingParticipated !== true}
- >
- <DollarSign className="mr-2 h-4 w-4" />
- 발주비율 산정
- </Button>
- )}
-
- {/* 유찰/낙찰: 입찰공고 또는 입찰평가중 상태에서만 */}
- {(bidding.status === 'bidding_opened' || bidding.status === 'evaluation_of_bidding') && (
+ {/* 상태별 액션 버튼 - 읽기 전용이 아닐 때만 표시 */}
+ {!readOnly && (
<>
- <Button
- variant="destructive"
- size="sm"
- onClick={handleMarkAsDisposal}
- disabled={isPending}
- >
- <XCircle className="mr-2 h-4 w-4" />
- 유찰
- </Button>
- <Button
- variant="default"
- size="sm"
- onClick={onOpenAwardDialog}
- disabled={isPending}
- >
- <Trophy className="mr-2 h-4 w-4" />
- 낙찰
- </Button>
+ {/* 차수증가: 입찰공고 또는 입찰평가중 상태에서만 */}
+ {(bidding.status === 'evaluation_of_bidding' || bidding.status === 'bidding_opened') && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsRoundIncreaseDialogOpen(true)}
+ disabled={isPending}
+ >
+ <RotateCw className="mr-2 h-4 w-4" />
+ 차수증가
+ </Button>
+ )}
+
+ {/* 발주비율 산정: single select 시에만 활성화 */}
+ {(bidding.status === 'evaluation_of_bidding') && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onOpenAwardRatioDialog}
+ disabled={!singleSelectedVendor || isPending || singleSelectedVendor.isBiddingParticipated !== true}
+ >
+ <DollarSign className="mr-2 h-4 w-4" />
+ 발주비율 산정
+ </Button>
+ )}
+
+ {/* 연동제 적용여부: single select 시에만 활성화 */}
+ {(bidding.status === 'evaluation_of_bidding') && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsPriceAdjustmentDialogOpen(true)}
+ disabled={!singleSelectedVendor || isPending || singleSelectedVendor.isBiddingParticipated !== true}
+ >
+ <Link2 className="mr-2 h-4 w-4" />
+ 연동제 적용
+ </Button>
+ )}
+
+ {/* 유찰/낙찰: 입찰공고 또는 입찰평가중 상태에서만 */}
+ {(bidding.status === 'bidding_opened' || bidding.status === 'evaluation_of_bidding') && (
+ <>
+ <Button
+ variant="destructive"
+ size="sm"
+ onClick={handleMarkAsDisposal}
+ disabled={isPending}
+ >
+ <XCircle className="mr-2 h-4 w-4" />
+ 유찰
+ </Button>
+ <Button
+ variant="default"
+ size="sm"
+ onClick={onOpenAwardDialog}
+ disabled={isPending}
+ >
+ <Trophy className="mr-2 h-4 w-4" />
+ 낙찰
+ </Button>
+ </>
+ )}
+
+ {/* 발주비율 취소: 100% 낙찰된 벤더가 있는 경우 */}
+ {winnerVendor && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsCancelAwardDialogOpen(true)}
+ disabled={isPending}
+ >
+ <RotateCcw className="mr-2 h-4 w-4" />
+ 발주비율 취소
+ </Button>
+ )}
</>
)}
- {/* 발주비율 취소: 100% 낙찰된 벤더가 있는 경우 */}
- {winnerVendor && (
- <Button
- variant="outline"
- size="sm"
- onClick={() => setIsCancelAwardDialogOpen(true)}
- disabled={isPending}
- >
- <RotateCcw className="mr-2 h-4 w-4" />
- 발주비율 취소
- </Button>
- )}
{/* 구분선 */}
{(bidding.status === 'bidding_generated' ||
bidding.status === 'bidding_disposal') && (
@@ -392,6 +350,14 @@ export function BiddingDetailVendorToolbarActions({
</DialogContent>
</Dialog>
+ {/* 연동제 적용여부 다이얼로그 */}
+ <PriceAdjustmentDialog
+ open={isPriceAdjustmentDialogOpen}
+ onOpenChange={setIsPriceAdjustmentDialogOpen}
+ vendor={singleSelectedVendor || null}
+ onSuccess={onSuccess}
+ />
+
</>
)
}
diff --git a/lib/bidding/detail/table/price-adjustment-dialog.tsx b/lib/bidding/detail/table/price-adjustment-dialog.tsx
new file mode 100644
index 00000000..96a3af0c
--- /dev/null
+++ b/lib/bidding/detail/table/price-adjustment-dialog.tsx
@@ -0,0 +1,195 @@
+"use client"
+
+import * as React from "react"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import { Switch } from "@/components/ui/switch"
+import { useToast } from "@/hooks/use-toast"
+import { updatePriceAdjustmentInfo } from "@/lib/bidding/detail/service"
+import { QuotationVendor } from "@/lib/bidding/detail/service"
+import { Loader2 } from "lucide-react"
+
+interface PriceAdjustmentDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ vendor: QuotationVendor | null
+ onSuccess: () => void
+}
+
+export function PriceAdjustmentDialog({
+ open,
+ onOpenChange,
+ vendor,
+ onSuccess,
+}: PriceAdjustmentDialogProps) {
+ const { toast } = useToast()
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+
+ // 폼 상태
+ const [shiPriceAdjustmentApplied, setSHIPriceAdjustmentApplied] = React.useState<boolean | null>(null)
+ const [priceAdjustmentNote, setPriceAdjustmentNote] = React.useState("")
+ const [hasChemicalSubstance, setHasChemicalSubstance] = React.useState<boolean | null>(null)
+
+ // 다이얼로그가 열릴 때 벤더 정보로 폼 초기화
+ React.useEffect(() => {
+ if (open && vendor) {
+ setSHIPriceAdjustmentApplied(vendor.shiPriceAdjustmentApplied ?? null)
+ setPriceAdjustmentNote(vendor.priceAdjustmentNote || "")
+ setHasChemicalSubstance(vendor.hasChemicalSubstance ?? null)
+ }
+ }, [open, vendor])
+
+ const handleSubmit = async () => {
+ if (!vendor) return
+
+ setIsSubmitting(true)
+ try {
+ const result = await updatePriceAdjustmentInfo({
+ biddingCompanyId: vendor.id,
+ shiPriceAdjustmentApplied,
+ priceAdjustmentNote: priceAdjustmentNote || null,
+ hasChemicalSubstance,
+ })
+
+ if (result.success) {
+ toast({
+ title: "저장 완료",
+ description: "연동제 정보가 저장되었습니다.",
+ })
+ onOpenChange(false)
+ onSuccess()
+ } else {
+ toast({
+ title: "오류",
+ description: result.error || "저장 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ } catch (error) {
+ console.error("연동제 정보 저장 오류:", error)
+ toast({
+ title: "오류",
+ description: "저장 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ if (!vendor) return null
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle>연동제 적용 설정</DialogTitle>
+ <DialogDescription>
+ <span className="font-semibold text-primary">{vendor.vendorName}</span> 업체의 연동제 적용 여부를 설정합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-6 py-4">
+ {/* 업체가 제출한 연동제 요청 여부 (읽기 전용) */}
+ {/* <div className="flex flex-row items-center justify-between rounded-lg border p-4 bg-muted/50">
+ <div className="space-y-0.5">
+ <Label className="text-base">업체 연동제 요청</Label>
+ <p className="text-sm text-muted-foreground">
+ 업체가 제출한 연동제 적용 요청 여부입니다.
+ </p>
+ </div>
+ <span className={`font-medium ${vendor.isPriceAdjustmentApplicableQuestion ? 'text-green-600' : 'text-gray-500'}`}>
+ {vendor.isPriceAdjustmentApplicableQuestion === null ? '미정' : vendor.isPriceAdjustmentApplicableQuestion ? '예' : '아니오'}
+ </span>
+ </div> */}
+
+ {/* SHI 연동제 적용여부 */}
+ <div className="flex flex-row items-center justify-between rounded-lg border p-4">
+ <div className="space-y-0.5">
+ <Label className="text-base">SHI 연동제 적용</Label>
+ <p className="text-sm text-muted-foreground">
+ 해당 업체에 연동제를 적용할지 결정합니다.
+ </p>
+ </div>
+ <div className="flex items-center gap-3">
+ <span className={`text-sm ${shiPriceAdjustmentApplied === false ? 'font-medium' : 'text-muted-foreground'}`}>
+ 미적용
+ </span>
+ <Switch
+ checked={shiPriceAdjustmentApplied === true}
+ onCheckedChange={(checked) => setSHIPriceAdjustmentApplied(checked)}
+ />
+ <span className={`text-sm ${shiPriceAdjustmentApplied === true ? 'font-medium' : 'text-muted-foreground'}`}>
+ 적용
+ </span>
+ </div>
+ </div>
+
+ {/* 연동제 Note */}
+ <div className="space-y-2">
+ <Label htmlFor="price-adjustment-note">연동제 Note</Label>
+ <Textarea
+ id="price-adjustment-note"
+ placeholder="연동제 관련 추가 사항을 입력하세요"
+ value={priceAdjustmentNote}
+ onChange={(e) => setPriceAdjustmentNote(e.target.value)}
+ rows={4}
+ />
+ </div>
+
+ {/* 화학물질 여부 */}
+ {/* <div className="flex flex-row items-center justify-between rounded-lg border p-4">
+ <div className="space-y-0.5">
+ <Label className="text-base">화학물질 해당여부</Label>
+ <p className="text-sm text-muted-foreground">
+ 해당 업체가 화학물질 취급 대상인지 여부입니다.
+ </p>
+ </div>
+ <div className="flex items-center gap-3">
+ <span className={`text-sm ${hasChemicalSubstance === false ? 'font-medium' : 'text-muted-foreground'}`}>
+ 해당없음
+ </span>
+ <Switch
+ checked={hasChemicalSubstance === true}
+ onCheckedChange={(checked) => setHasChemicalSubstance(checked)}
+ />
+ <span className={`text-sm ${hasChemicalSubstance === true ? 'font-medium text-red-600' : 'text-muted-foreground'}`}>
+ 해당
+ </span>
+ </div>
+ </div> */}
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button onClick={handleSubmit} disabled={isSubmitting}>
+ {isSubmitting ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 저장 중...
+ </>
+ ) : (
+ "저장"
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
+
diff --git a/lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx b/lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx
new file mode 100644
index 00000000..f31caf5e
--- /dev/null
+++ b/lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx
@@ -0,0 +1,324 @@
+'use client'
+
+import React from 'react'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Badge } from '@/components/ui/badge'
+import { Separator } from '@/components/ui/separator'
+import { format } from 'date-fns'
+import { ko } from 'date-fns/locale'
+import { Loader2 } from 'lucide-react'
+
+interface PriceAdjustmentData {
+ id: number
+ itemName?: string | null
+ adjustmentReflectionPoint?: string | null
+ majorApplicableRawMaterial?: string | null
+ adjustmentFormula?: string | null
+ rawMaterialPriceIndex?: string | null
+ referenceDate?: Date | string | null
+ comparisonDate?: Date | string | null
+ adjustmentRatio?: string | null
+ notes?: string | null
+ adjustmentConditions?: string | null
+ majorNonApplicableRawMaterial?: string | null
+ adjustmentPeriod?: string | null
+ contractorWriter?: string | null
+ adjustmentDate?: Date | string | null
+ nonApplicableReason?: string | null
+ createdAt: Date | string
+ updatedAt: Date | string
+}
+
+interface VendorPriceAdjustmentViewDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ vendorName: string
+ priceAdjustmentResponse: boolean | null // 벤더가 응답한 연동제 적용 여부
+ biddingCompanyId: number
+}
+
+export function VendorPriceAdjustmentViewDialog({
+ open,
+ onOpenChange,
+ vendorName,
+ priceAdjustmentResponse,
+ biddingCompanyId,
+}: VendorPriceAdjustmentViewDialogProps) {
+ const [data, setData] = React.useState<PriceAdjustmentData | null>(null)
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [error, setError] = React.useState<string | null>(null)
+
+ // 다이얼로그가 열릴 때 데이터 로드
+ React.useEffect(() => {
+ if (open && biddingCompanyId) {
+ loadPriceAdjustmentData()
+ }
+ }, [open, biddingCompanyId])
+
+ const loadPriceAdjustmentData = async () => {
+ setIsLoading(true)
+ setError(null)
+ try {
+ // 서버에서 연동제 폼 데이터 조회
+ const { getPriceAdjustmentFormByBiddingCompanyId } = await import('@/lib/bidding/detail/service')
+ const formData = await getPriceAdjustmentFormByBiddingCompanyId(biddingCompanyId)
+ setData(formData)
+ } catch (err) {
+ console.error('Failed to load price adjustment data:', err)
+ setError('연동제 정보를 불러오는데 실패했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 날짜 포맷팅 헬퍼
+ const formatDateValue = (date: Date | string | null | undefined) => {
+ if (!date) return '-'
+ try {
+ const dateObj = typeof date === 'string' ? new Date(date) : date
+ return format(dateObj, 'yyyy-MM-dd', { locale: ko })
+ } catch {
+ return '-'
+ }
+ }
+
+ // 연동제 적용 여부 판단
+ const isApplied = priceAdjustmentResponse === true
+ const isNotApplied = priceAdjustmentResponse === false
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <span>하도급대금등 연동표</span>
+ <Badge variant="secondary">{vendorName}</Badge>
+ {isApplied && (
+ <Badge variant="default" className="bg-green-600 hover:bg-green-700">
+ 연동제 적용
+ </Badge>
+ )}
+ {isNotApplied && (
+ <Badge variant="outline" className="border-red-500 text-red-600">
+ 연동제 미적용
+ </Badge>
+ )}
+ {priceAdjustmentResponse === null && (
+ <Badge variant="outline">해당없음</Badge>
+ )}
+ </DialogTitle>
+ <DialogDescription>
+ 협력업체가 제출한 연동제 적용 정보입니다.
+ {isApplied && " (연동제 적용)"}
+ {isNotApplied && " (연동제 미적용)"}
+ </DialogDescription>
+ </DialogHeader>
+
+ {isLoading ? (
+ <div className="flex items-center justify-center py-12">
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
+ <span className="ml-2 text-muted-foreground">연동제 정보를 불러오는 중...</span>
+ </div>
+ ) : error ? (
+ <div className="py-8 text-center text-red-600">{error}</div>
+ ) : !data && priceAdjustmentResponse !== null ? (
+ <div className="py-8 text-center text-muted-foreground">연동제 상세 정보가 없습니다.</div>
+ ) : priceAdjustmentResponse === null ? (
+ <div className="py-8 text-center text-muted-foreground">해당 업체는 연동제 관련 응답을 하지 않았습니다.</div>
+ ) : (
+ <div className="space-y-6">
+ {/* 기본 정보 */}
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">기본 정보</h3>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="text-xs text-gray-500">물품등의 명칭</label>
+ <p className="text-sm font-medium">{data?.itemName || '-'}</p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">연동제 적용 여부</label>
+ <div className="mt-1">
+ {isApplied && (
+ <Badge variant="default" className="bg-green-600 hover:bg-green-700">
+ 예 (연동제 적용)
+ </Badge>
+ )}
+ {isNotApplied && (
+ <Badge variant="outline" className="border-red-500 text-red-600">
+ 아니오 (연동제 미적용)
+ </Badge>
+ )}
+ </div>
+ </div>
+ {isApplied && (
+ <div>
+ <label className="text-xs text-gray-500">조정대금 반영시점</label>
+ <p className="text-sm font-medium">{data?.adjustmentReflectionPoint || '-'}</p>
+ </div>
+ )}
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 원재료 정보 */}
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">원재료 정보</h3>
+ <div className="space-y-4">
+ {isApplied && (
+ <div>
+ <label className="text-xs text-gray-500">연동대상 주요 원재료</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data?.majorApplicableRawMaterial || '-'}
+ </p>
+ </div>
+ )}
+ {isNotApplied && (
+ <>
+ <div>
+ <label className="text-xs text-gray-500">연동 미적용 주요 원재료</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data?.majorNonApplicableRawMaterial || '-'}
+ </p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">연동 미적용 사유</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data?.nonApplicableReason || '-'}
+ </p>
+ </div>
+ </>
+ )}
+ </div>
+ </div>
+
+ {isApplied && data && (
+ <>
+ <Separator />
+
+ {/* 연동 공식 및 지표 */}
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">연동 공식 및 지표</h3>
+ <div className="space-y-4">
+ <div>
+ <label className="text-xs text-gray-500">하도급대금등 연동 산식</label>
+ <div className="p-3 bg-gray-50 rounded-md">
+ <p className="text-sm font-mono whitespace-pre-wrap">
+ {data.adjustmentFormula || '-'}
+ </p>
+ </div>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">원재료 가격 기준지표</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.rawMaterialPriceIndex || '-'}
+ </p>
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="text-xs text-gray-500">원재료 기준 가격의 변동률 산정을 위한 기준시점</label>
+ <p className="text-sm font-medium">{data.referenceDate ? formatDateValue(data.referenceDate) : '-'}</p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">원재료 기준 가격의 변동률 산정을 위한 비교시점</label>
+ <p className="text-sm font-medium">{data.comparisonDate ? formatDateValue(data.comparisonDate) : '-'}</p>
+ </div>
+ </div>
+ {data.adjustmentRatio && (
+ <div>
+ <label className="text-xs text-gray-500">반영비율</label>
+ <p className="text-sm font-medium">
+ {data.adjustmentRatio}%
+ </p>
+ </div>
+ )}
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 조정 조건 및 기타 */}
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">조정 조건 및 기타</h3>
+ <div className="space-y-4">
+ <div>
+ <label className="text-xs text-gray-500">조정요건</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.adjustmentConditions || '-'}
+ </p>
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="text-xs text-gray-500">조정주기</label>
+ <p className="text-sm font-medium">{data.adjustmentPeriod || '-'}</p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">조정일</label>
+ <p className="text-sm font-medium">{data.adjustmentDate ? formatDateValue(data.adjustmentDate) : '-'}</p>
+ </div>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">수탁기업(협력사)작성자</label>
+ <p className="text-sm font-medium">{data.contractorWriter || '-'}</p>
+ </div>
+ {data.notes && (
+ <div>
+ <label className="text-xs text-gray-500">기타사항</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.notes}
+ </p>
+ </div>
+ )}
+ </div>
+ </div>
+ </>
+ )}
+
+ {isNotApplied && data && (
+ <>
+ <Separator />
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">작성자 정보</h3>
+ <div>
+ <label className="text-xs text-gray-500">수탁기업(협력사) 작성자</label>
+ <p className="text-sm font-medium">{data.contractorWriter || '-'}</p>
+ </div>
+ </div>
+ </>
+ )}
+
+ {data && (
+ <>
+ <Separator />
+
+ {/* 메타 정보 */}
+ <div className="text-xs text-gray-500 space-y-1">
+ <p>작성일: {formatDateValue(data.createdAt)}</p>
+ <p>수정일: {formatDateValue(data.updatedAt)}</p>
+ </div>
+ </>
+ )}
+
+ <Separator />
+
+ {/* 참고 경고문 */}
+ <div className="text-xs text-red-600 space-y-2 bg-red-50 p-3 rounded-md border border-red-200">
+ <p className="font-medium">※ 참고사항</p>
+ <div className="space-y-1">
+ <p>• 납품대금의 10% 이상을 차지하는 주요 원재료가 있는 경우 모든 주요 원재료에 대해서 적용 또는 미적용에 대한 연동표를 작성해야 한다.</p>
+ <p>• 납품대급연동표를 허위로 작성하거나 근거자료를 허위로 제출할 경우 본 계약이 체결되지 않을 수 있으며, 본 계약이 체결되었더라도 계약의 전부 또는 일부를 해제 또는 해지할 수 있다.</p>
+ </div>
+ </div>
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+}
+