summaryrefslogtreecommitdiff
path: root/lib/bidding/detail
diff options
context:
space:
mode:
Diffstat (limited to 'lib/bidding/detail')
-rw-r--r--lib/bidding/detail/bidding-actions.ts160
-rw-r--r--lib/bidding/detail/service.ts58
-rw-r--r--lib/bidding/detail/table/bidding-award-dialog.tsx190
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-table.tsx77
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}
+ />
+ )}
</>
)
}