diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-19 09:24:58 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-19 09:24:58 +0000 |
| commit | 0d68dbcba27ce49c15f30126f7a5dfce974847a3 (patch) | |
| tree | f00f71a2c33f0110fc2ef9e1243b47719ab5c316 | |
| parent | 60382940bac4ac8309be64be16f4774b6820df22 (diff) | |
(최겸) 구매 입찰 발주비율 취소기능 추가 등
| -rw-r--r-- | components/bidding/manage/bidding-companies-editor.tsx | 1 | ||||
| -rw-r--r-- | db/schema/bidding.ts | 1 | ||||
| -rw-r--r-- | lib/bidding/actions.ts | 6 | ||||
| -rw-r--r-- | lib/bidding/detail/service.ts | 34 | ||||
| -rw-r--r-- | lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx | 25 | ||||
| -rw-r--r-- | lib/bidding/detail/table/bidding-detail-vendor-table.tsx | 24 | ||||
| -rw-r--r-- | lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx | 89 | ||||
| -rw-r--r-- | lib/general-contracts/detail/general-contract-basic-info.tsx | 161 | ||||
| -rw-r--r-- | lib/general-contracts/detail/general-contract-items-table.tsx | 12 |
9 files changed, 225 insertions, 128 deletions
diff --git a/components/bidding/manage/bidding-companies-editor.tsx b/components/bidding/manage/bidding-companies-editor.tsx index a81f0063..a5ce1349 100644 --- a/components/bidding/manage/bidding-companies-editor.tsx +++ b/components/bidding/manage/bidding-companies-editor.tsx @@ -483,7 +483,6 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC <p className="text-sm text-muted-foreground mt-1"> 입찰에 참여하는 업체들을 관리합니다. 업체를 선택하면 하단에 담당자 목록이 표시됩니다. </p> - <p className="text-sm text-muted-foreground mt-1"> 단수 입찰의 경우 1개 업체만 등록 가능합니다. </p> </div> {!readonly && ( <Button onClick={() => setAddVendorDialogOpen(true)} className="flex items-center gap-2"> diff --git a/db/schema/bidding.ts b/db/schema/bidding.ts index 8f9f8d84..ab9b3373 100644 --- a/db/schema/bidding.ts +++ b/db/schema/bidding.ts @@ -692,6 +692,7 @@ export const biddingStatusLabels = { set_target_price: '내정가 산정', bidding_opened: '입찰공고', bidding_closed: '입찰마감', + approval_pending: '결재 진행중', evaluation_of_bidding: '입찰평가중', bidding_disposal: '유찰', vendor_selected: '업체선정', diff --git a/lib/bidding/actions.ts b/lib/bidding/actions.ts index c4c543d9..d0017413 100644 --- a/lib/bidding/actions.ts +++ b/lib/bidding/actions.ts @@ -613,16 +613,16 @@ export async function cancelDisposalAction( } } - // 3. 입찰 상태를 입찰생성으로 변경 + // 3. 입찰 상태를 입찰평가중으로 변경 await tx .update(biddings) .set({ - status: 'bidding_generated', + status: 'evaluation_of_bidding', updatedAt: new Date(), updatedBy: userName, }) .where(eq(biddings.id, biddingId)) - + return { success: true, message: '유찰 취소가 완료되었습니다.' diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index b5a3cce8..6ab9270e 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -610,6 +610,40 @@ export async function updateBiddingDetailVendor( } } +// 발주비율 취소 (발주비율을 null로 리셋하고 낙찰 상태 해제) +export async function cancelAwardRatio(biddingCompanyId: number) { + try { + const result = await db.update(biddingCompanies) + .set({ + awardRatio: null, + isWinner: false, + updatedAt: new Date(), + }) + .where(eq(biddingCompanies.id, biddingCompanyId)) + .returning({ biddingId: biddingCompanies.biddingId }) + + // 캐시 무효화 + if (result.length > 0) { + const biddingId = result[0].biddingId + revalidateTag(`bidding-${biddingId}`) + revalidateTag('quotation-vendors') + revalidateTag('quotation-details') + revalidatePath(`/evcp/bid/${biddingId}`) + } + + return { + success: true, + message: '발주비율이 성공적으로 취소되었습니다.', + } + } catch (error) { + console.error('Failed to cancel award ratio:', error) + return { + success: false, + error: error instanceof Error ? error.message : '발주비율 취소에 실패했습니다.' + } + } +} + // 본입찰용 업체 추가 export async function createBiddingDetailVendor( biddingId: number, diff --git a/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx b/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx index 1a1b331e..6e5481f4 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx @@ -24,6 +24,7 @@ interface BiddingDetailVendorEditDialogProps { onSuccess: () => void biddingAwardCount?: string // 낙찰수 정보 추가 biddingStatus?: string // 입찰 상태 정보 추가 + allVendors?: QuotationVendor[] // 전체 벤더 목록 추가 } export function BiddingDetailVendorEditDialog({ @@ -32,7 +33,8 @@ export function BiddingDetailVendorEditDialog({ onOpenChange, onSuccess, biddingAwardCount, - biddingStatus + biddingStatus, + allVendors = [] }: BiddingDetailVendorEditDialogProps) { const { toast } = useToast() const [isPending, startTransition] = useTransition() @@ -42,6 +44,14 @@ export function BiddingDetailVendorEditDialog({ awardRatio: 0, }) + // 단수낙찰의 경우 이미 100%인 벤더가 있는지 확인 + const hasWinnerWith100Percent = React.useMemo(() => { + if (biddingAwardCount === 'single') { + return allVendors.some(v => v.awardRatio === 100 && v.id !== vendor?.id) + } + return false + }, [allVendors, biddingAwardCount, vendor?.id]) + // vendor가 변경되면 폼 데이터 업데이트 React.useEffect(() => { if (vendor) { @@ -135,7 +145,7 @@ export function BiddingDetailVendorEditDialog({ value={formData.awardRatio} onChange={(e) => setFormData({ ...formData, awardRatio: Number(e.target.value) })} placeholder="발주비율을 입력하세요" - disabled={vendor?.isBiddingParticipated !== true || biddingAwardCount === 'single' || biddingStatus === 'vendor_selected'} + disabled={vendor?.isBiddingParticipated !== true || biddingAwardCount === 'single' || biddingStatus === 'vendor_selected' || hasWinnerWith100Percent} /> {vendor?.isBiddingParticipated !== true && ( <p className="text-sm text-muted-foreground"> @@ -152,15 +162,20 @@ export function BiddingDetailVendorEditDialog({ 낙찰이 완료되어 발주비율을 수정할 수 없습니다. </p> )} + {hasWinnerWith100Percent && ( + <p className="text-sm text-orange-600"> + 단수 낙찰의 경우 이미 100% 발주비율이 설정된 업체가 있어 다른 업체의 발주비율을 수정할 수 없습니다. + </p> + )} </div> </div> <DialogFooter> <Button variant="outline" onClick={() => onOpenChange(false)}> 취소 </Button> - <Button - onClick={handleEdit} - disabled={isPending || vendor?.isBiddingParticipated !== true} + <Button + onClick={handleEdit} + disabled={isPending || vendor?.isBiddingParticipated !== true || hasWinnerWith100Percent} > 산정 </Button> diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx index cfdab9c6..1fa116ab 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx @@ -66,16 +66,16 @@ const advancedFilterFields: DataTableAdvancedFilterField<QuotationVendor>[] = [ label: '견적금액', type: 'number', }, - { - id: 'status', - label: '상태', - type: 'multi-select', - options: [ - { label: '제출완료', value: 'submitted' }, - { label: '선정완료', value: 'selected' }, - { label: '미제출', value: 'pending' }, - ], - }, + { + id: 'invitationStatus', + label: '상태', + type: 'multi-select', + options: [ + { label: '제출완료', value: 'bidding_submitted' }, + { label: '선정완료', value: 'bidding_accepted' }, + { label: '미제출', value: 'pending' }, + ], + }, ] export function BiddingDetailVendorTableContent({ @@ -201,6 +201,7 @@ export function BiddingDetailVendorTableContent({ userId={userId} onOpenAwardDialog={() => setIsAwardDialogOpen(true)} onSuccess={onRefresh} + winnerVendor={vendors.find(v => v.awardRatio === 100)} /> </DataTableAdvancedToolbar> </DataTable> @@ -210,8 +211,9 @@ export function BiddingDetailVendorTableContent({ open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen} onSuccess={onRefresh} - biddingAwardCount={bidding.awardCount} + biddingAwardCount={bidding.awardCount || undefined} biddingStatus={bidding.status} + allVendors={vendors} /> <BiddingAwardDialog 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 f2c23de9..c1d59677 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx @@ -6,7 +6,7 @@ import { useTransition } from "react" import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign, RotateCw } from "lucide-react" -import { registerBidding, markAsDisposal, createRebidding } from "@/lib/bidding/detail/service" +import { registerBidding, markAsDisposal, createRebidding, cancelAwardRatio } from "@/lib/bidding/detail/service" import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from "@/lib/bidding/pre-quote/service" import { increaseRoundOrRebid } from "@/lib/bidding/service" @@ -14,6 +14,7 @@ import { BiddingDetailVendorCreateDialog } from "../../../../components/bidding/ import { BiddingDocumentUploadDialog } from "./bidding-document-upload-dialog" import { Bidding } from "@/db/schema" import { useToast } from "@/hooks/use-toast" +import { QuotationVendor } from "@/lib/bidding/detail/service" interface BiddingDetailVendorToolbarActionsProps { biddingId: number @@ -21,6 +22,7 @@ interface BiddingDetailVendorToolbarActionsProps { userId: string onOpenAwardDialog: () => void onSuccess: () => void + winnerVendor?: QuotationVendor | null // 100% 낙찰된 벤더 } export function BiddingDetailVendorToolbarActions({ @@ -28,7 +30,8 @@ export function BiddingDetailVendorToolbarActions({ bidding, userId, onOpenAwardDialog, - onSuccess + onSuccess, + winnerVendor }: BiddingDetailVendorToolbarActionsProps) { const router = useRouter() const { toast } = useToast() @@ -39,6 +42,7 @@ export function BiddingDetailVendorToolbarActions({ const [isBiddingInvitationDialogOpen, setIsBiddingInvitationDialogOpen] = React.useState(false) const [selectedVendors, setSelectedVendors] = React.useState<any[]>([]) const [isRoundIncreaseDialogOpen, setIsRoundIncreaseDialogOpen] = React.useState(false) + const [isCancelAwardDialogOpen, setIsCancelAwardDialogOpen] = React.useState(false) // 본입찰 초대 다이얼로그가 열릴 때 선정된 업체들 조회 React.useEffect(() => { @@ -178,26 +182,51 @@ export function BiddingDetailVendorToolbarActions({ }) } + const handleCancelAward = () => { + if (!winnerVendor) return + + startTransition(async () => { + const result = await cancelAwardRatio(winnerVendor.id) + + if (result.success) { + toast({ + title: "성공", + description: result.message, + }) + setIsCancelAwardDialogOpen(false) + onSuccess() + } else { + toast({ + title: "오류", + description: result.error || "발주비율 취소 중 오류가 발생했습니다.", + variant: 'destructive', + }) + } + }) + } + const handleRoundIncreaseWithNavigation = () => { startTransition(async () => { const result = await increaseRoundOrRebid(bidding.id, userId, 'round_increase') if (result.success) { + const successResult = result as { success: true; message: string; biddingId: number; biddingNumber: string } toast({ title: "성공", - description: result.message, + description: successResult.message, }) // 새로 생성된 입찰의 상세 페이지로 이동 - if (result.biddingId) { - router.push(`/evcp/bid/${result.biddingId}`) + if (successResult.biddingId) { + router.push(`/evcp/bid/${successResult.biddingId}/info`) } else { - router.push(`/evcp/bid`) + router.push(`/evcp/bid/${biddingId}/info`) } onSuccess() } else { + const errorResult = result as { success: false; error: string } toast({ title: "오류", - description: result.error || "차수증가 중 오류가 발생했습니다.", + description: errorResult.error || "차수증가 중 오류가 발생했습니다.", variant: 'destructive', }) } @@ -244,6 +273,19 @@ export function BiddingDetailVendorToolbarActions({ </Button> </> )} + + {/* 발주비율 취소: 100% 낙찰된 벤더가 있는 경우 */} + {winnerVendor && ( + <Button + variant="outline" + size="sm" + onClick={() => setIsCancelAwardDialogOpen(true)} + disabled={isPending} + > + <RotateCcw className="mr-2 h-4 w-4" /> + 발주비율 취소 + </Button> + )} {/* 구분선 */} {(bidding.status === 'bidding_generated' || bidding.status === 'bidding_disposal') && ( @@ -307,6 +349,39 @@ export function BiddingDetailVendorToolbarActions({ </DialogContent> </Dialog> + {/* 발주비율 취소 확인 다이얼로그 */} + <Dialog open={isCancelAwardDialogOpen} onOpenChange={setIsCancelAwardDialogOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>발주비율 취소 확인</DialogTitle> + <DialogDescription> + {winnerVendor && ( + <> + <strong>{winnerVendor.vendorName}</strong> 업체의 발주비율(100%)을 취소하시겠습니까? + <br /> + 취소 후 다른 업체의 발주비율을 설정할 수 있습니다. + </> + )} + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button + variant="outline" + onClick={() => setIsCancelAwardDialogOpen(false)} + > + 아니오 + </Button> + <Button + variant="destructive" + onClick={handleCancelAward} + disabled={isPending} + > + 취소하기 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </> ) } diff --git a/lib/general-contracts/detail/general-contract-basic-info.tsx b/lib/general-contracts/detail/general-contract-basic-info.tsx index 54c083ff..fc147b59 100644 --- a/lib/general-contracts/detail/general-contract-basic-info.tsx +++ b/lib/general-contracts/detail/general-contract-basic-info.tsx @@ -1385,9 +1385,25 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) { </div>
{/* 사외업체 야드투입 */}
- <div className="space-y-4 col-span-2">
- <Label className="text-base font-medium">사외업체 야드투입</Label>
+ <div className="space-y-4 grid grid-cols-2 col-span-2">
+ {/* 연동제적용 */}
+ <div className="space-y-4 flex-1">
+ <Label className="text-base font-medium">연동제적용</Label>
+ <div className="space-y-2">
+ <Select value={formData.interlockingSystem} onValueChange={(value) => setFormData(prev => ({ ...prev, interlockingSystem: value }))}>
+ <SelectTrigger>
+ <SelectValue placeholder="연동제적용을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="Y">Y</SelectItem>
+ <SelectItem value="N">N</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
<div className="flex items-center space-x-4">
+ <div className="space-y-4 flex-1">
+ <Label className="text-base font-medium">사외업체 야드투입</Label>
<div className="flex items-center space-x-2">
<input
type="radio"
@@ -1456,12 +1472,14 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) { </div>
</DialogContent>
</Dialog>
+ </div>
</div>
{/* 계약성립조건 */}
- <div className="space-y-4 col-span-2">
- <Label className="text-base font-medium">계약성립조건</Label>
+ <div className="space-y-4 grid grid-cols-2 col-span-2">
<div className="space-y-3">
+ <Label className="text-base font-medium">계약성립조건</Label>
+
<div className="flex items-center space-x-2">
<input
type="checkbox"
@@ -1503,106 +1521,49 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) { <Label htmlFor="establishmentOther">기타</Label>
</div>
</div>
- </div>
-
- {/* 연동제적용 */}
- <div className="space-y-4">
- <Label className="text-base font-medium">연동제적용</Label>
- <div className="space-y-2">
- <Select value={formData.interlockingSystem} onValueChange={(value) => setFormData(prev => ({ ...prev, interlockingSystem: value }))}>
- <SelectTrigger>
- <SelectValue placeholder="연동제적용을 선택하세요" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="Y">Y</SelectItem>
- <SelectItem value="N">N</SelectItem>
- </SelectContent>
- </Select>
+ {/* 계약해지조건 */}
+ <div className="space-y-4 flex-1">
+ <Label className="text-base font-medium">계약해지조건</Label>
+ <div className="space-y-3">
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="standardTermination"
+ checked={formData.contractTerminationConditions.standardTermination}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractTerminationConditions: { ...prev.contractTerminationConditions, standardTermination: e.target.checked } }))}
+ className="rounded"
+ />
+ <Label htmlFor="standardTermination">표준 계약해지조건</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="projectNotAwarded"
+ checked={formData.contractTerminationConditions.projectNotAwarded}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractTerminationConditions: { ...prev.contractTerminationConditions, projectNotAwarded: e.target.checked } }))}
+ className="rounded"
+ />
+ <Label htmlFor="projectNotAwarded">프로젝트 미수주 시</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="terminationOther"
+ checked={formData.contractTerminationConditions.other}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractTerminationConditions: { ...prev.contractTerminationConditions, other: e.target.checked } }))}
+ className="rounded"
+ />
+ <Label htmlFor="terminationOther">기타</Label>
+ </div>
+ </div>
</div>
</div>
- {/* 필수문서동의 */}
- {/* <div className="space-y-4">
- <Label className="text-base font-medium">필수문서동의</Label>
- <div className="space-y-3">
- <div className="flex items-center space-x-2">
- <input
- type="checkbox"
- id="technicalDataAgreement"
- checked={formData.mandatoryDocuments.technicalDataAgreement}
- onChange={(e) => setFormData(prev => ({ ...prev, mandatoryDocuments: { ...prev.mandatoryDocuments, technicalDataAgreement: e.target.checked } }))}
- className="rounded"
- />
- <Label htmlFor="technicalDataAgreement">기술자료제공동의서</Label>
- </div>
- <div className="flex items-center space-x-2">
- <input
- type="checkbox"
- id="nda"
- checked={formData.mandatoryDocuments.nda}
- onChange={(e) => setFormData(prev => ({ ...prev, mandatoryDocuments: { ...prev.mandatoryDocuments, nda: e.target.checked } }))}
- className="rounded"
- />
- <Label htmlFor="nda">비밀유지계약서(NDA)</Label>
- </div>
- <div className="flex items-center space-x-2">
- <input
- type="checkbox"
- id="basicCompliance"
- checked={formData.mandatoryDocuments.basicCompliance}
- onChange={(e) => setFormData(prev => ({ ...prev, mandatoryDocuments: { ...prev.mandatoryDocuments, basicCompliance: e.target.checked } }))}
- className="rounded"
- />
- <Label htmlFor="basicCompliance">기본준수서약서</Label>
- </div>
- <div className="flex items-center space-x-2">
- <input
- type="checkbox"
- id="safetyHealthAgreement"
- checked={formData.mandatoryDocuments.safetyHealthAgreement}
- onChange={(e) => setFormData(prev => ({ ...prev, mandatoryDocuments: { ...prev.mandatoryDocuments, safetyHealthAgreement: e.target.checked } }))}
- className="rounded"
- />
- <Label htmlFor="safetyHealthAgreement">안전보건관리 약정서</Label>
- </div>
- </div>
- </div> */}
+ {/* 연동제적용과 계약해지조건을 같은 줄에 배치 */}
+ <div className="flex gap-8">
+
+
- {/* 계약해지조건 */}
- <div className="space-y-4">
- <Label className="text-base font-medium">계약해지조건</Label>
- <div className="space-y-3">
- <div className="flex items-center space-x-2">
- <input
- type="checkbox"
- id="standardTermination"
- checked={formData.contractTerminationConditions.standardTermination}
- onChange={(e) => setFormData(prev => ({ ...prev, contractTerminationConditions: { ...prev.contractTerminationConditions, standardTermination: e.target.checked } }))}
- className="rounded"
- />
- <Label htmlFor="standardTermination">표준 계약해지조건</Label>
- </div>
- <div className="flex items-center space-x-2">
- <input
- type="checkbox"
- id="projectNotAwarded"
- checked={formData.contractTerminationConditions.projectNotAwarded}
- onChange={(e) => setFormData(prev => ({ ...prev, contractTerminationConditions: { ...prev.contractTerminationConditions, projectNotAwarded: e.target.checked } }))}
- className="rounded"
- />
- <Label htmlFor="projectNotAwarded">프로젝트 미수주 시</Label>
- </div>
- <div className="flex items-center space-x-2">
- <input
- type="checkbox"
- id="terminationOther"
- checked={formData.contractTerminationConditions.other}
- onChange={(e) => setFormData(prev => ({ ...prev, contractTerminationConditions: { ...prev.contractTerminationConditions, other: e.target.checked } }))}
- className="rounded"
- />
- <Label htmlFor="terminationOther">기타</Label>
- </div>
- </div>
</div>
<div className="space-y-2 col-span-2">
diff --git a/lib/general-contracts/detail/general-contract-items-table.tsx b/lib/general-contracts/detail/general-contract-items-table.tsx index ed1e5afb..bda2901e 100644 --- a/lib/general-contracts/detail/general-contract-items-table.tsx +++ b/lib/general-contracts/detail/general-contract-items-table.tsx @@ -664,7 +664,7 @@ export function ContractItemsTable({ /> )} </TableCell> - <TableCell className="px-3 py-3"> + {/* <TableCell className="px-3 py-3"> {readOnly ? ( <span className="text-sm text-right">{item.quantity.toLocaleString()}</span> ) : ( @@ -677,6 +677,16 @@ export function ContractItemsTable({ disabled={!isEnabled || isQuantityDisabled} /> )} + </TableCell> */} + <TableCell className="px-3 py-3"> + <Input + type="number" + value={item.quantity} + onChange={(e) => updateItem(index, 'quantity', parseFloat(e.target.value) || 0)} + className="h-8 text-sm text-right" + placeholder="0" + disabled={!isEnabled} + /> </TableCell> <TableCell className="px-3 py-3"> {readOnly ? ( |
