diff options
Diffstat (limited to 'lib/bidding/detail')
6 files changed, 728 insertions, 32 deletions
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index 17ea8f28..4ef48d33 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -64,6 +64,12 @@ export async function getBiddingDetailData(biddingId: number): Promise<BiddingDe awardRatio: biddingCompanies.awardRatio, isBiddingParticipated: biddingCompanies.isBiddingParticipated, invitationStatus: biddingCompanies.invitationStatus, + // 연동제 관련 필드 + isPriceAdjustmentApplicableQuestion: biddingCompanies.isPriceAdjustmentApplicableQuestion, + priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse, // 벤더가 응답한 연동제 적용 여부 + shiPriceAdjustmentApplied: biddingCompanies.shiPriceAdjustmentApplied, + priceAdjustmentNote: biddingCompanies.priceAdjustmentNote, + hasChemicalSubstance: biddingCompanies.hasChemicalSubstance, // Contact info from biddingCompaniesContacts contactPerson: biddingCompaniesContacts.contactName, contactEmail: biddingCompaniesContacts.contactEmail, @@ -75,6 +81,10 @@ export async function getBiddingDetailData(biddingId: number): Promise<BiddingDe eq(biddingCompaniesContacts.biddingId, biddingId), eq(biddingCompaniesContacts.vendorId, biddingCompanies.companyId) )) + .leftJoin(companyConditionResponses, and( + eq(companyConditionResponses.biddingCompanyId, biddingCompanies.id), + eq(companyConditionResponses.isPreQuote, false) // 본입찰 데이터만 + )) .where(and( eq(biddingCompanies.biddingId, biddingId), eq(biddingCompanies.isBiddingParticipated, true) @@ -102,6 +112,12 @@ export async function getBiddingDetailData(biddingId: number): Promise<BiddingDe awardRatio: curr.awardRatio ? Number(curr.awardRatio) : null, isBiddingParticipated: curr.isBiddingParticipated, invitationStatus: curr.invitationStatus, + // 연동제 관련 필드 + isPriceAdjustmentApplicableQuestion: curr.isPriceAdjustmentApplicableQuestion, + priceAdjustmentResponse: curr.priceAdjustmentResponse, // 벤더가 응답한 연동제 적용 여부 + shiPriceAdjustmentApplied: curr.shiPriceAdjustmentApplied, + priceAdjustmentNote: curr.priceAdjustmentNote, + hasChemicalSubstance: curr.hasChemicalSubstance, documents: [], }) } @@ -148,6 +164,12 @@ export interface QuotationVendor { awardRatio: number | null // 발주비율 isBiddingParticipated: boolean | null // 본입찰 참여여부 invitationStatus: 'pending' | 'pre_quote_sent' | 'pre_quote_accepted' | 'pre_quote_declined' | 'pre_quote_submitted' | 'bidding_sent' | 'bidding_accepted' | 'bidding_declined' | 'bidding_cancelled' | 'bidding_submitted' + // 연동제 관련 필드 + isPriceAdjustmentApplicableQuestion: boolean | null // SHI가 요청한 연동제 요청 여부 + priceAdjustmentResponse: boolean | null // 벤더가 응답한 연동제 적용 여부 (companyConditionResponses.priceAdjustmentResponse) + shiPriceAdjustmentApplied: boolean | null // SHI 연동제 적용여부 + priceAdjustmentNote: string | null // 연동제 Note + hasChemicalSubstance: boolean | null // 화학물질여부 documents: Array<{ id: number fileName: string @@ -818,11 +840,52 @@ export async function registerBidding(biddingId: number, userId: string) { await db.transaction(async (tx) => { debugLog('registerBidding: Transaction started') - // 1. 입찰 상태를 오픈으로 변경 + + // 0. 입찰서 제출기간 계산 (오프셋 기반) + const { submissionStartOffset, submissionDurationDays, submissionStartDate, submissionEndDate } = bidding + + let calculatedStartDate = bidding.submissionStartDate + let calculatedEndDate = bidding.submissionEndDate + + // 오프셋 값이 있으면 날짜 계산 + if (submissionStartOffset !== null && submissionDurationDays !== null) { + // 시간 추출 (기본값: 시작 09:00, 마감 18:00) + const startTime = submissionStartDate + ? { hours: submissionStartDate.getUTCHours(), minutes: submissionStartDate.getUTCMinutes() } + : { hours: 9, minutes: 0 } + const endTime = submissionEndDate + ? { hours: submissionEndDate.getUTCHours(), minutes: submissionEndDate.getUTCMinutes() } + : { hours: 18, minutes: 0 } + + // baseDate = 현재일 날짜만 (00:00:00) + const baseDate = new Date() + baseDate.setHours(0, 0, 0, 0) + + // 시작일 = baseDate + offset일 + 시작시간 + calculatedStartDate = new Date(baseDate) + calculatedStartDate.setDate(calculatedStartDate.getDate() + submissionStartOffset) + calculatedStartDate.setHours(startTime.hours, startTime.minutes, 0, 0) + + // 마감일 = 시작일(날짜만) + duration일 + 마감시간 + calculatedEndDate = new Date(calculatedStartDate) + calculatedEndDate.setHours(0, 0, 0, 0) + calculatedEndDate.setDate(calculatedEndDate.getDate() + submissionDurationDays) + calculatedEndDate.setHours(endTime.hours, endTime.minutes, 0, 0) + + debugLog('registerBidding: Submission dates calculated', { + baseDate: baseDate.toISOString(), + calculatedStartDate: calculatedStartDate.toISOString(), + calculatedEndDate: calculatedEndDate.toISOString(), + }) + } + + // 1. 입찰 상태를 오픈으로 변경 + 제출기간 업데이트 await tx .update(biddings) .set({ status: 'bidding_opened', + submissionStartDate: calculatedStartDate, + submissionEndDate: calculatedEndDate, updatedBy: userName, updatedAt: new Date() }) @@ -2617,3 +2680,35 @@ export async function setSpecificationMeetingParticipation(biddingCompanyId: num return { success: false, error: '사양설명회 참여상태 업데이트에 실패했습니다.' } } } + +// 연동제 정보 업데이트 +export async function updatePriceAdjustmentInfo(params: { + biddingCompanyId: number + shiPriceAdjustmentApplied: boolean | null + priceAdjustmentNote: string | null + hasChemicalSubstance: boolean | null +}): Promise<{ success: boolean; error?: string }> { + try { + const result = await db.update(biddingCompanies) + .set({ + shiPriceAdjustmentApplied: params.shiPriceAdjustmentApplied, + priceAdjustmentNote: params.priceAdjustmentNote, + hasChemicalSubstance: params.hasChemicalSubstance, + updatedAt: new Date(), + }) + .where(eq(biddingCompanies.id, params.biddingCompanyId)) + .returning({ biddingId: biddingCompanies.biddingId }) + + if (result.length > 0) { + const biddingId = result[0].biddingId + revalidateTag(`bidding-${biddingId}`) + revalidateTag('quotation-vendors') + revalidatePath(`/evcp/bid/${biddingId}`) + } + + return { success: true } + } catch (error) { + console.error('Failed to update price adjustment info:', error) + return { success: false, error: '연동제 정보 업데이트에 실패했습니다.' } + } +} diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx index 5368b287..05c1a93d 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx @@ -31,6 +31,7 @@ interface GetVendorColumnsProps { } export function getBiddingDetailVendorColumns({ + onViewPriceAdjustment, onViewItemDetails, onSendBidding, onUpdateParticipation, @@ -239,6 +240,83 @@ export function getBiddingDetailVendorColumns({ ), }, { + accessorKey: 'priceAdjustmentResponse', + header: '연동제 응답', + cell: ({ row }) => { + const vendor = row.original + const response = vendor.priceAdjustmentResponse + + // 버튼 형태로 표시, 클릭 시 상세 다이얼로그 열기 + const getBadgeVariant = () => { + if (response === null || response === undefined) return 'outline' + return response ? 'default' : 'secondary' + } + + const getBadgeClass = () => { + if (response === true) return 'bg-green-600 hover:bg-green-700 cursor-pointer' + if (response === false) return 'hover:bg-gray-300 cursor-pointer' + return '' + } + + const getLabel = () => { + if (response === null || response === undefined) return '해당없음' + return response ? '예' : '아니오' + } + + return ( + <Badge + variant={getBadgeVariant()} + className={getBadgeClass()} + onClick={() => onViewPriceAdjustment?.(vendor)} + > + {getLabel()} + </Badge> + ) + }, + }, + { + accessorKey: 'shiPriceAdjustmentApplied', + header: 'SHI연동제적용', + cell: ({ row }) => { + const applied = row.original.shiPriceAdjustmentApplied + if (applied === null || applied === undefined) { + return <Badge variant="outline">미정</Badge> + } + return ( + <Badge variant={applied ? 'default' : 'secondary'} className={applied ? 'bg-green-600' : ''}> + {applied ? '적용' : '미적용'} + </Badge> + ) + }, + }, + { + accessorKey: 'priceAdjustmentNote', + header: '연동제 Note', + cell: ({ row }) => { + const note = row.original.priceAdjustmentNote + return ( + <div className="text-sm max-w-[150px] truncate" title={note || ''}> + {note || '-'} + </div> + ) + }, + }, + { + accessorKey: 'hasChemicalSubstance', + header: '화학물질', + cell: ({ row }) => { + const hasChemical = row.original.hasChemicalSubstance + if (hasChemical === null || hasChemical === undefined) { + return <Badge variant="outline">미정</Badge> + } + return ( + <Badge variant={hasChemical ? 'destructive' : 'secondary'}> + {hasChemical ? '해당' : '해당없음'} + </Badge> + ) + }, + }, + { id: 'actions', header: '작업', cell: ({ row }) => { diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx index a6f64964..407cc51c 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx @@ -10,9 +10,9 @@ import { BiddingDetailVendorToolbarActions } from './bidding-detail-vendor-toolb import { BiddingDetailVendorEditDialog } from './bidding-detail-vendor-edit-dialog' import { BiddingAwardDialog } from './bidding-award-dialog' import { getBiddingDetailVendorColumns } from './bidding-detail-vendor-columns' -import { QuotationVendor, getPriceAdjustmentFormByBiddingCompanyId } from '@/lib/bidding/detail/service' +import { QuotationVendor } from '@/lib/bidding/detail/service' import { Bidding } from '@/db/schema' -import { PriceAdjustmentDialog } from '@/components/bidding/price-adjustment-dialog' +import { VendorPriceAdjustmentViewDialog } from './vendor-price-adjustment-view-dialog' import { QuotationHistoryDialog } from './quotation-history-dialog' import { ApprovalPreviewDialog } from '@/lib/approval/approval-preview-dialog' import { ApplicationReasonDialog } from '@/lib/rfq-last/vendor/application-reason-dialog' @@ -98,8 +98,7 @@ export function BiddingDetailVendorTableContent({ const [selectedVendor, setSelectedVendor] = React.useState<QuotationVendor | null>(null) const [isAwardDialogOpen, setIsAwardDialogOpen] = React.useState(false) const [isAwardRatioDialogOpen, setIsAwardRatioDialogOpen] = React.useState(false) - const [priceAdjustmentData, setPriceAdjustmentData] = React.useState<any>(null) - const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false) + const [isVendorPriceAdjustmentDialogOpen, setIsVendorPriceAdjustmentDialogOpen] = React.useState(false) const [quotationHistoryData, setQuotationHistoryData] = React.useState<any>(null) const [isQuotationHistoryDialogOpen, setIsQuotationHistoryDialogOpen] = React.useState(false) const [approvalPreviewData, setApprovalPreviewData] = React.useState<{ @@ -116,28 +115,9 @@ export function BiddingDetailVendorTableContent({ } | null>(null) const [isApprovalPreviewDialogOpen, setIsApprovalPreviewDialogOpen] = React.useState(false) - const handleViewPriceAdjustment = async (vendor: QuotationVendor) => { - try { - const priceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(vendor.id) - if (priceAdjustmentForm) { - setPriceAdjustmentData(priceAdjustmentForm) - setSelectedVendor(vendor) - setIsPriceAdjustmentDialogOpen(true) - } else { - toast({ - title: '연동제 정보 없음', - description: '해당 업체의 연동제 정보가 없습니다.', - variant: 'default', - }) - } - } catch (error) { - console.error('Failed to load price adjustment form:', error) - toast({ - title: '오류', - description: '연동제 정보를 불러오는데 실패했습니다.', - variant: 'destructive', - }) - } + const handleViewPriceAdjustment = (vendor: QuotationVendor) => { + setSelectedVendor(vendor) + setIsVendorPriceAdjustmentDialogOpen(true) } const handleViewQuotationHistory = async (vendor: QuotationVendor) => { @@ -299,11 +279,12 @@ export function BiddingDetailVendorTableContent({ }} /> - <PriceAdjustmentDialog - open={isPriceAdjustmentDialogOpen} - onOpenChange={setIsPriceAdjustmentDialogOpen} - data={priceAdjustmentData} + <VendorPriceAdjustmentViewDialog + open={isVendorPriceAdjustmentDialogOpen} + onOpenChange={setIsVendorPriceAdjustmentDialogOpen} vendorName={selectedVendor?.vendorName || ''} + priceAdjustmentResponse={selectedVendor?.priceAdjustmentResponse ?? null} + biddingCompanyId={selectedVendor?.id || 0} /> <QuotationHistoryDialog diff --git a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx index 7e571a23..d3df141a 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx @@ -5,13 +5,14 @@ import { useRouter } from "next/navigation" import { useTransition } from "react" import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" -import { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign, RotateCw } from "lucide-react" +import { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign, RotateCw, Link2 } from "lucide-react" import { registerBidding, markAsDisposal, cancelAwardRatio } from "@/lib/bidding/detail/service" import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from "@/lib/bidding/pre-quote/service" import { increaseRoundOrRebid } from "@/lib/bidding/service" import { BiddingDetailVendorCreateDialog } from "../../../../components/bidding/manage/bidding-detail-vendor-create-dialog" import { BiddingDocumentUploadDialog } from "./bidding-document-upload-dialog" +import { PriceAdjustmentDialog } from "./price-adjustment-dialog" import { Bidding } from "@/db/schema" import { useToast } from "@/hooks/use-toast" import { QuotationVendor } from "@/lib/bidding/detail/service" @@ -49,6 +50,7 @@ export function BiddingDetailVendorToolbarActions({ const [selectedVendors, setSelectedVendors] = React.useState<any[]>([]) const [isRoundIncreaseDialogOpen, setIsRoundIncreaseDialogOpen] = React.useState(false) const [isCancelAwardDialogOpen, setIsCancelAwardDialogOpen] = React.useState(false) + const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false) // 본입찰 초대 다이얼로그가 열릴 때 선정된 업체들 조회 React.useEffect(() => { @@ -196,6 +198,19 @@ export function BiddingDetailVendorToolbarActions({ </Button> )} + {/* 연동제 적용여부: single select 시에만 활성화 */} + {(bidding.status === 'evaluation_of_bidding') && ( + <Button + variant="outline" + size="sm" + onClick={() => setIsPriceAdjustmentDialogOpen(true)} + disabled={!singleSelectedVendor || isPending || singleSelectedVendor.isBiddingParticipated !== true} + > + <Link2 className="mr-2 h-4 w-4" /> + 연동제 적용 + </Button> + )} + {/* 유찰/낙찰: 입찰공고 또는 입찰평가중 상태에서만 */} {(bidding.status === 'bidding_opened' || bidding.status === 'evaluation_of_bidding') && ( <> @@ -331,6 +346,14 @@ export function BiddingDetailVendorToolbarActions({ </DialogContent> </Dialog> + {/* 연동제 적용여부 다이얼로그 */} + <PriceAdjustmentDialog + open={isPriceAdjustmentDialogOpen} + onOpenChange={setIsPriceAdjustmentDialogOpen} + vendor={singleSelectedVendor || null} + onSuccess={onSuccess} + /> + </> ) } diff --git a/lib/bidding/detail/table/price-adjustment-dialog.tsx b/lib/bidding/detail/table/price-adjustment-dialog.tsx new file mode 100644 index 00000000..14bbd843 --- /dev/null +++ b/lib/bidding/detail/table/price-adjustment-dialog.tsx @@ -0,0 +1,195 @@ +"use client" + +import * as React from "react" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { Switch } from "@/components/ui/switch" +import { useToast } from "@/hooks/use-toast" +import { updatePriceAdjustmentInfo } from "@/lib/bidding/detail/service" +import { QuotationVendor } from "@/lib/bidding/detail/service" +import { Loader2 } from "lucide-react" + +interface PriceAdjustmentDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + vendor: QuotationVendor | null + onSuccess: () => void +} + +export function PriceAdjustmentDialog({ + open, + onOpenChange, + vendor, + onSuccess, +}: PriceAdjustmentDialogProps) { + const { toast } = useToast() + const [isSubmitting, setIsSubmitting] = React.useState(false) + + // 폼 상태 + const [shiPriceAdjustmentApplied, setSHIPriceAdjustmentApplied] = React.useState<boolean | null>(null) + const [priceAdjustmentNote, setPriceAdjustmentNote] = React.useState("") + const [hasChemicalSubstance, setHasChemicalSubstance] = React.useState<boolean | null>(null) + + // 다이얼로그가 열릴 때 벤더 정보로 폼 초기화 + React.useEffect(() => { + if (open && vendor) { + setSHIPriceAdjustmentApplied(vendor.shiPriceAdjustmentApplied ?? null) + setPriceAdjustmentNote(vendor.priceAdjustmentNote || "") + setHasChemicalSubstance(vendor.hasChemicalSubstance ?? null) + } + }, [open, vendor]) + + const handleSubmit = async () => { + if (!vendor) return + + setIsSubmitting(true) + try { + const result = await updatePriceAdjustmentInfo({ + biddingCompanyId: vendor.id, + shiPriceAdjustmentApplied, + priceAdjustmentNote: priceAdjustmentNote || null, + hasChemicalSubstance, + }) + + if (result.success) { + toast({ + title: "저장 완료", + description: "연동제 정보가 저장되었습니다.", + }) + onOpenChange(false) + onSuccess() + } else { + toast({ + title: "오류", + description: result.error || "저장 중 오류가 발생했습니다.", + variant: "destructive", + }) + } + } catch (error) { + console.error("연동제 정보 저장 오류:", error) + toast({ + title: "오류", + description: "저장 중 오류가 발생했습니다.", + variant: "destructive", + }) + } finally { + setIsSubmitting(false) + } + } + + if (!vendor) return null + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle>연동제 적용 설정</DialogTitle> + <DialogDescription> + <span className="font-semibold text-primary">{vendor.vendorName}</span> 업체의 연동제 적용 여부 및 화학물질 정보를 설정합니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-6 py-4"> + {/* 업체가 제출한 연동제 요청 여부 (읽기 전용) */} + <div className="flex flex-row items-center justify-between rounded-lg border p-4 bg-muted/50"> + <div className="space-y-0.5"> + <Label className="text-base">업체 연동제 요청</Label> + <p className="text-sm text-muted-foreground"> + 업체가 제출한 연동제 적용 요청 여부입니다. + </p> + </div> + <span className={`font-medium ${vendor.isPriceAdjustmentApplicableQuestion ? 'text-green-600' : 'text-gray-500'}`}> + {vendor.isPriceAdjustmentApplicableQuestion === null ? '미정' : vendor.isPriceAdjustmentApplicableQuestion ? '예' : '아니오'} + </span> + </div> + + {/* SHI 연동제 적용여부 */} + <div className="flex flex-row items-center justify-between rounded-lg border p-4"> + <div className="space-y-0.5"> + <Label className="text-base">SHI 연동제 적용</Label> + <p className="text-sm text-muted-foreground"> + 해당 업체에 연동제를 적용할지 결정합니다. + </p> + </div> + <div className="flex items-center gap-3"> + <span className={`text-sm ${shiPriceAdjustmentApplied === false ? 'font-medium' : 'text-muted-foreground'}`}> + 미적용 + </span> + <Switch + checked={shiPriceAdjustmentApplied === true} + onCheckedChange={(checked) => setSHIPriceAdjustmentApplied(checked)} + /> + <span className={`text-sm ${shiPriceAdjustmentApplied === true ? 'font-medium' : 'text-muted-foreground'}`}> + 적용 + </span> + </div> + </div> + + {/* 연동제 Note */} + <div className="space-y-2"> + <Label htmlFor="price-adjustment-note">연동제 Note</Label> + <Textarea + id="price-adjustment-note" + placeholder="연동제 관련 추가 사항을 입력하세요" + value={priceAdjustmentNote} + onChange={(e) => setPriceAdjustmentNote(e.target.value)} + rows={4} + /> + </div> + + {/* 화학물질 여부 */} + <div className="flex flex-row items-center justify-between rounded-lg border p-4"> + <div className="space-y-0.5"> + <Label className="text-base">화학물질 해당여부</Label> + <p className="text-sm text-muted-foreground"> + 해당 업체가 화학물질 취급 대상인지 여부입니다. + </p> + </div> + <div className="flex items-center gap-3"> + <span className={`text-sm ${hasChemicalSubstance === false ? 'font-medium' : 'text-muted-foreground'}`}> + 해당없음 + </span> + <Switch + checked={hasChemicalSubstance === true} + onCheckedChange={(checked) => setHasChemicalSubstance(checked)} + /> + <span className={`text-sm ${hasChemicalSubstance === true ? 'font-medium text-red-600' : 'text-muted-foreground'}`}> + 해당 + </span> + </div> + </div> + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isSubmitting} + > + 취소 + </Button> + <Button onClick={handleSubmit} disabled={isSubmitting}> + {isSubmitting ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 저장 중... + </> + ) : ( + "저장" + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} + diff --git a/lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx b/lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx new file mode 100644 index 00000000..f31caf5e --- /dev/null +++ b/lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx @@ -0,0 +1,324 @@ +'use client' + +import React from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Badge } from '@/components/ui/badge' +import { Separator } from '@/components/ui/separator' +import { format } from 'date-fns' +import { ko } from 'date-fns/locale' +import { Loader2 } from 'lucide-react' + +interface PriceAdjustmentData { + id: number + itemName?: string | null + adjustmentReflectionPoint?: string | null + majorApplicableRawMaterial?: string | null + adjustmentFormula?: string | null + rawMaterialPriceIndex?: string | null + referenceDate?: Date | string | null + comparisonDate?: Date | string | null + adjustmentRatio?: string | null + notes?: string | null + adjustmentConditions?: string | null + majorNonApplicableRawMaterial?: string | null + adjustmentPeriod?: string | null + contractorWriter?: string | null + adjustmentDate?: Date | string | null + nonApplicableReason?: string | null + createdAt: Date | string + updatedAt: Date | string +} + +interface VendorPriceAdjustmentViewDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + vendorName: string + priceAdjustmentResponse: boolean | null // 벤더가 응답한 연동제 적용 여부 + biddingCompanyId: number +} + +export function VendorPriceAdjustmentViewDialog({ + open, + onOpenChange, + vendorName, + priceAdjustmentResponse, + biddingCompanyId, +}: VendorPriceAdjustmentViewDialogProps) { + const [data, setData] = React.useState<PriceAdjustmentData | null>(null) + const [isLoading, setIsLoading] = React.useState(false) + const [error, setError] = React.useState<string | null>(null) + + // 다이얼로그가 열릴 때 데이터 로드 + React.useEffect(() => { + if (open && biddingCompanyId) { + loadPriceAdjustmentData() + } + }, [open, biddingCompanyId]) + + const loadPriceAdjustmentData = async () => { + setIsLoading(true) + setError(null) + try { + // 서버에서 연동제 폼 데이터 조회 + const { getPriceAdjustmentFormByBiddingCompanyId } = await import('@/lib/bidding/detail/service') + const formData = await getPriceAdjustmentFormByBiddingCompanyId(biddingCompanyId) + setData(formData) + } catch (err) { + console.error('Failed to load price adjustment data:', err) + setError('연동제 정보를 불러오는데 실패했습니다.') + } finally { + setIsLoading(false) + } + } + + // 날짜 포맷팅 헬퍼 + const formatDateValue = (date: Date | string | null | undefined) => { + if (!date) return '-' + try { + const dateObj = typeof date === 'string' ? new Date(date) : date + return format(dateObj, 'yyyy-MM-dd', { locale: ko }) + } catch { + return '-' + } + } + + // 연동제 적용 여부 판단 + const isApplied = priceAdjustmentResponse === true + const isNotApplied = priceAdjustmentResponse === false + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <span>하도급대금등 연동표</span> + <Badge variant="secondary">{vendorName}</Badge> + {isApplied && ( + <Badge variant="default" className="bg-green-600 hover:bg-green-700"> + 연동제 적용 + </Badge> + )} + {isNotApplied && ( + <Badge variant="outline" className="border-red-500 text-red-600"> + 연동제 미적용 + </Badge> + )} + {priceAdjustmentResponse === null && ( + <Badge variant="outline">해당없음</Badge> + )} + </DialogTitle> + <DialogDescription> + 협력업체가 제출한 연동제 적용 정보입니다. + {isApplied && " (연동제 적용)"} + {isNotApplied && " (연동제 미적용)"} + </DialogDescription> + </DialogHeader> + + {isLoading ? ( + <div className="flex items-center justify-center py-12"> + <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> + <span className="ml-2 text-muted-foreground">연동제 정보를 불러오는 중...</span> + </div> + ) : error ? ( + <div className="py-8 text-center text-red-600">{error}</div> + ) : !data && priceAdjustmentResponse !== null ? ( + <div className="py-8 text-center text-muted-foreground">연동제 상세 정보가 없습니다.</div> + ) : priceAdjustmentResponse === null ? ( + <div className="py-8 text-center text-muted-foreground">해당 업체는 연동제 관련 응답을 하지 않았습니다.</div> + ) : ( + <div className="space-y-6"> + {/* 기본 정보 */} + <div> + <h3 className="text-sm font-medium text-gray-900 mb-3">기본 정보</h3> + <div className="grid grid-cols-2 gap-4"> + <div> + <label className="text-xs text-gray-500">물품등의 명칭</label> + <p className="text-sm font-medium">{data?.itemName || '-'}</p> + </div> + <div> + <label className="text-xs text-gray-500">연동제 적용 여부</label> + <div className="mt-1"> + {isApplied && ( + <Badge variant="default" className="bg-green-600 hover:bg-green-700"> + 예 (연동제 적용) + </Badge> + )} + {isNotApplied && ( + <Badge variant="outline" className="border-red-500 text-red-600"> + 아니오 (연동제 미적용) + </Badge> + )} + </div> + </div> + {isApplied && ( + <div> + <label className="text-xs text-gray-500">조정대금 반영시점</label> + <p className="text-sm font-medium">{data?.adjustmentReflectionPoint || '-'}</p> + </div> + )} + </div> + </div> + + <Separator /> + + {/* 원재료 정보 */} + <div> + <h3 className="text-sm font-medium text-gray-900 mb-3">원재료 정보</h3> + <div className="space-y-4"> + {isApplied && ( + <div> + <label className="text-xs text-gray-500">연동대상 주요 원재료</label> + <p className="text-sm font-medium whitespace-pre-wrap"> + {data?.majorApplicableRawMaterial || '-'} + </p> + </div> + )} + {isNotApplied && ( + <> + <div> + <label className="text-xs text-gray-500">연동 미적용 주요 원재료</label> + <p className="text-sm font-medium whitespace-pre-wrap"> + {data?.majorNonApplicableRawMaterial || '-'} + </p> + </div> + <div> + <label className="text-xs text-gray-500">연동 미적용 사유</label> + <p className="text-sm font-medium whitespace-pre-wrap"> + {data?.nonApplicableReason || '-'} + </p> + </div> + </> + )} + </div> + </div> + + {isApplied && data && ( + <> + <Separator /> + + {/* 연동 공식 및 지표 */} + <div> + <h3 className="text-sm font-medium text-gray-900 mb-3">연동 공식 및 지표</h3> + <div className="space-y-4"> + <div> + <label className="text-xs text-gray-500">하도급대금등 연동 산식</label> + <div className="p-3 bg-gray-50 rounded-md"> + <p className="text-sm font-mono whitespace-pre-wrap"> + {data.adjustmentFormula || '-'} + </p> + </div> + </div> + <div> + <label className="text-xs text-gray-500">원재료 가격 기준지표</label> + <p className="text-sm font-medium whitespace-pre-wrap"> + {data.rawMaterialPriceIndex || '-'} + </p> + </div> + <div className="grid grid-cols-2 gap-4"> + <div> + <label className="text-xs text-gray-500">원재료 기준 가격의 변동률 산정을 위한 기준시점</label> + <p className="text-sm font-medium">{data.referenceDate ? formatDateValue(data.referenceDate) : '-'}</p> + </div> + <div> + <label className="text-xs text-gray-500">원재료 기준 가격의 변동률 산정을 위한 비교시점</label> + <p className="text-sm font-medium">{data.comparisonDate ? formatDateValue(data.comparisonDate) : '-'}</p> + </div> + </div> + {data.adjustmentRatio && ( + <div> + <label className="text-xs text-gray-500">반영비율</label> + <p className="text-sm font-medium"> + {data.adjustmentRatio}% + </p> + </div> + )} + </div> + </div> + + <Separator /> + + {/* 조정 조건 및 기타 */} + <div> + <h3 className="text-sm font-medium text-gray-900 mb-3">조정 조건 및 기타</h3> + <div className="space-y-4"> + <div> + <label className="text-xs text-gray-500">조정요건</label> + <p className="text-sm font-medium whitespace-pre-wrap"> + {data.adjustmentConditions || '-'} + </p> + </div> + <div className="grid grid-cols-2 gap-4"> + <div> + <label className="text-xs text-gray-500">조정주기</label> + <p className="text-sm font-medium">{data.adjustmentPeriod || '-'}</p> + </div> + <div> + <label className="text-xs text-gray-500">조정일</label> + <p className="text-sm font-medium">{data.adjustmentDate ? formatDateValue(data.adjustmentDate) : '-'}</p> + </div> + </div> + <div> + <label className="text-xs text-gray-500">수탁기업(협력사)작성자</label> + <p className="text-sm font-medium">{data.contractorWriter || '-'}</p> + </div> + {data.notes && ( + <div> + <label className="text-xs text-gray-500">기타사항</label> + <p className="text-sm font-medium whitespace-pre-wrap"> + {data.notes} + </p> + </div> + )} + </div> + </div> + </> + )} + + {isNotApplied && data && ( + <> + <Separator /> + <div> + <h3 className="text-sm font-medium text-gray-900 mb-3">작성자 정보</h3> + <div> + <label className="text-xs text-gray-500">수탁기업(협력사) 작성자</label> + <p className="text-sm font-medium">{data.contractorWriter || '-'}</p> + </div> + </div> + </> + )} + + {data && ( + <> + <Separator /> + + {/* 메타 정보 */} + <div className="text-xs text-gray-500 space-y-1"> + <p>작성일: {formatDateValue(data.createdAt)}</p> + <p>수정일: {formatDateValue(data.updatedAt)}</p> + </div> + </> + )} + + <Separator /> + + {/* 참고 경고문 */} + <div className="text-xs text-red-600 space-y-2 bg-red-50 p-3 rounded-md border border-red-200"> + <p className="font-medium">※ 참고사항</p> + <div className="space-y-1"> + <p>• 납품대금의 10% 이상을 차지하는 주요 원재료가 있는 경우 모든 주요 원재료에 대해서 적용 또는 미적용에 대한 연동표를 작성해야 한다.</p> + <p>• 납품대급연동표를 허위로 작성하거나 근거자료를 허위로 제출할 경우 본 계약이 체결되지 않을 수 있으며, 본 계약이 체결되었더라도 계약의 전부 또는 일부를 해제 또는 해지할 수 있다.</p> + </div> + </div> + </div> + )} + </DialogContent> + </Dialog> + ) +} + |
