summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/[lng]/evcp/(evcp)/bid/[id]/detail/page.tsx52
-rw-r--r--app/[lng]/evcp/(evcp)/bid/[id]/layout.tsx91
-rw-r--r--app/[lng]/evcp/(evcp)/bid/[id]/page.tsx52
-rw-r--r--app/[lng]/evcp/(evcp)/bid/[id]/pre-quote/page.tsx52
-rw-r--r--components/bidding/bidding-conditions-edit.tsx304
-rw-r--r--components/bidding/bidding-info-header.tsx149
-rw-r--r--components/bidding/price-adjustment-dialog.tsx200
-rw-r--r--db/schema/bidding.ts63
-rw-r--r--lib/bidding/detail/service.ts174
-rw-r--r--lib/bidding/detail/table/bidding-detail-content.tsx37
-rw-r--r--lib/bidding/detail/table/bidding-detail-header.tsx81
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-columns.tsx39
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-table.tsx46
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx152
-rw-r--r--lib/bidding/list/biddings-table-columns.tsx4
-rw-r--r--lib/bidding/list/create-bidding-dialog.tsx253
-rw-r--r--lib/bidding/service.ts143
-rw-r--r--lib/bidding/validation.ts14
-rw-r--r--lib/bidding/vendor/partners-bidding-detail.tsx241
-rw-r--r--lib/tech-vendors/table/import-button.tsx2
20 files changed, 1939 insertions, 210 deletions
diff --git a/app/[lng]/evcp/(evcp)/bid/[id]/detail/page.tsx b/app/[lng]/evcp/(evcp)/bid/[id]/detail/page.tsx
new file mode 100644
index 00000000..ac9b5df4
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/bid/[id]/detail/page.tsx
@@ -0,0 +1,52 @@
+import { Suspense } from 'react'
+import { notFound } from 'next/navigation'
+import { getBiddingDetailData } from '@/lib/bidding/detail/service'
+import { BiddingDetailContent } from '@/lib/bidding/detail/table/bidding-detail-content'
+
+// 메타데이터 생성
+export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params
+ const parsedId = parseInt(id)
+ if (isNaN(parsedId)) return { title: '입찰 관리상세' }
+
+ try {
+ const detailData = await getBiddingDetailData(parsedId)
+ return {
+ title: detailData.bidding ? `${detailData.bidding.title} - 입찰 관리상세` : '입찰 관리상세',
+ }
+ } catch {
+ return { title: '입찰 관리상세' }
+ }
+}
+
+interface PageProps {
+ params: Promise<{ id: string }>
+}
+
+export default async function Page({ params }: PageProps) {
+ const { id } = await params
+ const parsedId = parseInt(id)
+
+ if (isNaN(parsedId)) {
+ notFound()
+ }
+
+ // 통합 데이터 로딩 함수 사용
+ const detailData = await getBiddingDetailData(parsedId)
+
+ if (!detailData.bidding) {
+ notFound()
+ }
+
+ return (
+ <Suspense fallback={<div className="p-8">로딩 중...</div>}>
+ <BiddingDetailContent
+ bidding={detailData.bidding}
+ quotationDetails={detailData.quotationDetails}
+ quotationVendors={detailData.quotationVendors}
+ biddingCompanies={detailData.biddingCompanies}
+ prItems={detailData.prItems}
+ />
+ </Suspense>
+ )
+}
diff --git a/app/[lng]/evcp/(evcp)/bid/[id]/layout.tsx b/app/[lng]/evcp/(evcp)/bid/[id]/layout.tsx
new file mode 100644
index 00000000..b675aed1
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/bid/[id]/layout.tsx
@@ -0,0 +1,91 @@
+import { Metadata } from "next"
+
+import { Separator } from "@/components/ui/separator"
+import { SidebarNav } from "@/components/layout/sidebar-nav"
+import { getBiddingById, getBiddingConditions } from "@/lib/bidding/service"
+import { Bidding } from "@/db/schema/bidding"
+import { Button } from "@/components/ui/button"
+import { ArrowLeft } from "lucide-react"
+import Link from "next/link"
+import { BiddingInfoHeader } from "@/components/bidding/bidding-info-header"
+import { BiddingConditionsEdit } from "@/components/bidding/bidding-conditions-edit"
+export const metadata: Metadata = {
+ title: "Bidding Detail",
+}
+
+export default async function SettingsLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode
+ params: { lng: string , id: string}
+}) {
+
+ // 1) URL 파라미터에서 id 추출, Number로 변환
+ const resolvedParams = await params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+ // 2) DB에서 해당 입찰 정보 조회
+ const bidding: Bidding | null = await getBiddingById(idAsNumber)
+ const biddingConditions = await getBiddingConditions(idAsNumber)
+
+ // 3) 사이드바 메뉴
+ const sidebarNavItems = [
+ {
+ title: "입찰 사전견적",
+ href: `/${lng}/evcp/bid/${id}/pre-quote`,
+ },
+ {
+ title: "입찰 관리상세",
+ href: `/${lng}/evcp/bid/${id}/detail`,
+ },
+ ]
+
+ return (
+ <>
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
+ <div className="hidden space-y-6 p-10 pb-16 md:block">
+ {/* RFQ 목록으로 돌아가는 링크 추가 */}
+ <div className="flex items-center justify-end mb-4">
+ <Link href={`/${lng}/evcp/bid`} passHref>
+ <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto">
+ <ArrowLeft className="mr-1 h-4 w-4" />
+ <span>입찰 목록으로 돌아가기</span>
+ </Button>
+ </Link>
+ </div>
+ <div className="space-y-0.5">
+ {/* 4) 입찰 정보가 있으면 번호 + 제목 + "상세 정보" 표기 */}
+ <h2 className="text-2xl font-bold tracking-tight">
+ {bidding
+ ? `${bidding.biddingNumber ?? ""} - ${bidding.title}`
+ : "Loading Bidding..."}
+ </h2>
+ </div>
+ {/* 입찰 정보 헤더 */}
+ <BiddingInfoHeader bidding={bidding} />
+
+ {/* 입찰 조건 */}
+ {bidding && (
+ <BiddingConditionsEdit
+ biddingId={bidding.id}
+ initialConditions={biddingConditions}
+ />
+ )}
+
+ <Separator className="my-6" />
+ <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
+ <aside className="-mx-4 lg:w-1/5">
+ <SidebarNav items={sidebarNavItems} />
+ </aside>
+ <div className="flex-1 overflow-auto max-w-full">{children}</div>
+ </div>
+ </div>
+ </section>
+ </div>
+ </>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/bid/[id]/page.tsx b/app/[lng]/evcp/(evcp)/bid/[id]/page.tsx
index e4051f9b..ca0788a5 100644
--- a/app/[lng]/evcp/(evcp)/bid/[id]/page.tsx
+++ b/app/[lng]/evcp/(evcp)/bid/[id]/page.tsx
@@ -1,52 +1,12 @@
-import { Suspense } from 'react'
-import { notFound } from 'next/navigation'
-import { getBiddingDetailData } from '@/lib/bidding/detail/service'
-import { BiddingDetailContent } from '@/lib/bidding/detail/table/bidding-detail-content'
-
-// 메타데이터 생성
-export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
- const { id } = await params
- const parsedId = parseInt(id)
- if (isNaN(parsedId)) return { title: '입찰 상세' }
-
- try {
- const detailData = await getBiddingDetailData(parsedId)
- return {
- title: detailData.bidding ? `${detailData.bidding.title} - 입찰 상세` : '입찰 상세',
- }
- } catch {
- return { title: '입찰 상세' }
- }
-}
+import { redirect } from 'next/navigation'
interface PageProps {
- params: Promise<{ id: string }>
+ params: Promise<{ lng: string; id: string }>
}
export default async function Page({ params }: PageProps) {
- const { id } = await params
- const parsedId = parseInt(id)
-
- if (isNaN(parsedId)) {
- notFound()
- }
-
- // 통합 데이터 로딩 함수 사용
- const detailData = await getBiddingDetailData(parsedId)
-
- if (!detailData.bidding) {
- notFound()
- }
-
- return (
- <Suspense fallback={<div className="p-8">로딩 중...</div>}>
- <BiddingDetailContent
- bidding={detailData.bidding}
- quotationDetails={detailData.quotationDetails}
- quotationVendors={detailData.quotationVendors}
- biddingCompanies={detailData.biddingCompanies}
- prItems={detailData.prItems}
- />
- </Suspense>
- )
+ const { lng, id } = await params
+
+ // 기본적으로 입찰 사전견적 페이지로 리다이렉트
+ redirect(`/${lng}/evcp/bid/${id}/pre-quote`)
}
diff --git a/app/[lng]/evcp/(evcp)/bid/[id]/pre-quote/page.tsx b/app/[lng]/evcp/(evcp)/bid/[id]/pre-quote/page.tsx
new file mode 100644
index 00000000..e2c22b22
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/bid/[id]/pre-quote/page.tsx
@@ -0,0 +1,52 @@
+import { Suspense } from 'react'
+import { notFound } from 'next/navigation'
+import { getBiddingDetailData } from '@/lib/bidding/detail/service'
+import { BiddingDetailContent } from '@/lib/bidding/detail/table/bidding-detail-content'
+
+// 메타데이터 생성
+export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params
+ const parsedId = parseInt(id)
+ if (isNaN(parsedId)) return { title: '입찰 사전견적' }
+
+ try {
+ const detailData = await getBiddingDetailData(parsedId)
+ return {
+ title: detailData.bidding ? `${detailData.bidding.title} - 입찰 사전견적` : '입찰 사전견적',
+ }
+ } catch {
+ return { title: '입찰 사전견적' }
+ }
+}
+
+interface PageProps {
+ params: Promise<{ id: string }>
+}
+
+export default async function Page({ params }: PageProps) {
+ const { id } = await params
+ const parsedId = parseInt(id)
+
+ if (isNaN(parsedId)) {
+ notFound()
+ }
+
+ // 통합 데이터 로딩 함수 사용
+ const detailData = await getBiddingDetailData(parsedId)
+
+ if (!detailData.bidding) {
+ notFound()
+ }
+
+ return (
+ <Suspense fallback={<div className="p-8">로딩 중...</div>}>
+ <BiddingDetailContent
+ bidding={detailData.bidding}
+ quotationDetails={detailData.quotationDetails}
+ quotationVendors={detailData.quotationVendors}
+ biddingCompanies={detailData.biddingCompanies}
+ prItems={detailData.prItems}
+ />
+ </Suspense>
+ )
+}
diff --git a/components/bidding/bidding-conditions-edit.tsx b/components/bidding/bidding-conditions-edit.tsx
new file mode 100644
index 00000000..a78bb0e0
--- /dev/null
+++ b/components/bidding/bidding-conditions-edit.tsx
@@ -0,0 +1,304 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { useTransition } from "react"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { Label } from "@/components/ui/label"
+import { Switch } from "@/components/ui/switch"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Pencil, Save, X } from "lucide-react"
+import { getBiddingConditions, updateBiddingConditions } from "@/lib/bidding/service"
+import { useToast } from "@/hooks/use-toast"
+
+interface BiddingConditionsEditProps {
+ biddingId: number
+ initialConditions?: any | null
+}
+
+export function BiddingConditionsEdit({ biddingId, initialConditions }: BiddingConditionsEditProps) {
+ const router = useRouter()
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+ const [isEditing, setIsEditing] = React.useState(false)
+ const [conditions, setConditions] = React.useState({
+ paymentTerms: initialConditions?.paymentTerms || "",
+ taxConditions: initialConditions?.taxConditions || "",
+ incoterms: initialConditions?.incoterms || "",
+ contractDeliveryDate: initialConditions?.contractDeliveryDate
+ ? new Date(initialConditions.contractDeliveryDate).toISOString().split('T')[0]
+ : "",
+ shippingPort: initialConditions?.shippingPort || "",
+ destinationPort: initialConditions?.destinationPort || "",
+ isPriceAdjustmentApplicable: initialConditions?.isPriceAdjustmentApplicable || false,
+ sparePartOptions: initialConditions?.sparePartOptions || "",
+ })
+
+ const handleSave = () => {
+ startTransition(async () => {
+ try {
+ const result = await updateBiddingConditions(biddingId, conditions)
+
+ if (result.success) {
+ toast({
+ title: "성공",
+ description: result.message,
+ })
+ setIsEditing(false)
+ router.refresh()
+ } else {
+ toast({
+ title: "오류",
+ description: result.error,
+ variant: "destructive",
+ })
+ }
+ } catch (error) {
+ console.error('Error updating bidding conditions:', error)
+ toast({
+ title: "오류",
+ description: "입찰 조건 업데이트 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ })
+ }
+
+ const handleCancel = () => {
+ setConditions({
+ paymentTerms: initialConditions?.paymentTerms || "",
+ taxConditions: initialConditions?.taxConditions || "",
+ incoterms: initialConditions?.incoterms || "",
+ contractDeliveryDate: initialConditions?.contractDeliveryDate
+ ? new Date(initialConditions.contractDeliveryDate).toISOString().split('T')[0]
+ : "",
+ shippingPort: initialConditions?.shippingPort || "",
+ destinationPort: initialConditions?.destinationPort || "",
+ isPriceAdjustmentApplicable: initialConditions?.isPriceAdjustmentApplicable || false,
+ sparePartOptions: initialConditions?.sparePartOptions || "",
+ })
+ setIsEditing(false)
+ }
+
+ if (!isEditing) {
+ return (
+ <Card className="mt-6">
+ <CardHeader className="flex flex-row items-center justify-between">
+ <CardTitle>입찰 조건</CardTitle>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsEditing(true)}
+ className="flex items-center gap-2"
+ >
+ <Pencil className="w-4 h-4" />
+ 수정
+ </Button>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 text-sm">
+ <div>
+ <Label className="text-muted-foreground">지급조건</Label>
+ <p className="font-medium">{conditions.paymentTerms || "미설정"}</p>
+ </div>
+ <div>
+ <Label className="text-muted-foreground">세금조건</Label>
+ <p className="font-medium">{conditions.taxConditions || "미설정"}</p>
+ </div>
+ <div>
+ <Label className="text-muted-foreground">운송조건</Label>
+ <p className="font-medium">{conditions.incoterms || "미설정"}</p>
+ </div>
+ <div>
+ <Label className="text-muted-foreground">계약 납품일</Label>
+ <p className="font-medium">
+ {conditions.contractDeliveryDate
+ ? new Date(conditions.contractDeliveryDate).toLocaleDateString('ko-KR')
+ : "미설정"
+ }
+ </p>
+ </div>
+ <div>
+ <Label className="text-muted-foreground">선적지</Label>
+ <p className="font-medium">{conditions.shippingPort || "미설정"}</p>
+ </div>
+ <div>
+ <Label className="text-muted-foreground">도착지</Label>
+ <p className="font-medium">{conditions.destinationPort || "미설정"}</p>
+ </div>
+ <div>
+ <Label className="text-muted-foreground">연동제 적용</Label>
+ <p className="font-medium">{conditions.isPriceAdjustmentApplicable ? "적용 가능" : "적용 불가"}</p>
+ </div>
+ {conditions.sparePartOptions && (
+ <div className="col-span-full">
+ <Label className="text-muted-foreground">스페어파트 옵션</Label>
+ <p className="font-medium">{conditions.sparePartOptions}</p>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ )
+ }
+
+ return (
+ <Card className="mt-6">
+ <CardHeader className="flex flex-row items-center justify-between">
+ <CardTitle>입찰 조건 수정</CardTitle>
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleCancel}
+ disabled={isPending}
+ className="flex items-center gap-2"
+ >
+ <X className="w-4 h-4" />
+ 취소
+ </Button>
+ <Button
+ size="sm"
+ onClick={handleSave}
+ disabled={isPending}
+ className="flex items-center gap-2"
+ >
+ <Save className="w-4 h-4" />
+ 저장
+ </Button>
+ </div>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
+ <div className="space-y-2">
+ <Label htmlFor="paymentTerms">지급조건 *</Label>
+ <Input
+ id="paymentTerms"
+ placeholder="예: 월말결제, 60일"
+ value={conditions.paymentTerms}
+ onChange={(e) => setConditions(prev => ({
+ ...prev,
+ paymentTerms: e.target.value
+ }))}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="taxConditions">세금조건 *</Label>
+ <Input
+ id="taxConditions"
+ placeholder="예: VAT 별도, 원천세 3.3%"
+ value={conditions.taxConditions}
+ onChange={(e) => setConditions(prev => ({
+ ...prev,
+ taxConditions: e.target.value
+ }))}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="incoterms">운송조건(인코텀즈) *</Label>
+ <Select
+ value={conditions.incoterms}
+ onValueChange={(value) => setConditions(prev => ({
+ ...prev,
+ incoterms: value
+ }))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="인코텀즈 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="EXW">EXW (Ex Works)</SelectItem>
+ <SelectItem value="FCA">FCA (Free Carrier)</SelectItem>
+ <SelectItem value="CPT">CPT (Carriage Paid To)</SelectItem>
+ <SelectItem value="CIP">CIP (Carriage and Insurance Paid to)</SelectItem>
+ <SelectItem value="DAP">DAP (Delivered at Place)</SelectItem>
+ <SelectItem value="DPU">DPU (Delivered at Place Unloaded)</SelectItem>
+ <SelectItem value="DDP">DDP (Delivered Duty Paid)</SelectItem>
+ <SelectItem value="FAS">FAS (Free Alongside Ship)</SelectItem>
+ <SelectItem value="FOB">FOB (Free on Board)</SelectItem>
+ <SelectItem value="CFR">CFR (Cost and Freight)</SelectItem>
+ <SelectItem value="CIF">CIF (Cost, Insurance, and Freight)</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="contractDeliveryDate">계약 납품일</Label>
+ <Input
+ id="contractDeliveryDate"
+ type="date"
+ value={conditions.contractDeliveryDate}
+ onChange={(e) => setConditions(prev => ({
+ ...prev,
+ contractDeliveryDate: e.target.value
+ }))}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="shippingPort">선적지</Label>
+ <Input
+ id="shippingPort"
+ placeholder="예: 부산항, 인천항"
+ value={conditions.shippingPort}
+ onChange={(e) => setConditions(prev => ({
+ ...prev,
+ shippingPort: e.target.value
+ }))}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="destinationPort">도착지</Label>
+ <Input
+ id="destinationPort"
+ placeholder="예: 현장 직납, 창고 납품"
+ value={conditions.destinationPort}
+ onChange={(e) => setConditions(prev => ({
+ ...prev,
+ destinationPort: e.target.value
+ }))}
+ />
+ </div>
+ </div>
+
+ <div className="flex items-center space-x-2">
+ <Switch
+ id="isPriceAdjustmentApplicable"
+ checked={conditions.isPriceAdjustmentApplicable}
+ onCheckedChange={(checked) => setConditions(prev => ({
+ ...prev,
+ isPriceAdjustmentApplicable: checked
+ }))}
+ />
+ <Label htmlFor="isPriceAdjustmentApplicable">연동제 적용 가능</Label>
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="sparePartOptions">스페어파트 옵션</Label>
+ <Textarea
+ id="sparePartOptions"
+ placeholder="스페어파트 관련 옵션을 입력하세요"
+ value={conditions.sparePartOptions}
+ onChange={(e) => setConditions(prev => ({
+ ...prev,
+ sparePartOptions: e.target.value
+ }))}
+ rows={3}
+ />
+ </div>
+ </CardContent>
+ </Card>
+ )
+}
diff --git a/components/bidding/bidding-info-header.tsx b/components/bidding/bidding-info-header.tsx
new file mode 100644
index 00000000..c140920b
--- /dev/null
+++ b/components/bidding/bidding-info-header.tsx
@@ -0,0 +1,149 @@
+import { Bidding } from '@/db/schema/bidding'
+import { Building2, Package, User, DollarSign, Calendar } from 'lucide-react'
+import { contractTypeLabels, biddingTypeLabels } from '@/db/schema/bidding'
+
+interface BiddingInfoHeaderProps {
+ bidding: Bidding
+}
+
+function formatDate(date: Date | string | null | undefined, locale: 'KR' | 'EN' = 'KR'): string {
+ if (!date) return ''
+
+ const dateObj = typeof date === 'string' ? new Date(date) : date
+
+ if (locale === 'KR') {
+ return dateObj.toLocaleDateString('ko-KR', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit'
+ }).replace(/\./g, '-').replace(/-$/, '')
+ }
+
+ return dateObj.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit'
+ })
+}
+
+export function BiddingInfoHeader({ bidding }: BiddingInfoHeaderProps) {
+ return (
+ <div className="bg-white border rounded-lg p-6 mb-6 shadow-sm">
+ {/* 주요 정보 섹션 */}
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
+ {/* 프로젝트 정보 */}
+ {bidding.projectName && (
+ <div className="space-y-1">
+ <div className="flex items-center gap-2 text-sm text-gray-500">
+ <Building2 className="w-4 h-4" />
+ <span>프로젝트</span>
+ </div>
+ <div className="font-medium text-gray-900">{bidding.projectName}</div>
+ </div>
+ )}
+
+ {/* 품목 정보 */}
+ {bidding.itemName && (
+ <div className="space-y-1">
+ <div className="flex items-center gap-2 text-sm text-gray-500">
+ <Package className="w-4 h-4" />
+ <span>품목</span>
+ </div>
+ <div className="font-medium text-gray-900">{bidding.itemName}</div>
+ </div>
+ )}
+
+ {/* 담당자 정보 */}
+ {bidding.managerName && (
+ <div className="space-y-1">
+ <div className="flex items-center gap-2 text-sm text-gray-500">
+ <User className="w-4 h-4" />
+ <span>담당자</span>
+ </div>
+ <div className="font-medium text-gray-900">{bidding.managerName}</div>
+ </div>
+ )}
+
+ {/* 예산 정보 */}
+ {bidding.budget && (
+ <div className="space-y-1">
+ <div className="flex items-center gap-2 text-sm text-gray-500">
+ <DollarSign className="w-4 h-4" />
+ <span>예산</span>
+ </div>
+ <div className="font-semibold text-gray-900">
+ {new Intl.NumberFormat('ko-KR', {
+ style: 'currency',
+ currency: bidding.currency || 'KRW',
+ }).format(Number(bidding.budget))}
+ </div>
+ </div>
+ )}
+ </div>
+
+ {/* 구분선 */}
+ <div className="border-t border-gray-100 pt-4 mb-4">
+ {/* 계약 정보 */}
+ <div className="flex flex-wrap gap-8 text-sm">
+ <div className="flex items-center gap-2">
+ <span className="text-gray-500">계약</span>
+ <span className="font-medium">{contractTypeLabels[bidding.contractType]}</span>
+ </div>
+
+ <div className="flex items-center gap-2">
+ <span className="text-gray-500">유형</span>
+ <span className="font-medium">{biddingTypeLabels[bidding.biddingType]}</span>
+ </div>
+
+ <div className="flex items-center gap-2">
+ <span className="text-gray-500">낙찰</span>
+ <span className="font-medium">{bidding.awardCount === 'single' ? '단수' : '복수'}</span>
+ </div>
+
+ <div className="flex items-center gap-2">
+ <span className="text-gray-500">통화</span>
+ <span className="font-mono font-medium">{bidding.currency}</span>
+ </div>
+ </div>
+ </div>
+
+ {/* 일정 정보 */}
+ {(bidding.submissionStartDate || bidding.evaluationDate || bidding.preQuoteDate || bidding.biddingRegistrationDate) && (
+ <div className="border-t border-gray-100 pt-4">
+ <div className="flex items-center gap-2 mb-3 text-sm text-gray-500">
+ <Calendar className="w-4 h-4" />
+ <span>일정 정보</span>
+ </div>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
+ {bidding.submissionStartDate && bidding.submissionEndDate && (
+ <div>
+ <span className="text-gray-500">제출기간</span>
+ <div className="font-medium">
+ {formatDate(bidding.submissionStartDate, 'KR')} ~ {formatDate(bidding.submissionEndDate, 'KR')}
+ </div>
+ </div>
+ )}
+ {bidding.biddingRegistrationDate && (
+ <div>
+ <span className="text-gray-500">입찰등록일</span>
+ <div className="font-medium">{formatDate(bidding.biddingRegistrationDate, 'KR')}</div>
+ </div>
+ )}
+ {bidding.preQuoteDate && (
+ <div>
+ <span className="text-gray-500">사전견적일</span>
+ <div className="font-medium">{formatDate(bidding.preQuoteDate, 'KR')}</div>
+ </div>
+ )}
+ {bidding.evaluationDate && (
+ <div>
+ <span className="text-gray-500">평가일</span>
+ <div className="font-medium">{formatDate(bidding.evaluationDate, 'KR')}</div>
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ )
+}
diff --git a/components/bidding/price-adjustment-dialog.tsx b/components/bidding/price-adjustment-dialog.tsx
new file mode 100644
index 00000000..b53f9ef1
--- /dev/null
+++ b/components/bidding/price-adjustment-dialog.tsx
@@ -0,0 +1,200 @@
+'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'
+
+interface PriceAdjustmentData {
+ id: number
+ itemName?: string | null
+ adjustmentReflectionPoint?: string | null
+ majorApplicableRawMaterial?: string | null
+ adjustmentFormula?: string | null
+ rawMaterialPriceIndex?: string | null
+ referenceDate?: Date | null
+ comparisonDate?: Date | null
+ adjustmentRatio?: string | null
+ notes?: string | null
+ adjustmentConditions?: string | null
+ majorNonApplicableRawMaterial?: string | null
+ adjustmentPeriod?: string | null
+ contractorWriter?: string | null
+ adjustmentDate?: Date | null
+ nonApplicableReason?: string | null
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface PriceAdjustmentDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ data: PriceAdjustmentData | null
+ vendorName: string
+}
+
+function formatDate(date: Date | null | undefined): string {
+ if (!date) return '-'
+ return new Date(date).toLocaleDateString('ko-KR', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ })
+}
+
+export function PriceAdjustmentDialog({
+ open,
+ onOpenChange,
+ data,
+ vendorName,
+}: PriceAdjustmentDialogProps) {
+ if (!data) return null
+
+ 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>
+ </DialogTitle>
+ <DialogDescription>
+ 협력업체가 제출한 연동제 적용 정보입니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <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>
+ <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">
+ <div>
+ <label className="text-xs text-gray-500">연동대상 주요 원재료</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.majorApplicableRawMaterial || '-'}
+ </p>
+ </div>
+ <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>
+
+ <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">{formatDate(data.referenceDate)}</p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">비교시점</label>
+ <p className="text-sm font-medium">{formatDate(data.comparisonDate)}</p>
+ </div>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">연동 비율</label>
+ <p className="text-sm font-medium">
+ {data.adjustmentRatio ? `${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">{formatDate(data.adjustmentDate)}</p>
+ </div>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">수탁기업(협력사) 작성자</label>
+ <p className="text-sm font-medium">{data.contractorWriter || '-'}</p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">기타 사항</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.notes || '-'}
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 메타 정보 */}
+ <div className="text-xs text-gray-500 space-y-1">
+ <p>작성일: {formatDate(data.createdAt)}</p>
+ <p>수정일: {formatDate(data.updatedAt)}</p>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/db/schema/bidding.ts b/db/schema/bidding.ts
index 723bee32..710fb60d 100644
--- a/db/schema/bidding.ts
+++ b/db/schema/bidding.ts
@@ -256,7 +256,7 @@ export const prItemsForBidding = pgTable('pr_items_for_bidding', {
// 12. 입찰 조건 테이블 (SHI 구매자가 제시하는 조건들)
export const biddingConditions = pgTable('bidding_conditions', {
id: serial('id').primaryKey(),
- biddingCompanyId: integer('bidding_company_id').references(() => biddingCompanies.id).notNull(),
+ biddingId: integer('bidding_id').references(() => biddings.id).notNull(),
// 지급조건
paymentTerms: text('payment_terms'), // 지급조건 옵션들 (JSON 배열)
@@ -347,6 +347,7 @@ export const companyConditionResponses = pgTable('company_condition_responses',
// 계약 및 납기 응답
proposedContractDeliveryDate: date('proposed_contract_delivery_date'), // 제안 계약납기일
priceAdjustmentResponse: boolean('price_adjustment_response'), // 연동제적용 응답
+ isInitialResponse: boolean('is_initial_response'), // 초도여부 응답
// 무역조건 응답
incotermsResponse: varchar('incoterms_response', { length: 100 }), // 선택된 Incoterms
@@ -416,6 +417,63 @@ export const vendorSelectionResults = pgTable('vendor_selection_results', {
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})
+// 19. 하도급대금 등 연동표 테이블
+export const priceAdjustmentForms = pgTable('price_adjustment_forms', {
+ id: serial('id').primaryKey(),
+
+ // companyConditionResponses 테이블과 외래 키로 연결
+ companyConditionResponsesId: integer('company_condition_responses_id')
+ .notNull()
+ .references(() => companyConditionResponses.id),
+
+ // 품목등의 명칭
+ itemName: varchar('item_name', { length: 255 }),
+
+ // 조정대금 반영시점
+ adjustmentReflectionPoint: varchar('adjustment_reflection_point', { length: 255 }),
+
+ // 연동대상 주요 원재료
+ majorApplicableRawMaterial: text('major_applicable_raw_material'),
+
+ // 하도급대금등 연동 산식
+ adjustmentFormula: text('adjustment_formula'),
+
+ // 원재료 가격 기준지표
+ rawMaterialPriceIndex: text('raw_material_price_index'),
+
+ // 기준시점 및 비교시점
+ referenceDate: date('reference_date'), // 기준시점
+ comparisonDate: date('comparison_date'), // 비교시점
+
+ // 연동 비율
+ adjustmentRatio: decimal('adjustment_ratio', { precision: 5, scale: 2 }), // 소수점 2자리까지
+
+ // 기타 사항
+ notes: text('notes'),
+
+ // 조정요건
+ adjustmentConditions: text('adjustment_conditions'),
+
+ // 연동 미적용 주요 원재료
+ majorNonApplicableRawMaterial: text('major_non_applicable_raw_material'),
+
+ // 조정주기
+ adjustmentPeriod: varchar('adjustment_period', { length: 100 }),
+
+ // 수탁기업(협력사) 작성자
+ contractorWriter: varchar('contractor_writer', { length: 100 }),
+
+ // 조정일
+ adjustmentDate: date('adjustment_date'),
+
+ // 연동 미적용 사유
+ nonApplicableReason: text('non_applicable_reason'),
+
+ // 메타 정보
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
+});
+
// 타입 정의
export type Bidding = typeof biddings.$inferSelect
export type NewBidding = typeof biddings.$inferInsert
@@ -447,6 +505,9 @@ export type NewBiddingDocument = typeof biddingDocuments.$inferInsert
export type VendorSelectionResult = typeof vendorSelectionResults.$inferSelect
export type NewVendorSelectionResult = typeof vendorSelectionResults.$inferInsert
+export type PriceAdjustmentForm = typeof priceAdjustmentForms.$inferSelect
+export type NewPriceAdjustmentForm = typeof priceAdjustmentForms.$inferInsert
+
// 조인 타입 정의 (자주 사용될 것들)
export type BiddingWithDetails = Bidding & {
specificationMeeting?: SpecificationMeeting
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts
index 2ce17713..c811f46d 100644
--- a/lib/bidding/detail/service.ts
+++ b/lib/bidding/detail/service.ts
@@ -1,7 +1,8 @@
'use server'
import db from '@/db/db'
-import { biddings, prItemsForBidding, biddingDocuments, biddingCompanies, vendors, companyPrItemBids, companyConditionResponses, vendorSelectionResults, BiddingListItem, biddingConditions } from '@/db/schema'
+import { biddings, prItemsForBidding, biddingDocuments, biddingCompanies, vendors, companyPrItemBids, companyConditionResponses, vendorSelectionResults, BiddingListItem, biddingConditions, priceAdjustmentForms } from '@/db/schema'
+import { specificationMeetings } from '@/db/schema/bidding'
import { eq, and, sql, desc, ne } from 'drizzle-orm'
import { revalidatePath } from 'next/cache'
@@ -224,6 +225,7 @@ export async function getQuotationVendors(biddingId: number): Promise<QuotationV
proposedShippingPort: companyConditionResponses.proposedShippingPort,
proposedDestinationPort: companyConditionResponses.proposedDestinationPort,
priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse,
+ isInitialResponse: companyConditionResponses.isInitialResponse,
sparePartResponse: companyConditionResponses.sparePartResponse,
additionalProposals: companyConditionResponses.additionalProposals,
})
@@ -256,6 +258,7 @@ export async function getQuotationVendors(biddingId: number): Promise<QuotationV
proposedShippingPort: vendor.proposedShippingPort || '',
proposedDestinationPort: vendor.proposedDestinationPort || '',
priceAdjustmentResponse: vendor.priceAdjustmentResponse || false,
+ isInitialResponse: vendor.isInitialResponse || false,
sparePartResponse: vendor.sparePartResponse || '',
additionalProposals: vendor.additionalProposals || '',
documents: [] // TODO: 문서 정보 조회 로직 추가
@@ -828,6 +831,7 @@ export async function getBiddingDetailsForPartners(biddingId: number, companyId:
proposedShippingPort: companyConditionResponses.proposedShippingPort,
proposedDestinationPort: companyConditionResponses.proposedDestinationPort,
priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse,
+ isInitialResponse: companyConditionResponses.isInitialResponse,
sparePartResponse: companyConditionResponses.sparePartResponse,
additionalProposals: companyConditionResponses.additionalProposals,
responseSubmittedAt: companyConditionResponses.submittedAt,
@@ -859,9 +863,27 @@ export async function submitPartnerResponse(
proposedShippingPort?: string
proposedDestinationPort?: string
priceAdjustmentResponse?: boolean
+ isInitialResponse?: boolean
sparePartResponse?: string
additionalProposals?: string
finalQuoteAmount?: number
+ priceAdjustmentForm?: {
+ itemName?: string
+ adjustmentReflectionPoint?: string
+ majorApplicableRawMaterial?: string
+ adjustmentFormula?: string
+ rawMaterialPriceIndex?: string
+ referenceDate?: string
+ comparisonDate?: string
+ adjustmentRatio?: number
+ notes?: string
+ adjustmentConditions?: string
+ majorNonApplicableRawMaterial?: string
+ adjustmentPeriod?: string
+ contractorWriter?: string
+ adjustmentDate?: string
+ nonApplicableReason?: string
+ }
},
userId: string
) {
@@ -876,6 +898,7 @@ export async function submitPartnerResponse(
proposedShippingPort: response.proposedShippingPort,
proposedDestinationPort: response.proposedDestinationPort,
priceAdjustmentResponse: response.priceAdjustmentResponse,
+ isInitialResponse: response.isInitialResponse,
sparePartResponse: response.sparePartResponse,
additionalProposals: response.additionalProposals,
submittedAt: new Date(),
@@ -889,20 +912,67 @@ export async function submitPartnerResponse(
.where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId))
.limit(1)
+ let companyConditionResponseId: number
+
if (existingResponse.length > 0) {
// 업데이트
await tx
.update(companyConditionResponses)
.set(responseData)
.where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId))
+
+ companyConditionResponseId = existingResponse[0].id
} else {
// 새로 생성
- await tx
+ const [newResponse] = await tx
.insert(companyConditionResponses)
.values({
biddingCompanyId,
...responseData,
})
+ .returning({ id: companyConditionResponses.id })
+
+ companyConditionResponseId = newResponse.id
+ }
+
+ // 3. 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우)
+ if (response.priceAdjustmentResponse && response.priceAdjustmentForm) {
+ const priceAdjustmentData = {
+ companyConditionResponsesId: companyConditionResponseId,
+ itemName: response.priceAdjustmentForm.itemName,
+ adjustmentReflectionPoint: response.priceAdjustmentForm.adjustmentReflectionPoint,
+ majorApplicableRawMaterial: response.priceAdjustmentForm.majorApplicableRawMaterial,
+ adjustmentFormula: response.priceAdjustmentForm.adjustmentFormula,
+ rawMaterialPriceIndex: response.priceAdjustmentForm.rawMaterialPriceIndex,
+ referenceDate: response.priceAdjustmentForm.referenceDate ? new Date(response.priceAdjustmentForm.referenceDate) : null,
+ comparisonDate: response.priceAdjustmentForm.comparisonDate ? new Date(response.priceAdjustmentForm.comparisonDate) : null,
+ adjustmentRatio: response.priceAdjustmentForm.adjustmentRatio,
+ notes: response.priceAdjustmentForm.notes,
+ adjustmentConditions: response.priceAdjustmentForm.adjustmentConditions,
+ majorNonApplicableRawMaterial: response.priceAdjustmentForm.majorNonApplicableRawMaterial,
+ adjustmentPeriod: response.priceAdjustmentForm.adjustmentPeriod,
+ contractorWriter: response.priceAdjustmentForm.contractorWriter,
+ adjustmentDate: response.priceAdjustmentForm.adjustmentDate ? new Date(response.priceAdjustmentForm.adjustmentDate) : null,
+ nonApplicableReason: response.priceAdjustmentForm.nonApplicableReason,
+ }
+
+ // 기존 연동제 정보가 있는지 확인
+ const existingPriceAdjustment = await tx
+ .select()
+ .from(priceAdjustmentForms)
+ .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
+ .limit(1)
+
+ if (existingPriceAdjustment.length > 0) {
+ // 업데이트
+ await tx
+ .update(priceAdjustmentForms)
+ .set(priceAdjustmentData)
+ .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
+ } else {
+ // 새로 생성
+ await tx.insert(priceAdjustmentForms).values(priceAdjustmentData)
+ }
}
// 2. biddingCompanies 테이블에 견적 금액과 상태 업데이트
@@ -940,6 +1010,26 @@ export async function submitPartnerResponse(
// 사양설명회 정보 조회 (협력업체용)
export async function getSpecificationMeetingForPartners(biddingId: number) {
try {
+ // specification_meetings 테이블에서 사양설명회 정보 조회
+ const specMeeting = await db
+ .select({
+ id: specificationMeetings.id,
+ meetingDate: specificationMeetings.meetingDate,
+ meetingTime: specificationMeetings.meetingTime,
+ location: specificationMeetings.location,
+ address: specificationMeetings.address,
+ contactPerson: specificationMeetings.contactPerson,
+ contactPhone: specificationMeetings.contactPhone,
+ contactEmail: specificationMeetings.contactEmail,
+ agenda: specificationMeetings.agenda,
+ materials: specificationMeetings.materials,
+ notes: specificationMeetings.notes,
+ isRequired: specificationMeetings.isRequired,
+ })
+ .from(specificationMeetings)
+ .where(eq(specificationMeetings.biddingId, biddingId))
+ .limit(1)
+
// bidding_documents에서 사양설명회 관련 문서 조회
const documents = await db
.select({
@@ -956,17 +1046,12 @@ export async function getSpecificationMeetingForPartners(biddingId: number) {
eq(biddingDocuments.documentType, 'specification_meeting')
))
- // biddings 테이블에서 사양설명회 기본 정보 조회
+ // 기본 입찰 정보도 가져오기 (제목, 입찰번호 등)
const bidding = await db
.select({
id: biddings.id,
title: biddings.title,
biddingNumber: biddings.biddingNumber,
- preQuoteDate: biddings.preQuoteDate,
- biddingRegistrationDate: biddings.biddingRegistrationDate,
- managerName: biddings.managerName,
- managerEmail: biddings.managerEmail,
- managerPhone: biddings.managerPhone,
})
.from(biddings)
.where(eq(biddings.id, biddingId))
@@ -976,15 +1061,44 @@ export async function getSpecificationMeetingForPartners(biddingId: number) {
return { success: false, error: '입찰 정보를 찾을 수 없습니다.' }
}
+ // 사양설명회 정보가 없는 경우
+ if (specMeeting.length === 0) {
+ return {
+ success: true,
+ data: {
+ ...bidding[0],
+ documents,
+ meetingDate: null,
+ meetingTime: null,
+ location: null,
+ address: null,
+ contactPerson: null,
+ contactPhone: null,
+ contactEmail: null,
+ agenda: null,
+ materials: null,
+ notes: null,
+ isRequired: false,
+ }
+ }
+ }
+
return {
success: true,
data: {
...bidding[0],
documents,
- meetingDate: bidding[0].preQuoteDate ? bidding[0].preQuoteDate.toISOString().split('T')[0] : null,
- contactPerson: bidding[0].managerName,
- contactEmail: bidding[0].managerEmail,
- contactPhone: bidding[0].managerPhone,
+ meetingDate: specMeeting[0].meetingDate ? specMeeting[0].meetingDate.toISOString().split('T')[0] : null,
+ meetingTime: specMeeting[0].meetingTime,
+ location: specMeeting[0].location,
+ address: specMeeting[0].address,
+ contactPerson: specMeeting[0].contactPerson,
+ contactPhone: specMeeting[0].contactPhone,
+ contactEmail: specMeeting[0].contactEmail,
+ agenda: specMeeting[0].agenda,
+ materials: specMeeting[0].materials,
+ notes: specMeeting[0].notes,
+ isRequired: specMeeting[0].isRequired,
}
}
} catch (error) {
@@ -1094,3 +1208,39 @@ export async function updatePartnerAttendance(
return { success: false, error: '참석 여부 업데이트에 실패했습니다.' }
}
}
+
+// 연동제 정보 조회
+export async function getPriceAdjustmentForm(companyConditionResponseId: number) {
+ try {
+ const priceAdjustment = await db
+ .select()
+ .from(priceAdjustmentForms)
+ .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
+ .limit(1)
+
+ return priceAdjustment[0] || null
+ } catch (error) {
+ console.error('Failed to get price adjustment form:', error)
+ return null
+ }
+}
+
+// 입찰업체 ID로 연동제 정보 조회
+export async function getPriceAdjustmentFormByBiddingCompanyId(biddingCompanyId: number) {
+ try {
+ const result = await db
+ .select({
+ priceAdjustmentForm: priceAdjustmentForms,
+ companyConditionResponse: companyConditionResponses,
+ })
+ .from(companyConditionResponses)
+ .leftJoin(priceAdjustmentForms, eq(companyConditionResponses.id, priceAdjustmentForms.companyConditionResponsesId))
+ .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId))
+ .limit(1)
+
+ return result[0]?.priceAdjustmentForm || null
+ } catch (error) {
+ console.error('Failed to get price adjustment form by bidding company id:', error)
+ return null
+ }
+}
diff --git a/lib/bidding/detail/table/bidding-detail-content.tsx b/lib/bidding/detail/table/bidding-detail-content.tsx
index 090e7218..50f0941e 100644
--- a/lib/bidding/detail/table/bidding-detail-content.tsx
+++ b/lib/bidding/detail/table/bidding-detail-content.tsx
@@ -3,7 +3,7 @@
import * as React from 'react'
import { Bidding } from '@/db/schema'
import { QuotationDetails, QuotationVendor } from '@/lib/bidding/detail/service'
-import { BiddingDetailHeader } from './bidding-detail-header'
+
import { BiddingDetailVendorTableContent } from './bidding-detail-vendor-table'
import { BiddingDetailItemsDialog } from './bidding-detail-items-dialog'
import { BiddingDetailTargetPriceDialog } from './bidding-detail-target-price-dialog'
@@ -45,27 +45,20 @@ export function BiddingDetailContent({
}, [])
return (
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="p-6">
- <BiddingDetailHeader bidding={bidding} />
-
- <div className="mt-6">
- <BiddingDetailVendorTableContent
- biddingId={bidding.id}
- vendors={quotationVendors}
- biddingCompanies={biddingCompanies}
- onRefresh={handleRefresh}
- onOpenItemsDialog={() => openDialog('items')}
- onOpenTargetPriceDialog={() => openDialog('targetPrice')}
- onOpenSelectionReasonDialog={() => openDialog('selectionReason')}
- onEdit={undefined}
- onDelete={undefined}
- onSelectWinner={undefined}
- />
- </div>
- </div>
- </section>
+ <div className="space-y-6">
+ <BiddingDetailVendorTableContent
+ biddingId={bidding.id}
+ bidding={bidding}
+ vendors={quotationVendors}
+ biddingCompanies={biddingCompanies}
+ onRefresh={handleRefresh}
+ onOpenItemsDialog={() => openDialog('items')}
+ onOpenTargetPriceDialog={() => openDialog('targetPrice')}
+ onOpenSelectionReasonDialog={() => openDialog('selectionReason')}
+ onEdit={undefined}
+ onDelete={undefined}
+ onSelectWinner={undefined}
+ />
<BiddingDetailItemsDialog
open={dialogStates.items}
diff --git a/lib/bidding/detail/table/bidding-detail-header.tsx b/lib/bidding/detail/table/bidding-detail-header.tsx
index 3135f37d..fcbbeb9a 100644
--- a/lib/bidding/detail/table/bidding-detail-header.tsx
+++ b/lib/bidding/detail/table/bidding-detail-header.tsx
@@ -145,17 +145,7 @@ export function BiddingDetailHeader({ bidding }: BiddingDetailHeaderProps) {
const buttons = []
// 기본 액션 버튼들 (항상 표시)
- buttons.push(
- <Button
- key="back"
- variant="outline"
- onClick={handleGoBack}
- disabled={isPending}
- >
- <ArrowLeft className="w-4 h-4 mr-2" />
- 목록으로
- </Button>
- )
+
// 모든 액션 버튼을 항상 표시 (상태 검증은 각 핸들러에서)
buttons.push(
@@ -228,74 +218,9 @@ export function BiddingDetailHeader({ bidding }: BiddingDetailHeaderProps) {
</div>
{/* 세부 정보 영역 */}
- <div className="flex flex-wrap items-center gap-6 text-sm">
- {/* 프로젝트 정보 */}
- {bidding.projectName && (
- <div className="flex items-center gap-1.5 text-muted-foreground">
- <Building2 className="w-4 h-4" />
- <span className="font-medium">프로젝트:</span>
- <span>{bidding.projectName}</span>
- </div>
- )}
-
- {/* 품목 정보 */}
- {bidding.itemName && (
- <div className="flex items-center gap-1.5 text-muted-foreground">
- <Package className="w-4 h-4" />
- <span className="font-medium">품목:</span>
- <span>{bidding.itemName}</span>
- </div>
- )}
-
- {/* 담당자 정보 */}
- {bidding.managerName && (
- <div className="flex items-center gap-1.5 text-muted-foreground">
- <User className="w-4 h-4" />
- <span className="font-medium">담당자:</span>
- <span>{bidding.managerName}</span>
- </div>
- )}
-
- {/* 계약구분 */}
- <div className="flex items-center gap-1.5 text-muted-foreground">
- <span className="font-medium">계약:</span>
- <span>{contractTypeLabels[bidding.contractType]}</span>
- </div>
-
- {/* 입찰유형 */}
- <div className="flex items-center gap-1.5 text-muted-foreground">
- <span className="font-medium">유형:</span>
- <span>{biddingTypeLabels[bidding.biddingType]}</span>
- </div>
-
- {/* 낙찰수 */}
- <div className="flex items-center gap-1.5 text-muted-foreground">
- <span className="font-medium">낙찰:</span>
- <span>{bidding.awardCount === 'single' ? '단수' : '복수'}</span>
- </div>
-
- {/* 통화 */}
- <div className="flex items-center gap-1.5 text-muted-foreground">
- <DollarSign className="w-4 h-4" />
- <span className="font-mono">{bidding.currency}</span>
- </div>
-
- {/* 예산 정보 */}
- {bidding.budget && (
- <div className="flex items-center gap-1.5">
- <span className="font-medium text-muted-foreground">예산:</span>
- <span className="font-semibold">
- {new Intl.NumberFormat('ko-KR', {
- style: 'currency',
- currency: bidding.currency || 'KRW',
- }).format(Number(bidding.budget))}
- </span>
- </div>
- )}
- </div>
{/* 일정 정보 */}
- {(bidding.submissionStartDate || bidding.evaluationDate || bidding.preQuoteDate || bidding.biddingRegistrationDate) && (
+ {/* {(bidding.submissionStartDate || bidding.evaluationDate || bidding.preQuoteDate || bidding.biddingRegistrationDate) && (
<div className="flex flex-wrap items-center gap-4 mt-3 pt-3 border-t border-border/50">
<Calendar className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
@@ -321,7 +246,7 @@ export function BiddingDetailHeader({ bidding }: BiddingDetailHeaderProps) {
)}
</div>
</div>
- )}
+ )} */}
</div>
</div>
)
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
index 9e06d5d1..6f02497f 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
@@ -22,12 +22,14 @@ interface GetVendorColumnsProps {
onEdit: (vendor: QuotationVendor) => void
onDelete: (vendor: QuotationVendor) => void
onSelectWinner: (vendor: QuotationVendor) => void
+ onViewPriceAdjustment?: (vendor: QuotationVendor) => void
}
export function getBiddingDetailVendorColumns({
onEdit,
onDelete,
- onSelectWinner
+ onSelectWinner,
+ onViewPriceAdjustment
}: GetVendorColumnsProps): ColumnDef<QuotationVendor>[] {
return [
{
@@ -139,13 +141,46 @@ export function getBiddingDetailVendorColumns({
{row.original.incotermsResponse || '-'}
</div>
),
+ },
+ {
+ accessorKey: 'isInitialResponse',
+ header: '초도여부',
+ cell: ({ row }) => (
+ <Badge variant={row.original.isInitialResponse ? 'default' : 'secondary'}>
+ {row.original.isInitialResponse ? 'Y' : 'N'}
+ </Badge>
+ ),
+ },
+ {
+ accessorKey: 'priceAdjustmentResponse',
+ header: '연동제',
+ cell: ({ row }) => {
+ const hasPriceAdjustment = row.original.priceAdjustmentResponse
+ return (
+ <div className="flex items-center gap-2">
+ <Badge variant={hasPriceAdjustment ? 'default' : 'secondary'}>
+ {hasPriceAdjustment ? '적용' : '미적용'}
+ </Badge>
+ {hasPriceAdjustment && onViewPriceAdjustment && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => onViewPriceAdjustment(row.original)}
+ className="h-6 px-2 text-xs"
+ >
+ 상세
+ </Button>
+ )}
+ </div>
+ )
+ },
},
{
accessorKey: 'proposedContractDeliveryDate',
header: '제안납기일',
cell: ({ row }) => (
<div className="text-sm">
- {row.original.proposedContractDeliveryDate ?
+ {row.original.proposedContractDeliveryDate ?
new Date(row.original.proposedContractDeliveryDate).toLocaleDateString('ko-KR') : '-'}
</div>
),
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
index 7ad7056c..b1f0b08e 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
@@ -9,7 +9,9 @@ import { BiddingDetailVendorToolbarActions } from './bidding-detail-vendor-toolb
import { BiddingDetailVendorCreateDialog } from './bidding-detail-vendor-create-dialog'
import { BiddingDetailVendorEditDialog } from './bidding-detail-vendor-edit-dialog'
import { getBiddingDetailVendorColumns } from './bidding-detail-vendor-columns'
-import { QuotationVendor } from '@/lib/bidding/detail/service'
+import { QuotationVendor, getPriceAdjustmentFormByBiddingCompanyId } from '@/lib/bidding/detail/service'
+import { Bidding } from '@/db/schema'
+import { PriceAdjustmentDialog } from '@/components/bidding/price-adjustment-dialog'
import {
deleteQuotationVendor,
selectWinner
@@ -20,6 +22,7 @@ import { useTransition } from 'react'
interface BiddingDetailVendorTableContentProps {
biddingId: number
+ bidding: Bidding
vendors: QuotationVendor[]
onRefresh: () => void
onOpenItemsDialog: () => void
@@ -83,6 +86,7 @@ const advancedFilterFields: DataTableAdvancedFilterField<QuotationVendor>[] = [
export function BiddingDetailVendorTableContent({
biddingId,
+ bidding,
vendors,
onRefresh,
onOpenItemsDialog,
@@ -96,6 +100,8 @@ export function BiddingDetailVendorTableContent({
const [isPending, startTransition] = useTransition()
const [selectedVendor, setSelectedVendor] = React.useState<QuotationVendor | null>(null)
const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
+ const [priceAdjustmentData, setPriceAdjustmentData] = React.useState<any>(null)
+ const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false)
const handleDelete = (vendor: QuotationVendor) => {
if (!confirm(`${vendor.vendorName} 업체를 삭제하시겠습니까?`)) return
@@ -170,13 +176,38 @@ export function BiddingDetailVendorTableContent({
setIsEditDialogOpen(true)
}
+ 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 columns = React.useMemo(
() => getBiddingDetailVendorColumns({
onEdit: onEdit || handleEdit,
onDelete: onDelete || handleDelete,
- onSelectWinner: onSelectWinner || handleSelectWinner
+ onSelectWinner: onSelectWinner || handleSelectWinner,
+ onViewPriceAdjustment: handleViewPriceAdjustment
}),
- [onEdit, onDelete, onSelectWinner, handleEdit, handleDelete, handleSelectWinner]
+ [onEdit, onDelete, onSelectWinner, handleEdit, handleDelete, handleSelectWinner, handleViewPriceAdjustment]
)
const { table } = useDataTable({
@@ -205,10 +236,10 @@ export function BiddingDetailVendorTableContent({
<BiddingDetailVendorToolbarActions
table={table}
biddingId={biddingId}
+ bidding={bidding}
onOpenItemsDialog={onOpenItemsDialog}
onOpenTargetPriceDialog={onOpenTargetPriceDialog}
onOpenSelectionReasonDialog={onOpenSelectionReasonDialog}
-
onSuccess={onRefresh}
/>
</DataTableAdvancedToolbar>
@@ -220,6 +251,13 @@ export function BiddingDetailVendorTableContent({
onOpenChange={setIsEditDialogOpen}
onSuccess={onRefresh}
/>
+
+ <PriceAdjustmentDialog
+ open={isPriceAdjustmentDialogOpen}
+ onOpenChange={setIsPriceAdjustmentDialogOpen}
+ data={priceAdjustmentData}
+ vendorName={selectedVendor?.vendorName || ''}
+ />
</>
)
}
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 00daa005..ca9ffc60 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
@@ -2,38 +2,184 @@
import * as React from "react"
import { type Table } from "@tanstack/react-table"
+import { useRouter } from "next/navigation"
+import { useTransition } from "react"
import { Button } from "@/components/ui/button"
-import { Plus } from "lucide-react"
-import { QuotationVendor } from "@/lib/bidding/detail/service"
+import { Plus, Send, RotateCcw, XCircle } from "lucide-react"
+import { QuotationVendor, registerBidding, markAsDisposal, createRebidding } from "@/lib/bidding/detail/service"
import { BiddingDetailVendorCreateDialog } from "./bidding-detail-vendor-create-dialog"
+import { Bidding } from "@/db/schema"
+import { useToast } from "@/hooks/use-toast"
interface BiddingDetailVendorToolbarActionsProps {
table: Table<QuotationVendor>
biddingId: number
+ bidding: Bidding
onOpenItemsDialog: () => void
onOpenTargetPriceDialog: () => void
onOpenSelectionReasonDialog: () => void
-
onSuccess: () => void
}
export function BiddingDetailVendorToolbarActions({
table,
biddingId,
+ bidding,
onOpenItemsDialog,
onOpenTargetPriceDialog,
onOpenSelectionReasonDialog,
onSuccess
}: BiddingDetailVendorToolbarActionsProps) {
+ const router = useRouter()
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false)
const handleCreateVendor = () => {
setIsCreateDialogOpen(true)
}
+ const handleRegister = () => {
+ // 상태 검증
+ if (bidding.status !== 'bidding_generated') {
+ toast({
+ title: '실행 불가',
+ description: '입찰 등록은 입찰 생성 상태에서만 가능합니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ if (!confirm('입찰을 등록하시겠습니까?')) return
+
+ startTransition(async () => {
+ const result = await registerBidding(bidding.id, 'current-user') // TODO: 실제 사용자 ID
+
+ if (result.success) {
+ toast({
+ title: '성공',
+ description: result.message,
+ })
+ router.refresh()
+ } else {
+ toast({
+ title: '오류',
+ description: result.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ const handleMarkAsDisposal = () => {
+ // 상태 검증
+ if (bidding.status !== 'bidding_closed') {
+ toast({
+ title: '실행 불가',
+ description: '유찰 처리는 입찰 마감 상태에서만 가능합니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ if (!confirm('입찰을 유찰 처리하시겠습니까?')) return
+
+ startTransition(async () => {
+ const result = await markAsDisposal(bidding.id, 'current-user') // TODO: 실제 사용자 ID
+
+ if (result.success) {
+ toast({
+ title: '성공',
+ description: result.message,
+ })
+ router.refresh()
+ } else {
+ toast({
+ title: '오류',
+ description: result.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ const handleCreateRebidding = () => {
+ // 상태 검증
+ if (bidding.status !== 'bidding_disposal') {
+ toast({
+ title: '실행 불가',
+ description: '재입찰은 유찰 상태에서만 가능합니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ if (!confirm('재입찰을 생성하시겠습니까?')) return
+
+ startTransition(async () => {
+ const result = await createRebidding(bidding.id, 'current-user') // TODO: 실제 사용자 ID
+
+ if (result.success) {
+ toast({
+ title: '성공',
+ description: result.message,
+ })
+ if (result.data?.redirectTo) {
+ router.push(result.data.redirectTo)
+ } else {
+ router.refresh()
+ }
+ } else {
+ toast({
+ title: '오류',
+ description: result.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
return (
<>
<div className="flex items-center gap-2">
+ {/* 상태별 액션 버튼 */}
+ {/* {bidding.status === 'bidding_generated' && (
+ <Button
+ variant="default"
+ size="sm"
+ onClick={handleRegister}
+ disabled={isPending}
+ >
+ <Send className="mr-2 h-4 w-4" />
+ 입찰 등록
+ </Button>
+ )}
+
+ {bidding.status === 'bidding_closed' && (
+ <Button
+ variant="destructive"
+ size="sm"
+ onClick={handleMarkAsDisposal}
+ disabled={isPending}
+ >
+ <XCircle className="mr-2 h-4 w-4" />
+ 유찰 처리
+ </Button>
+ )}
+
+ {bidding.status === 'bidding_disposal' && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleCreateRebidding}
+ disabled={isPending}
+ >
+ <RotateCcw className="mr-2 h-4 w-4" />
+ 재입찰 생성
+ </Button>
+ )} */}
+
+ {/* 기존 버튼들 */}
<Button
variant="outline"
size="sm"
diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx
index c936de33..48a77954 100644
--- a/lib/bidding/list/biddings-table-columns.tsx
+++ b/lib/bidding/list/biddings-table-columns.tsx
@@ -558,7 +558,7 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
<Edit className="mr-2 h-4 w-4" />
수정
</DropdownMenuItem>
- <DropdownMenuSeparator />
+ {/* <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setRowAction({ row, type: "copy" })}>
<Package className="mr-2 h-4 w-4" />
복사 생성
@@ -566,7 +566,7 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
<DropdownMenuItem onClick={() => setRowAction({ row, type: "manage_companies" })}>
<Users className="mr-2 h-4 w-4" />
참여업체 관리
- </DropdownMenuItem>
+ </DropdownMenuItem> */}
</DropdownMenuContent>
</DropdownMenu>
),
diff --git a/lib/bidding/list/create-bidding-dialog.tsx b/lib/bidding/list/create-bidding-dialog.tsx
index 90204dc9..e5bfcae4 100644
--- a/lib/bidding/list/create-bidding-dialog.tsx
+++ b/lib/bidding/list/create-bidding-dialog.tsx
@@ -120,7 +120,7 @@ interface PRItemInfo {
}
// 탭 순서 정의
-const TAB_ORDER = ["basic", "contract", "schedule", "details", "manager"] as const
+const TAB_ORDER = ["basic", "contract", "schedule", "conditions", "details", "manager"] as const
type TabType = typeof TAB_ORDER[number]
export function CreateBiddingDialog() {
@@ -154,6 +154,18 @@ export function CreateBiddingDialog() {
// 파일 첨부를 위해 선택된 아이템 ID
const [selectedItemForFile, setSelectedItemForFile] = React.useState<string | null>(null)
+ // 입찰 조건 상태
+ const [biddingConditions, setBiddingConditions] = React.useState({
+ paymentTerms: "",
+ taxConditions: "",
+ incoterms: "",
+ contractDeliveryDate: "",
+ shippingPort: "",
+ destinationPort: "",
+ isPriceAdjustmentApplicable: false,
+ sparePartOptions: "",
+ })
+
// 사양설명회 파일 추가
const addMeetingFiles = (files: File[]) => {
setSpecMeetingInfo(prev => ({
@@ -257,6 +269,12 @@ export function CreateBiddingDialog() {
(specMeetingInfo.meetingDate && specMeetingInfo.location && specMeetingInfo.contactPerson)),
hasErrors: !!(formErrors.submissionStartDate || formErrors.submissionEndDate)
},
+ conditions: {
+ isValid: biddingConditions.paymentTerms.trim() !== "" &&
+ biddingConditions.taxConditions.trim() !== "" &&
+ biddingConditions.incoterms.trim() !== "",
+ hasErrors: false
+ },
details: {
isValid: true, // 세부내역은 선택사항
hasErrors: false
@@ -405,6 +423,8 @@ export function CreateBiddingDialog() {
} else {
toast.error("제출 시작일시와 마감일시를 입력해주세요")
}
+ } else if (activeTab === "conditions") {
+ toast.error("입찰 조건을 모두 입력해주세요 (지급조건, 세금조건, 운송조건)")
}
return
}
@@ -444,6 +464,7 @@ export function CreateBiddingDialog() {
meetingFiles: specMeetingInfo.meetingFiles
} : null,
prItems: prItems.length > 0 ? prItems : [],
+ biddingConditions: biddingConditions,
}
const result = await createBidding(extendedData, userId)
@@ -517,6 +538,16 @@ export function CreateBiddingDialog() {
})
setPrItems([])
setSelectedItemForFile(null)
+ setBiddingConditions({
+ paymentTerms: "",
+ taxConditions: "",
+ incoterms: "",
+ contractDeliveryDate: "",
+ shippingPort: "",
+ destinationPort: "",
+ isPriceAdjustmentApplicable: false,
+ sparePartOptions: "",
+ })
setActiveTab("basic")
setShowSuccessDialog(false) // 추가
setCreatedBiddingId(null) // 추가
@@ -545,7 +576,7 @@ export function CreateBiddingDialog() {
// 성공 다이얼로그 핸들러들
const handleNavigateToDetail = () => {
if (createdBiddingId) {
- router.push(`/evcp/biddings/${createdBiddingId}`)
+ router.push(`/evcp/bid/${createdBiddingId}`)
}
setShowSuccessDialog(false)
setCreatedBiddingId(null)
@@ -566,7 +597,7 @@ export function CreateBiddingDialog() {
신규 입찰
</Button>
</DialogTrigger>
- <DialogContent className="max-w-6xl h-[90vh] p-0 flex flex-col">
+ <DialogContent className="max-w-7xl h-[90vh] p-0 flex flex-col">
{/* 고정 헤더 */}
<div className="flex-shrink-0 p-6 border-b">
<DialogHeader>
@@ -586,29 +617,87 @@ export function CreateBiddingDialog() {
{/* 탭 영역 */}
<div className="flex-1 overflow-hidden">
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
- <div className="px-6 pt-4">
- <TabsList className="grid w-full grid-cols-5">
- <TabsTrigger value="basic" className="relative">
- 기본 정보
+ <div className="px-6">
+ <div className="flex space-x-1 bg-muted p-1 rounded-lg overflow-x-auto">
+ <button
+ type="button"
+ onClick={() => setActiveTab("basic")}
+ className={`relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0 ${
+ activeTab === "basic"
+ ? "bg-background text-foreground shadow-sm"
+ : "text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ 기본정보
{!tabValidation.basic.isValid && (
<span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
)}
- </TabsTrigger>
- <TabsTrigger value="contract" className="relative">
- 계약 정보
+ </button>
+ <button
+ type="button"
+ onClick={() => setActiveTab("contract")}
+ className={`relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0 ${
+ activeTab === "contract"
+ ? "bg-background text-foreground shadow-sm"
+ : "text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ 계약정보
{!tabValidation.contract.isValid && (
<span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
)}
- </TabsTrigger>
- <TabsTrigger value="schedule" className="relative">
- 일정 & 회의
+ </button>
+ <button
+ type="button"
+ onClick={() => setActiveTab("schedule")}
+ className={`relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0 ${
+ activeTab === "schedule"
+ ? "bg-background text-foreground shadow-sm"
+ : "text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ 일정회의
{!tabValidation.schedule.isValid && (
<span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
)}
- </TabsTrigger>
- <TabsTrigger value="details">세부내역</TabsTrigger>
- <TabsTrigger value="manager">담당자 & 기타</TabsTrigger>
- </TabsList>
+ </button>
+ <button
+ type="button"
+ onClick={() => setActiveTab("conditions")}
+ className={`relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0 ${
+ activeTab === "conditions"
+ ? "bg-background text-foreground shadow-sm"
+ : "text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ 입찰조건
+ {!tabValidation.conditions.isValid && (
+ <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
+ )}
+ </button>
+ <button
+ type="button"
+ onClick={() => setActiveTab("details")}
+ className={`relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0 ${
+ activeTab === "details"
+ ? "bg-background text-foreground shadow-sm"
+ : "text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ 세부내역
+ </button>
+ <button
+ type="button"
+ onClick={() => setActiveTab("manager")}
+ className={`relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0 ${
+ activeTab === "manager"
+ ? "bg-background text-foreground shadow-sm"
+ : "text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ 담당자
+ </button>
+ </div>
</div>
<div className="flex-1 overflow-y-auto p-6">
@@ -1193,6 +1282,128 @@ export function CreateBiddingDialog() {
</Card>
</TabsContent>
+ {/* 입찰 조건 탭 */}
+ <TabsContent value="conditions" className="mt-0 space-y-6">
+ <Card>
+ <CardHeader>
+ <CardTitle>입찰 조건</CardTitle>
+ <p className="text-sm text-muted-foreground">
+ 벤더가 사전견적 시 참고할 입찰 조건을 설정하세요
+ </p>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div className="grid grid-cols-2 gap-6">
+ <div className="space-y-2">
+ <label className="text-sm font-medium">
+ 지급조건 <span className="text-red-500">*</span>
+ </label>
+ <Input
+ placeholder="예: 월말결제, 60일"
+ value={biddingConditions.paymentTerms}
+ onChange={(e) => setBiddingConditions(prev => ({
+ ...prev,
+ paymentTerms: e.target.value
+ }))}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <label className="text-sm font-medium">
+ 세금조건 <span className="text-red-500">*</span>
+ </label>
+ <Input
+
+ value={biddingConditions.taxConditions}
+ onChange={(e) => setBiddingConditions(prev => ({
+ ...prev,
+ taxConditions: e.target.value
+ }))}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <label className="text-sm font-medium">
+ 운송조건(인코텀즈) <span className="text-red-500">*</span>
+ </label>
+ <Input
+ placeholder="예: FOB, CIF 등"
+ value={biddingConditions.incoterms}
+ onChange={(e) => setBiddingConditions(prev => ({
+ ...prev,
+ incoterms: e.target.value
+ }))}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <label className="text-sm font-medium">
+ 계약 납품일
+ </label>
+ <Input
+ type="date"
+ value={biddingConditions.contractDeliveryDate}
+ onChange={(e) => setBiddingConditions(prev => ({
+ ...prev,
+ contractDeliveryDate: e.target.value
+ }))}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <label className="text-sm font-medium">선적지</label>
+ <Input
+ placeholder="예: 부산항, 인천항"
+ value={biddingConditions.shippingPort}
+ onChange={(e) => setBiddingConditions(prev => ({
+ ...prev,
+ shippingPort: e.target.value
+ }))}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <label className="text-sm font-medium">도착지</label>
+ <Input
+ placeholder="예: 현장 직납, 창고 납품"
+ value={biddingConditions.destinationPort}
+ onChange={(e) => setBiddingConditions(prev => ({
+ ...prev,
+ destinationPort: e.target.value
+ }))}
+ />
+ </div>
+ </div>
+
+ <div className="flex items-center space-x-2">
+ <Switch
+ id="price-adjustment"
+ checked={biddingConditions.isPriceAdjustmentApplicable}
+ onCheckedChange={(checked) => setBiddingConditions(prev => ({
+ ...prev,
+ isPriceAdjustmentApplicable: checked
+ }))}
+ />
+ <label htmlFor="price-adjustment" className="text-sm font-medium">
+ 연동제 적용 가능
+ </label>
+ </div>
+
+ <div className="space-y-2">
+ <label className="text-sm font-medium">스페어파트 옵션</label>
+ <Textarea
+ placeholder="스페어파트 관련 옵션을 입력하세요"
+ value={biddingConditions.sparePartOptions}
+ onChange={(e) => setBiddingConditions(prev => ({
+ ...prev,
+ sparePartOptions: e.target.value
+ }))}
+ rows={3}
+ />
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
{/* 세부내역 탭 */}
<TabsContent value="details" className="mt-0 space-y-6">
<Card>
@@ -1657,6 +1868,14 @@ export function CreateBiddingDialog() {
)}
</span>
)}
+ {activeTab === "conditions" && (
+ <span>
+ 입찰 조건을 설정하세요
+ {!tabValidation.conditions.isValid && (
+ <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span>
+ )}
+ </span>
+ )}
{activeTab === "details" && "세부내역 아이템을 관리하세요 (선택사항)"}
{activeTab === "manager" && "담당자 정보를 확인하고 입찰을 생성하세요"}
</div>
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index 5d384476..fc96ddfe 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -9,7 +9,8 @@ import {
biddingDocuments,
prItemsForBidding,
specificationMeetings,
- prDocuments
+ prDocuments,
+ biddingConditions
} from '@/db/schema'
import {
eq,
@@ -356,25 +357,35 @@ export interface CreateBiddingInput extends CreateBiddingSchema {
materials: string
notes: string
isRequired: boolean
+ meetingFiles: File[]
} | null
// PR 아이템들 (선택사항)
- prItemsForBidding?: Array<{
- itemNumber: string
- projectInfo: string
+ prItems?: Array<{
+ id: string
+ prNumber: string
+ itemCode: string
itemInfo: string
- shi: string
- requestedDeliveryDate: string
- annualUnitPrice: string
- currency: string
quantity: string
quantityUnit: string
- totalWeight: string
- weightUnit: string
- materialDescription: string
- prNumber: string
+ requestedDeliveryDate: string
specFiles: File[]
+ isRepresentative: boolean
}>
+
+ // 입찰 조건 (선택사항)
+ biddingConditions?: {
+ paymentTerms: string
+ taxConditions: string
+ incoterms: string
+ proposedDeliveryDate: string
+ proposedShippingPort: string
+ proposedDestinationPort: string
+ priceAdjustmentApplicable: boolean
+ specialConditions: string
+ sparePartRequirement: string
+ additionalNotes: string
+ }
}
export interface UpdateBiddingInput extends UpdateBiddingSchema {
@@ -593,7 +604,27 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
}
}
- // 3. PR 아이템들 저장 (있는 경우)
+ // 3. 입찰 조건 저장 (있는 경우)
+ if (input.biddingConditions) {
+ try {
+ await tx.insert(biddingConditions).values({
+ biddingId,
+ paymentTerms: input.biddingConditions.paymentTerms,
+ taxConditions: input.biddingConditions.taxConditions,
+ incoterms: input.biddingConditions.incoterms,
+ contractDeliveryDate: input.biddingConditions.proposedDeliveryDate ? new Date(input.biddingConditions.proposedDeliveryDate) : null,
+ shippingPort: input.biddingConditions.proposedShippingPort,
+ destinationPort: input.biddingConditions.proposedDestinationPort,
+ isPriceAdjustmentApplicable: input.biddingConditions.priceAdjustmentApplicable,
+ sparePartOptions: input.biddingConditions.sparePartRequirement,
+ })
+ } catch (error) {
+ console.error('Error saving bidding conditions:', error)
+ // 입찰 조건 저장 실패해도 전체 트랜잭션은 계속 진행
+ }
+ }
+
+ // 4. PR 아이템들 저장 (있는 경우)
if (input.prItems && input.prItems.length > 0) {
for (const prItem of input.prItems) {
// PR 아이템 저장
@@ -1190,4 +1221,90 @@ export async function getBiddingBasicInfoAction(
error: "입찰 기본 정보 조회 중 오류가 발생했습니다"
}
}
+}
+
+// 입찰 조건 조회
+export async function getBiddingConditions(biddingId: number) {
+ try {
+ const conditions = await db
+ .select()
+ .from(biddingConditions)
+ .where(eq(biddingConditions.biddingId, biddingId))
+ .limit(1)
+
+ if (conditions.length === 0) {
+ return null
+ }
+
+ return conditions[0]
+ } catch (error) {
+ console.error('Error fetching bidding conditions:', error)
+ return null
+ }
+}
+
+// 입찰 조건 업데이트
+export async function updateBiddingConditions(
+ biddingId: number,
+ updates: {
+ paymentTerms?: string
+ taxConditions?: string
+ incoterms?: string
+ contractDeliveryDate?: string
+ shippingPort?: string
+ destinationPort?: string
+ isPriceAdjustmentApplicable?: boolean
+ sparePartOptions?: string
+ }
+) {
+ try {
+ return await db.transaction(async (tx) => {
+ // 기존 조건 확인
+ const existing = await tx
+ .select()
+ .from(biddingConditions)
+ .where(eq(biddingConditions.biddingId, biddingId))
+ .limit(1)
+
+ const updateData = {
+ paymentTerms: updates.paymentTerms,
+ taxConditions: updates.taxConditions,
+ incoterms: updates.incoterms,
+ contractDeliveryDate: updates.contractDeliveryDate ? new Date(updates.contractDeliveryDate) : null,
+ shippingPort: updates.shippingPort,
+ destinationPort: updates.destinationPort,
+ isPriceAdjustmentApplicable: updates.isPriceAdjustmentApplicable,
+ sparePartOptions: updates.sparePartOptions,
+ updatedAt: new Date(),
+ }
+
+ if (existing.length > 0) {
+ // 업데이트
+ await tx
+ .update(biddingConditions)
+ .set(updateData)
+ .where(eq(biddingConditions.biddingId, biddingId))
+ } else {
+ // 새로 생성
+ await tx.insert(biddingConditions).values({
+ biddingId,
+ ...updateData,
+ })
+ }
+
+ // 캐시 무효화
+ revalidatePath(`/evcp/bid/${biddingId}`)
+
+ return {
+ success: true,
+ message: '입찰 조건이 성공적으로 업데이트되었습니다.'
+ }
+ })
+ } catch (error) {
+ console.error('Error updating bidding conditions:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '입찰 조건 업데이트 중 오류가 발생했습니다.'
+ }
+ }
} \ No newline at end of file
diff --git a/lib/bidding/validation.ts b/lib/bidding/validation.ts
index 5dec3ab3..95cbb02c 100644
--- a/lib/bidding/validation.ts
+++ b/lib/bidding/validation.ts
@@ -101,6 +101,20 @@ export const createBiddingSchema = z.object({
// 메타
remarks: z.string().optional(),
+
+ // 입찰 조건 (선택사항이지만, 설정할 경우 필수 항목들이 있음)
+ biddingConditions: z.object({
+ paymentTerms: z.string().min(1, "지급조건은 필수입니다"),
+ taxConditions: z.string().min(1, "세금조건은 필수입니다"),
+ incoterms: z.string().min(1, "운송조건은 필수입니다"),
+ proposedDeliveryDate: z.string().optional(),
+ proposedShippingPort: z.string().optional(),
+ proposedDestinationPort: z.string().optional(),
+ priceAdjustmentApplicable: z.boolean().default(false),
+ specialConditions: z.string().optional(),
+ sparePartRequirement: z.string().optional(),
+ additionalNotes: z.string().optional(),
+ }).optional(),
}).refine((data) => {
// 제출 기간 검증: 시작일이 마감일보다 이전이어야 함
if (data.submissionStartDate && data.submissionEndDate) {
diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx
index c6ba4926..1e6ae479 100644
--- a/lib/bidding/vendor/partners-bidding-detail.tsx
+++ b/lib/bidding/vendor/partners-bidding-detail.tsx
@@ -104,11 +104,31 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
proposedShippingPort: '',
proposedDestinationPort: '',
priceAdjustmentResponse: false,
+ isInitialResponse: false,
sparePartResponse: '',
additionalProposals: '',
isAttendingMeeting: false,
})
+ // 연동제 폼 상태
+ const [priceAdjustmentForm, setPriceAdjustmentForm] = React.useState({
+ itemName: '',
+ adjustmentReflectionPoint: '',
+ majorApplicableRawMaterial: '',
+ adjustmentFormula: '',
+ rawMaterialPriceIndex: '',
+ referenceDate: '',
+ comparisonDate: '',
+ adjustmentRatio: '',
+ notes: '',
+ adjustmentConditions: '',
+ majorNonApplicableRawMaterial: '',
+ adjustmentPeriod: '',
+ contractorWriter: '',
+ adjustmentDate: '',
+ nonApplicableReason: '',
+ })
+
// 데이터 로드
React.useEffect(() => {
const loadData = async () => {
@@ -128,6 +148,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
proposedShippingPort: result.proposedShippingPort || '',
proposedDestinationPort: result.proposedDestinationPort || '',
priceAdjustmentResponse: result.priceAdjustmentResponse || false,
+ isInitialResponse: result.isInitialResponse || false,
sparePartResponse: result.sparePartResponse || '',
additionalProposals: result.additionalProposals || '',
isAttendingMeeting: result.isAttendingMeeting || false,
@@ -173,8 +194,26 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
proposedShippingPort: responseData.proposedShippingPort,
proposedDestinationPort: responseData.proposedDestinationPort,
priceAdjustmentResponse: responseData.priceAdjustmentResponse,
+ isInitialResponse: responseData.isInitialResponse,
sparePartResponse: responseData.sparePartResponse,
additionalProposals: responseData.additionalProposals,
+ priceAdjustmentForm: responseData.priceAdjustmentResponse ? {
+ itemName: priceAdjustmentForm.itemName,
+ adjustmentReflectionPoint: priceAdjustmentForm.adjustmentReflectionPoint,
+ majorApplicableRawMaterial: priceAdjustmentForm.majorApplicableRawMaterial,
+ adjustmentFormula: priceAdjustmentForm.adjustmentFormula,
+ rawMaterialPriceIndex: priceAdjustmentForm.rawMaterialPriceIndex,
+ referenceDate: priceAdjustmentForm.referenceDate,
+ comparisonDate: priceAdjustmentForm.comparisonDate,
+ adjustmentRatio: priceAdjustmentForm.adjustmentRatio ? parseFloat(priceAdjustmentForm.adjustmentRatio) : undefined,
+ notes: priceAdjustmentForm.notes,
+ adjustmentConditions: priceAdjustmentForm.adjustmentConditions,
+ majorNonApplicableRawMaterial: priceAdjustmentForm.majorNonApplicableRawMaterial,
+ adjustmentPeriod: priceAdjustmentForm.adjustmentPeriod,
+ contractorWriter: priceAdjustmentForm.contractorWriter,
+ adjustmentDate: priceAdjustmentForm.adjustmentDate,
+ nonApplicableReason: priceAdjustmentForm.nonApplicableReason,
+ } : undefined
},
'current-user' // TODO: 실제 사용자 ID
)
@@ -508,17 +547,201 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
/>
</div>
- <div className="flex items-center space-x-2">
- <Checkbox
- id="priceAdjustmentResponse"
- checked={responseData.priceAdjustmentResponse}
- onCheckedChange={(checked) =>
- setResponseData({...responseData, priceAdjustmentResponse: !!checked})
- }
- />
- <Label htmlFor="priceAdjustmentResponse">연동제 적용에 동의합니다</Label>
+ <div className="space-y-4">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="isInitialResponse"
+ checked={responseData.isInitialResponse}
+ onCheckedChange={(checked) =>
+ setResponseData({...responseData, isInitialResponse: !!checked})
+ }
+ />
+ <Label htmlFor="isInitialResponse">초도 공급입니다</Label>
+ </div>
+
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="priceAdjustmentResponse"
+ checked={responseData.priceAdjustmentResponse}
+ onCheckedChange={(checked) =>
+ setResponseData({...responseData, priceAdjustmentResponse: !!checked})
+ }
+ />
+ <Label htmlFor="priceAdjustmentResponse">연동제 적용에 동의합니다</Label>
+ </div>
</div>
+ {/* 연동제 상세 정보 (연동제 적용 시에만 표시) */}
+ {responseData.priceAdjustmentResponse && (
+ <Card className="mt-6">
+ <CardHeader>
+ <CardTitle className="text-lg">하도급대금등 연동표</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="itemName">품목등의 명칭</Label>
+ <Input
+ id="itemName"
+ value={priceAdjustmentForm.itemName}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, itemName: e.target.value})}
+ placeholder="품목명을 입력하세요"
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="adjustmentReflectionPoint">조정대금 반영시점</Label>
+ <Input
+ id="adjustmentReflectionPoint"
+ value={priceAdjustmentForm.adjustmentReflectionPoint}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentReflectionPoint: e.target.value})}
+ placeholder="반영시점을 입력하세요"
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="adjustmentRatio">연동 비율 (%)</Label>
+ <Input
+ id="adjustmentRatio"
+ type="number"
+ step="0.01"
+ value={priceAdjustmentForm.adjustmentRatio}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentRatio: e.target.value})}
+ placeholder="비율을 입력하세요"
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="adjustmentPeriod">조정주기</Label>
+ <Input
+ id="adjustmentPeriod"
+ value={priceAdjustmentForm.adjustmentPeriod}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentPeriod: e.target.value})}
+ placeholder="조정주기를 입력하세요"
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="referenceDate">기준시점</Label>
+ <Input
+ id="referenceDate"
+ type="date"
+ value={priceAdjustmentForm.referenceDate}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, referenceDate: e.target.value})}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="comparisonDate">비교시점</Label>
+ <Input
+ id="comparisonDate"
+ type="date"
+ value={priceAdjustmentForm.comparisonDate}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, comparisonDate: e.target.value})}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="contractorWriter">수탁기업(협력사) 작성자</Label>
+ <Input
+ id="contractorWriter"
+ value={priceAdjustmentForm.contractorWriter}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, contractorWriter: e.target.value})}
+ placeholder="작성자명을 입력하세요"
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="adjustmentDate">조정일</Label>
+ <Input
+ id="adjustmentDate"
+ type="date"
+ value={priceAdjustmentForm.adjustmentDate}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentDate: e.target.value})}
+ />
+ </div>
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="majorApplicableRawMaterial">연동대상 주요 원재료</Label>
+ <Textarea
+ id="majorApplicableRawMaterial"
+ value={priceAdjustmentForm.majorApplicableRawMaterial}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, majorApplicableRawMaterial: e.target.value})}
+ placeholder="연동 대상 원재료를 입력하세요"
+ rows={3}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="adjustmentFormula">하도급대금등 연동 산식</Label>
+ <Textarea
+ id="adjustmentFormula"
+ value={priceAdjustmentForm.adjustmentFormula}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentFormula: e.target.value})}
+ placeholder="연동 산식을 입력하세요"
+ rows={3}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="rawMaterialPriceIndex">원재료 가격 기준지표</Label>
+ <Textarea
+ id="rawMaterialPriceIndex"
+ value={priceAdjustmentForm.rawMaterialPriceIndex}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, rawMaterialPriceIndex: e.target.value})}
+ placeholder="가격 기준지표를 입력하세요"
+ rows={2}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="adjustmentConditions">조정요건</Label>
+ <Textarea
+ id="adjustmentConditions"
+ value={priceAdjustmentForm.adjustmentConditions}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentConditions: e.target.value})}
+ placeholder="조정요건을 입력하세요"
+ rows={2}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="majorNonApplicableRawMaterial">연동 미적용 주요 원재료</Label>
+ <Textarea
+ id="majorNonApplicableRawMaterial"
+ value={priceAdjustmentForm.majorNonApplicableRawMaterial}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, majorNonApplicableRawMaterial: e.target.value})}
+ placeholder="연동 미적용 원재료를 입력하세요"
+ rows={2}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="nonApplicableReason">연동 미적용 사유</Label>
+ <Textarea
+ id="nonApplicableReason"
+ value={priceAdjustmentForm.nonApplicableReason}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, nonApplicableReason: e.target.value})}
+ placeholder="미적용 사유를 입력하세요"
+ rows={2}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="priceAdjustmentNotes">기타 사항</Label>
+ <Textarea
+ id="priceAdjustmentNotes"
+ value={priceAdjustmentForm.notes}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, notes: e.target.value})}
+ placeholder="기타 사항을 입력하세요"
+ rows={2}
+ />
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
<div className="flex justify-end pt-4">
<Button onClick={handleSubmitResponse} disabled={isPending}>
<Send className="w-4 h-4 mr-2" />
diff --git a/lib/tech-vendors/table/import-button.tsx b/lib/tech-vendors/table/import-button.tsx
index b268d29d..85b16bc7 100644
--- a/lib/tech-vendors/table/import-button.tsx
+++ b/lib/tech-vendors/table/import-button.tsx
@@ -213,7 +213,7 @@ export function ImportTechVendorButton({ onSuccess }: ImportTechVendorButtonProp
// 담당자 정보가 없으면 벤더 정보를 기본 담당자로 사용
const representativeName = row["대표자명"] || row["representativeName"];
contacts.push({
- contactName: representativeName || row["업체명"] || row["vendorName"] || "기본 담당자",
+ contactName: contactName || row["vendorName"] || "기본 담당자",
contactPosition: "기본 담당자",
contactEmail: vendorEmail,
contactPhone: row["대표자연락처"] || row["representativePhone"] || row["전화번호"] || row["phone"] || "",