diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-08 10:29:19 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-08 10:29:19 +0000 |
| commit | f93493f68c9f368e10f1c3379f1c1384068e3b14 (patch) | |
| tree | a9dada58741750fa7ca6e04b210443ad99a6bccc /lib/bidding/pre-quote | |
| parent | e832a508e1b3c531fb3e1b9761e18e1b55e3d76a (diff) | |
(대표님, 최겸) rfqLast, bidding, prequote
Diffstat (limited to 'lib/bidding/pre-quote')
4 files changed, 257 insertions, 6 deletions
diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts index 7f0a9083..b5b06769 100644 --- a/lib/bidding/pre-quote/service.ts +++ b/lib/bidding/pre-quote/service.ts @@ -127,6 +127,33 @@ export async function updateBiddingCompany(id: number, input: UpdateBiddingCompa } } +// 본입찰 등록 상태 업데이트 (복수 업체 선택 가능) +export async function updatePreQuoteSelection(companyIds: number[], isSelected: boolean, userId: string) { + try { + await db.update(biddingCompanies) + .set({ + isPreQuoteSelected: isSelected, + updatedAt: new Date() + }) + .where(inArray(biddingCompanies.id, companyIds)) + + const message = isSelected + ? `${companyIds.length}개 업체가 본입찰 대상으로 선정되었습니다.` + : `${companyIds.length}개 업체의 본입찰 선정이 취소되었습니다.` + + return { + success: true, + message + } + } catch (error) { + console.error('Failed to update pre-quote selection:', error) + return { + success: false, + error: error instanceof Error ? error.message : '본입찰 선정 상태 업데이트에 실패했습니다.' + } + } +} + // 사전견적용 업체 삭제 export async function deleteBiddingCompany(id: number) { try { @@ -302,6 +329,17 @@ export async function sendPreQuoteInvitations(companyIds: number[]) { } } } + // 3. 입찰 상태를 사전견적 요청으로 변경 (bidding_generated 상태에서만) + await tx + .update(biddings) + .set({ + status: 'request_for_quotation', + updatedAt: new Date() + }) + .where(and( + eq(biddings.id, biddingId), + eq(biddings.status, 'bidding_generated') + )) return { success: true, @@ -556,6 +594,28 @@ export async function submitPreQuoteResponse( await tx.insert(priceAdjustmentForms).values(priceAdjustmentData) } } + + // 5. 입찰 상태를 사전견적 접수로 변경 (request_for_quotation 상태에서만) + // 또한 사전견적 접수일 업데이트 + const biddingCompany = await tx + .select({ biddingId: biddingCompanies.biddingId }) + .from(biddingCompanies) + .where(eq(biddingCompanies.id, biddingCompanyId)) + .limit(1) + + if (biddingCompany.length > 0) { + await tx + .update(biddings) + .set({ + status: 'received_quotation', + preQuoteReceivedAt: new Date(), // 사전견적 접수일 업데이트 + updatedAt: new Date() + }) + .where(and( + eq(biddings.id, biddingCompany[0].biddingId), + eq(biddings.status, 'request_for_quotation') + )) + } }) return { @@ -648,6 +708,8 @@ export async function getPrItemsForBidding(biddingId: number) { materialDescription: prItemsForBidding.materialDescription, quantity: prItemsForBidding.quantity, quantityUnit: prItemsForBidding.quantityUnit, + totalWeight: prItemsForBidding.totalWeight, + weightUnit: prItemsForBidding.weightUnit, currency: prItemsForBidding.currency, requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate, hasSpecDocument: prItemsForBidding.hasSpecDocument @@ -665,7 +727,6 @@ export async function getPrItemsForBidding(biddingId: number) { // SPEC 문서 조회 (PR 아이템에 연결된 문서들) export async function getSpecDocumentsForPrItem(prItemId: number) { try { - console.log('getSpecDocumentsForPrItem called with prItemId:', prItemId) const specDocs = await db .select({ @@ -686,7 +747,6 @@ export async function getSpecDocumentsForPrItem(prItemId: number) { ) ) - console.log('getSpecDocumentsForPrItem result:', specDocs) return specDocs } catch (error) { console.error('Failed to get spec documents for PR item:', error) diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-selection-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-selection-dialog.tsx new file mode 100644 index 00000000..7de79771 --- /dev/null +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-selection-dialog.tsx @@ -0,0 +1,158 @@ +'use client' + +import * as React from 'react' +import { BiddingCompany } from './bidding-pre-quote-vendor-columns' +import { updatePreQuoteSelection } from '../service' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { CheckCircle, XCircle, AlertCircle } from 'lucide-react' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' + +interface BiddingPreQuoteSelectionDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + selectedCompanies: BiddingCompany[] + onSuccess: () => void +} + +export function BiddingPreQuoteSelectionDialog({ + open, + onOpenChange, + selectedCompanies, + onSuccess +}: BiddingPreQuoteSelectionDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + // 선택된 업체들의 현재 상태 분석 (선정만 가능) + const unselectedCompanies = selectedCompanies.filter(c => !c.isPreQuoteSelected) + const hasQuotationCompanies = selectedCompanies.filter(c => c.preQuoteAmount && Number(c.preQuoteAmount) > 0) + + const handleConfirm = () => { + const companyIds = selectedCompanies.map(c => c.id) + const isSelected = true // 항상 선정으로 고정 + + startTransition(async () => { + const result = await updatePreQuoteSelection( + companyIds, + isSelected, + 'current-user' // TODO: 실제 사용자 ID + ) + + if (result.success) { + toast({ + title: '성공', + description: result.message, + }) + onSuccess() + onOpenChange(false) + } else { + toast({ + title: '오류', + description: result.error, + variant: 'destructive', + }) + } + }) + } + + const getActionIcon = (isSelected: boolean) => { + return isSelected ? + <CheckCircle className="h-4 w-4 text-muted-foreground" /> : + <CheckCircle className="h-4 w-4 text-green-600" /> + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[600px]"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <AlertCircle className="h-5 w-5 text-amber-500" /> + 본입찰 선정 상태 변경 + </DialogTitle> + <DialogDescription> + 선택된 {selectedCompanies.length}개 업체의 본입찰 선정 상태를 변경합니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 견적 제출 여부 안내 */} + {hasQuotationCompanies.length !== selectedCompanies.length && ( + <div className="bg-amber-50 border border-amber-200 rounded-lg p-3"> + <div className="flex items-center gap-2 text-amber-800"> + <AlertCircle className="h-4 w-4" /> + <span className="text-sm font-medium">알림</span> + </div> + <p className="text-sm text-amber-700 mt-1"> + 사전견적을 제출하지 않은 업체도 포함되어 있습니다. + 견적 미제출 업체도 본입찰에 참여시키시겠습니까? + </p> + </div> + )} + + {/* 업체 목록 */} + <div className="border rounded-lg"> + <div className="p-3 bg-muted/50 border-b"> + <h4 className="font-medium">대상 업체 목록</h4> + </div> + <div className="max-h-64 overflow-y-auto"> + {selectedCompanies.map((company) => ( + <div key={company.id} className="flex items-center justify-between p-3 border-b last:border-b-0"> + <div className="flex items-center gap-3"> + {getActionIcon(company.isPreQuoteSelected)} + <div> + <div className="font-medium">{company.companyName}</div> + <div className="text-sm text-muted-foreground">{company.companyCode}</div> + </div> + </div> + <div className="flex items-center gap-2"> + <Badge variant={company.isPreQuoteSelected ? 'default' : 'secondary'}> + {company.isPreQuoteSelected ? '현재 선정' : '현재 미선정'} + </Badge> + {company.preQuoteAmount && Number(company.preQuoteAmount) > 0 ? ( + <Badge variant="outline" className="text-green-600"> + 견적 제출 + </Badge> + ) : ( + <Badge variant="outline" className="text-muted-foreground"> + 견적 미제출 + </Badge> + )} + </div> + </div> + ))} + </div> + </div> + + {/* 결과 요약 */} + <div className="bg-blue-50 border border-blue-200 rounded-lg p-3"> + <h5 className="font-medium text-blue-900 mb-2">변경 결과</h5> + <div className="text-sm text-blue-800"> + <p>• {unselectedCompanies.length}개 업체가 본입찰 대상으로 <span className="font-medium text-green-600">선정</span>됩니다.</p> + {selectedCompanies.length > unselectedCompanies.length && ( + <p>• {selectedCompanies.length - unselectedCompanies.length}개 업체는 이미 선정 상태이므로 변경되지 않습니다.</p> + )} + </div> + </div> + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button onClick={handleConfirm} disabled={isPending}> + {isPending ? '처리 중...' : '확인'} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx index f28f9e1f..7e84f178 100644 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx @@ -317,7 +317,7 @@ export function getBiddingPreQuoteVendorColumns({ }, { id: 'actions', - header: '작업', + header: '액션', cell: ({ row }) => { const company = row.original @@ -330,7 +330,6 @@ export function getBiddingPreQuoteVendorColumns({ </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> - <DropdownMenuLabel>작업</DropdownMenuLabel> {/* <DropdownMenuItem onClick={() => onEdit(company)}> <Edit className="mr-2 h-4 w-4" /> 수정 @@ -341,7 +340,6 @@ export function getBiddingPreQuoteVendorColumns({ 초대 발송 </DropdownMenuItem> )} - <DropdownMenuSeparator /> <DropdownMenuItem onClick={() => onDelete(company)} className="text-destructive" diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx index c1b1baa5..6c209e2d 100644 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx @@ -4,10 +4,11 @@ import * as React from "react" import { type Table } from "@tanstack/react-table" import { useTransition } from "react" import { Button } from "@/components/ui/button" -import { Plus, Send, Mail } from "lucide-react" +import { Plus, Send, Mail, CheckSquare } from "lucide-react" import { BiddingCompany } from "./bidding-pre-quote-vendor-columns" import { BiddingPreQuoteVendorCreateDialog } from "./bidding-pre-quote-vendor-create-dialog" import { BiddingPreQuoteInvitationDialog } from "./bidding-pre-quote-invitation-dialog" +import { BiddingPreQuoteSelectionDialog } from "./bidding-pre-quote-selection-dialog" import { Bidding } from "@/db/schema" import { useToast } from "@/hooks/use-toast" @@ -36,6 +37,7 @@ export function BiddingPreQuoteVendorToolbarActions({ const [isPending, startTransition] = useTransition() const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false) const [isInvitationDialogOpen, setIsInvitationDialogOpen] = React.useState(false) + const [isSelectionDialogOpen, setIsSelectionDialogOpen] = React.useState(false) const handleCreateCompany = () => { setIsCreateDialogOpen(true) @@ -45,6 +47,19 @@ export function BiddingPreQuoteVendorToolbarActions({ setIsInvitationDialogOpen(true) } + const handleManageSelection = () => { + const selectedRows = table.getFilteredSelectedRowModel().rows + if (selectedRows.length === 0) { + toast({ + title: '선택 필요', + description: '본입찰 선정 상태를 변경할 업체를 선택해주세요.', + variant: 'destructive', + }) + return + } + setIsSelectionDialogOpen(true) + } + return ( @@ -69,6 +84,16 @@ export function BiddingPreQuoteVendorToolbarActions({ <Mail className="mr-2 h-4 w-4" /> 초대 발송 </Button> + + <Button + variant="secondary" + size="sm" + onClick={handleManageSelection} + disabled={isPending} + > + <CheckSquare className="mr-2 h-4 w-4" /> + 본입찰 선정 + </Button> </div> <BiddingPreQuoteVendorCreateDialog @@ -87,6 +112,16 @@ export function BiddingPreQuoteVendorToolbarActions({ companies={biddingCompanies} onSuccess={onSuccess} /> + + <BiddingPreQuoteSelectionDialog + open={isSelectionDialogOpen} + onOpenChange={setIsSelectionDialogOpen} + selectedCompanies={table.getFilteredSelectedRowModel().rows.map(row => row.original)} + onSuccess={() => { + onSuccess() + table.resetRowSelection() + }} + /> </> ) } |
