diff options
| author | joonhoekim <26rote@gmail.com> | 2025-11-24 20:16:56 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-11-24 20:16:56 +0900 |
| commit | 6bc4162b19f06ad4f919270ebcd4ef18f31cd490 (patch) | |
| tree | be37a152174789d269ef718c2a1f3794531e1c37 /lib/bidding/detail | |
| parent | 775997501ef36bf07d7f1f2e1d4abe7c97505e96 (diff) | |
| parent | a8674e6b91fb4d356c311fad0251878de154da53 (diff) | |
(김준회) 최겸프로 작업사항 병합
Diffstat (limited to 'lib/bidding/detail')
| -rw-r--r-- | lib/bidding/detail/bidding-actions.ts | 160 | ||||
| -rw-r--r-- | lib/bidding/detail/service.ts | 58 | ||||
| -rw-r--r-- | lib/bidding/detail/table/bidding-award-dialog.tsx | 190 | ||||
| -rw-r--r-- | lib/bidding/detail/table/bidding-detail-vendor-table.tsx | 77 |
4 files changed, 380 insertions, 105 deletions
diff --git a/lib/bidding/detail/bidding-actions.ts b/lib/bidding/detail/bidding-actions.ts index 70bba1c3..fb659039 100644 --- a/lib/bidding/detail/bidding-actions.ts +++ b/lib/bidding/detail/bidding-actions.ts @@ -143,85 +143,85 @@ export async function checkAllVendorsFinalSubmitted(biddingId: number) { }
}
-// 개찰 서버 액션 (조기개찰/개찰 구분)
-export async function performBidOpening(
- biddingId: number,
- userId: string,
- isEarly: boolean = false // 조기개찰 여부
-) {
- try {
- const userName = await getUserNameById(userId)
+// // 개찰 서버 액션 (조기개찰/개찰 구분)
+// export async function performBidOpening(
+// biddingId: number,
+// userId: string,
+// isEarly: boolean = false // 조기개찰 여부
+// ) {
+// try {
+// const userName = await getUserNameById(userId)
- return await db.transaction(async (tx) => {
- // 1. 입찰 정보 조회
- const [bidding] = await tx
- .select({
- id: biddings.id,
- status: biddings.status,
- submissionEndDate: biddings.submissionEndDate,
- })
- .from(biddings)
- .where(eq(biddings.id, biddingId))
- .limit(1)
-
- if (!bidding) {
- return {
- success: false,
- error: '입찰 정보를 찾을 수 없습니다.'
- }
- }
-
- // 2. 개찰 가능 여부 확인 (evaluation_of_bidding 상태에서만)
- if (bidding.status !== 'evaluation_of_bidding') {
- return {
- success: false,
- error: '입찰평가중 상태에서만 개찰할 수 있습니다.'
- }
- }
-
- // 3. 모든 벤더가 최종제출했는지 확인
- const checkResult = await checkAllVendorsFinalSubmitted(biddingId)
- if (!checkResult.allSubmitted) {
- return {
- success: false,
- error: `모든 벤더가 최종 제출해야 개찰할 수 있습니다. (${checkResult.submittedCompanies}/${checkResult.totalCompanies})`
- }
- }
-
- // 4. 조기개찰 여부 결정
- const now = new Date()
- const submissionEndDate = bidding.submissionEndDate ? new Date(bidding.submissionEndDate) : null
- const isBeforeDeadline = submissionEndDate && now < submissionEndDate
-
- // 마감일 전이면 조기개찰, 마감일 후면 일반 개찰
- const newStatus = (isEarly || isBeforeDeadline) ? 'early_bid_opening' : 'bid_opening'
-
- // 5. 입찰 상태 변경
- await tx
- .update(biddings)
- .set({
- status: newStatus,
- updatedAt: new Date()
- })
- .where(eq(biddings.id, biddingId))
-
- // 캐시 무효화
- revalidateTag(`bidding-${biddingId}`)
- revalidateTag('bidding-detail')
- revalidatePath(`/evcp/bid/${biddingId}`)
-
- return {
- success: true,
- message: `${newStatus === 'early_bid_opening' ? '조기개찰' : '개찰'}이 완료되었습니다.`,
- status: newStatus
- }
- })
- } catch (error) {
- console.error('Failed to perform bid opening:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '개찰에 실패했습니다.'
- }
- }
-}
+// return await db.transaction(async (tx) => {
+// // 1. 입찰 정보 조회
+// const [bidding] = await tx
+// .select({
+// id: biddings.id,
+// status: biddings.status,
+// submissionEndDate: biddings.submissionEndDate,
+// })
+// .from(biddings)
+// .where(eq(biddings.id, biddingId))
+// .limit(1)
+
+// if (!bidding) {
+// return {
+// success: false,
+// error: '입찰 정보를 찾을 수 없습니다.'
+// }
+// }
+
+// // 2. 개찰 가능 여부 확인 (evaluation_of_bidding 상태에서만)
+// if (bidding.status !== 'evaluation_of_bidding') {
+// return {
+// success: false,
+// error: '입찰평가중 상태에서만 개찰할 수 있습니다.'
+// }
+// }
+
+// // 3. 모든 벤더가 최종제출했는지 확인
+// const checkResult = await checkAllVendorsFinalSubmitted(biddingId)
+// if (!checkResult.allSubmitted) {
+// return {
+// success: false,
+// error: `모든 벤더가 최종 제출해야 개찰할 수 있습니다. (${checkResult.submittedCompanies}/${checkResult.totalCompanies})`
+// }
+// }
+
+// // 4. 조기개찰 여부 결정
+// const now = new Date()
+// const submissionEndDate = bidding.submissionEndDate ? new Date(bidding.submissionEndDate) : null
+// const isBeforeDeadline = submissionEndDate && now < submissionEndDate
+
+// // 마감일 전이면 조기개찰, 마감일 후면 일반 개찰
+// const newStatus = (isEarly || isBeforeDeadline) ? 'early_bid_opening' : 'bid_opening'
+
+// // 5. 입찰 상태 변경
+// await tx
+// .update(biddings)
+// .set({
+// status: newStatus,
+// updatedAt: new Date()
+// })
+// .where(eq(biddings.id, biddingId))
+
+// // 캐시 무효화
+// revalidateTag(`bidding-${biddingId}`)
+// revalidateTag('bidding-detail')
+// revalidatePath(`/evcp/bid/${biddingId}`)
+
+// return {
+// success: true,
+// message: `${newStatus === 'early_bid_opening' ? '조기개찰' : '개찰'}이 완료되었습니다.`,
+// status: newStatus
+// }
+// })
+// } catch (error) {
+// console.error('Failed to perform bid opening:', error)
+// return {
+// success: false,
+// error: error instanceof Error ? error.message : '개찰에 실패했습니다.'
+// }
+// }
+// }
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index d0f8070f..297c6f98 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -1251,9 +1251,55 @@ export async function getAwardedCompanies(biddingId: number) { } } +// 입찰의 PR 아이템 금액 합산하여 bidding 업데이트 +async function updateBiddingAmounts(biddingId: number) { + try { + // 해당 bidding의 모든 PR 아이템들의 금액 합계 계산 + const amounts = await db + .select({ + totalTargetAmount: sql<number>`COALESCE(SUM(${prItemsForBidding.targetAmount}), 0)`, + totalBudgetAmount: sql<number>`COALESCE(SUM(${prItemsForBidding.budgetAmount}), 0)`, + totalActualAmount: sql<number>`COALESCE(SUM(${prItemsForBidding.actualAmount}), 0)` + }) + .from(prItemsForBidding) + .where(eq(prItemsForBidding.biddingId, biddingId)) + + const { totalTargetAmount, totalBudgetAmount, totalActualAmount } = amounts[0] + + // bidding 테이블 업데이트 + await db + .update(biddings) + .set({ + targetPrice: totalTargetAmount, + budget: totalBudgetAmount, + finalBidPrice: totalActualAmount, + updatedAt: new Date() + }) + .where(eq(biddings.id, biddingId)) + + console.log(`Bidding ${biddingId} amounts updated: target=${totalTargetAmount}, budget=${totalBudgetAmount}, actual=${totalActualAmount}`) + } catch (error) { + console.error('Failed to update bidding amounts:', error) + throw error + } +} + // PR 품목 정보 업데이트 export async function updatePrItem(prItemId: number, input: Partial<typeof prItemsForBidding.$inferSelect>, userId: string) { try { + // 업데이트 전 biddingId 확인 + const prItem = await db + .select({ biddingId: prItemsForBidding.biddingId }) + .from(prItemsForBidding) + .where(eq(prItemsForBidding.id, prItemId)) + .limit(1) + + if (!prItem[0]?.biddingId) { + throw new Error('PR item not found or biddingId is missing') + } + + const biddingId = prItem[0].biddingId + await db .update(prItemsForBidding) .set({ @@ -1262,12 +1308,14 @@ export async function updatePrItem(prItemId: number, input: Partial<typeof prIte }) .where(eq(prItemsForBidding.id, prItemId)) + // PR 아이템 금액 합산하여 bidding 업데이트 + await updateBiddingAmounts(biddingId) + // 캐시 무효화 - if (input.biddingId) { - revalidateTag(`bidding-${input.biddingId}`) - revalidateTag('pr-items') - revalidatePath(`/evcp/bid/${input.biddingId}`) - } + revalidateTag(`bidding-${biddingId}`) + revalidateTag('pr-items') + revalidatePath(`/evcp/bid/${biddingId}`) + return { success: true, message: '품목 정보가 성공적으로 업데이트되었습니다.' } } catch (error) { console.error('Failed to update PR item:', error) diff --git a/lib/bidding/detail/table/bidding-award-dialog.tsx b/lib/bidding/detail/table/bidding-award-dialog.tsx index 9a4614bd..ff104fac 100644 --- a/lib/bidding/detail/table/bidding-award-dialog.tsx +++ b/lib/bidding/detail/table/bidding-award-dialog.tsx @@ -26,7 +26,8 @@ import { } from '@/components/ui/table' import { Trophy, Building2, Calculator } from 'lucide-react' import { useToast } from '@/hooks/use-toast' -import { getAwardedCompanies, awardBidding } from '@/lib/bidding/detail/service' +import { getAwardedCompanies } from '@/lib/bidding/detail/service' +import { requestBiddingAwardWithApproval } from '@/lib/bidding/approval-actions' import { AwardSimpleFileUpload } from './components/award-simple-file-upload' interface BiddingAwardDialogProps { @@ -34,6 +35,12 @@ interface BiddingAwardDialogProps { open: boolean onOpenChange: (open: boolean) => void onSuccess: () => void + onApprovalPreview?: (data: { + templateName: string + variables: Record<string, string> + title: string + selectionReason: string + }) => void } interface AwardedCompany { @@ -47,7 +54,8 @@ export function BiddingAwardDialog({ biddingId, open, onOpenChange, - onSuccess + onSuccess, + onApprovalPreview }: BiddingAwardDialogProps) { const { toast } = useToast() const { data: session } = useSession() @@ -106,26 +114,36 @@ const userId = session?.user?.id || '2'; return } - startTransition(async () => { - const result = await awardBidding(biddingId, selectionReason, userId) + // 결재 템플릿 변수 준비 + const { mapBiddingAwardToTemplateVariables } = await import('@/lib/bidding/handlers') - if (result.success) { - toast({ - title: '성공', - description: result.message, - }) - onSuccess() - onOpenChange(false) - // 폼 초기화 - setSelectionReason('') - } else { - toast({ - title: '오류', - description: result.error, - variant: 'destructive', + try { + const variables = await mapBiddingAwardToTemplateVariables({ + biddingId, + selectionReason, + requestedAt: new Date() + }) + + // 상위 컴포넌트로 결재 미리보기 데이터 전달 + if (onApprovalPreview) { + onApprovalPreview({ + templateName: '입찰 결과 업체 선정 품의 요청서', + variables, + title: `낙찰 - ${bidding?.title}`, + selectionReason }) } - }) + + onOpenChange(false) + setSelectionReason('') + } catch (error) { + console.error('낙찰 템플릿 변수 준비 실패:', error) + toast({ + title: '오류', + description: '결재 문서 준비 중 오류가 발생했습니다.', + variant: 'destructive', + }) + } } @@ -251,11 +269,143 @@ const userId = session?.user?.id || '2'; type="submit" disabled={isPending || awardedCompanies.length === 0} > - {isPending ? '처리 중...' : '낙찰 완료'} + {isPending ? '상신 중...' : '결재 상신'} </Button> </DialogFooter> </form> </DialogContent> </Dialog> ) + + return ( + <> + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Trophy className="w-5 h-5 text-yellow-600" /> + 낙찰 처리 + </DialogTitle> + <DialogDescription> + 낙찰된 업체의 발주비율과 선정 사유를 확인하고 낙찰을 완료하세요. + </DialogDescription> + </DialogHeader> + + <form onSubmit={handleSubmit}> + <div className="space-y-6"> + {/* 낙찰 업체 정보 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Building2 className="w-4 h-4" /> + 낙찰 업체 정보 + </CardTitle> + </CardHeader> + <CardContent> + {isLoading ? ( + <div className="text-center py-4"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div> + <p className="mt-2 text-sm text-muted-foreground">낙찰 업체 정보를 불러오는 중...</p> + </div> + ) : awardedCompanies.length > 0 ? ( + <div className="space-y-4"> + <Table> + <TableHeader> + <TableRow> + <TableHead>업체명</TableHead> + <TableHead className="text-right">견적금액</TableHead> + <TableHead className="text-right">발주비율</TableHead> + <TableHead className="text-right">발주금액</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {awardedCompanies.map((company) => ( + <TableRow key={company.companyId}> + <TableCell className="font-medium"> + <div className="flex items-center gap-2"> + <Badge variant="default" className="bg-green-600">낙찰</Badge> + {company.companyName} + </div> + </TableCell> + <TableCell className="text-right"> + {company.finalQuoteAmount.toLocaleString()}원 + </TableCell> + <TableCell className="text-right"> + {company.awardRatio}% + </TableCell> + <TableCell className="text-right font-semibold"> + {(company.finalQuoteAmount * company.awardRatio / 100).toLocaleString()}원 + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + + {/* 최종입찰가 요약 */} + <div className="flex items-center justify-between p-4 bg-blue-50 border border-blue-200 rounded-lg"> + <div className="flex items-center gap-2"> + <Calculator className="w-5 h-5 text-blue-600" /> + <span className="font-semibold text-blue-800">최종입찰가</span> + </div> + <span className="text-xl font-bold text-blue-800"> + {finalBidPrice.toLocaleString()}원 + </span> + </div> + </div> + ) : ( + <div className="text-center py-8"> + <Trophy className="w-12 h-12 text-gray-400 mx-auto mb-4" /> + <p className="text-gray-500 mb-2">낙찰된 업체가 없습니다</p> + <p className="text-sm text-gray-400"> + 먼저 업체 수정 다이얼로그에서 발주비율을 산정해주세요. + </p> + </div> + )} + </CardContent> + </Card> + + {/* 낙찰 사유 */} + <div className="space-y-2"> + <Label htmlFor="selectionReason"> + 낙찰 사유 <span className="text-red-500">*</span> + </Label> + <Textarea + id="selectionReason" + placeholder="낙찰 사유를 상세히 입력해주세요..." + value={selectionReason} + onChange={(e) => setSelectionReason(e.target.value)} + rows={4} + className="resize-none" + /> + </div> + + {/* 첨부파일 */} + <AwardSimpleFileUpload + biddingId={biddingId} + userId={userId} + readOnly={false} + /> + </div> + + <DialogFooter className="mt-6"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isPending} + > + 취소 + </Button> + <Button + type="submit" + disabled={isPending || awardedCompanies.length === 0} + > + {isPending ? '상신 중...' : '결재 상신'} + </Button> + </DialogFooter> + </form> + </DialogContent> + </Dialog> + </> + ) } diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx index 1fa116ab..08fc0293 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx @@ -14,6 +14,8 @@ import { QuotationVendor, getPriceAdjustmentFormByBiddingCompanyId } from '@/lib import { Bidding } from '@/db/schema' import { PriceAdjustmentDialog } from '@/components/bidding/price-adjustment-dialog' import { QuotationHistoryDialog } from './quotation-history-dialog' +import { ApprovalPreviewDialog } from '@/lib/approval/approval-preview-dialog' +import { requestBiddingAwardWithApproval } from '@/lib/bidding/approval-actions' import { useToast } from '@/hooks/use-toast' interface BiddingDetailVendorTableContentProps { @@ -99,6 +101,13 @@ export function BiddingDetailVendorTableContent({ const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false) const [quotationHistoryData, setQuotationHistoryData] = React.useState<any>(null) const [isQuotationHistoryDialogOpen, setIsQuotationHistoryDialogOpen] = React.useState(false) + const [approvalPreviewData, setApprovalPreviewData] = React.useState<{ + templateName: string + variables: Record<string, string> + title: string + selectionReason: string + } | null>(null) + const [isApprovalPreviewDialogOpen, setIsApprovalPreviewDialogOpen] = React.useState(false) const handleEdit = (vendor: QuotationVendor) => { setSelectedVendor(vendor) @@ -187,6 +196,47 @@ export function BiddingDetailVendorTableContent({ clearOnDefault: true, }) + // 낙찰 결재 상신 핸들러 + const handleAwardApprovalConfirm = async (data: { approvers: string[]; title: string; attachments?: File[] }) => { + if (!session?.user?.id || !approvalPreviewData) return + + try { + const result = await requestBiddingAwardWithApproval({ + biddingId, + selectionReason: approvalPreviewData.selectionReason, + currentUser: { + id: Number(session.user.id), + epId: session.user.epId || null, + email: session.user.email || undefined + }, + approvers: data.approvers, + }) + + if (result.status === 'pending_approval') { + toast({ + title: '성공', + description: `낙찰 결재가 상신되었습니다. (ID: ${result.approvalId})`, + }) + setIsApprovalPreviewDialogOpen(false) + setApprovalPreviewData(null) + onRefresh() + } else { + toast({ + title: '오류', + description: '낙찰 결재 상신 중 오류가 발생했습니다.', + variant: 'destructive', + }) + } + } catch (error) { + console.error('낙찰 결재 상신 실패:', error) + toast({ + title: '오류', + description: '낙찰 결재 상신 중 오류가 발생했습니다.', + variant: 'destructive', + }) + } + } + return ( <> <DataTable table={table}> @@ -221,6 +271,10 @@ export function BiddingDetailVendorTableContent({ open={isAwardDialogOpen} onOpenChange={setIsAwardDialogOpen} onSuccess={onRefresh} + onApprovalPreview={(data) => { + setApprovalPreviewData(data) + setIsApprovalPreviewDialogOpen(true) + }} /> <PriceAdjustmentDialog @@ -238,6 +292,29 @@ export function BiddingDetailVendorTableContent({ biddingCurrency={quotationHistoryData?.biddingCurrency || 'KRW'} targetPrice={quotationHistoryData?.targetPrice} /> + + {/* 낙찰 결재 미리보기 다이얼로그 */} + {session?.user && session.user.epId && approvalPreviewData && ( + <ApprovalPreviewDialog + open={isApprovalPreviewDialogOpen} + onOpenChange={(open) => { + setIsApprovalPreviewDialogOpen(open) + if (!open) { + setApprovalPreviewData(null) + } + }} + templateName={approvalPreviewData.templateName} + variables={approvalPreviewData.variables} + title={approvalPreviewData.title} + currentUser={{ + id: Number(session.user.id), + epId: session.user.epId, + name: session.user.name || undefined, + email: session.user.email || undefined + }} + onConfirm={handleAwardApprovalConfirm} + /> + )} </> ) } |
