diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-03 10:35:57 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-03 10:35:57 +0000 |
| commit | a2bc455f654e011c53968b0d3a14389d7259847e (patch) | |
| tree | 6ff60b8ef0880aaa4cf2c9d4f234772fb0a74537 | |
| parent | bfe354f7633f62350e61eb784cbf1926079339d1 (diff) | |
(최겸) 구매 입찰 개발(벤더 응찰 개발 및 기본계약 요청 개발 필)
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"] || "",
|
