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 | |
| parent | 775997501ef36bf07d7f1f2e1d4abe7c97505e96 (diff) | |
| parent | a8674e6b91fb4d356c311fad0251878de154da53 (diff) | |
(김준회) 최겸프로 작업사항 병합
44 files changed, 3274 insertions, 1216 deletions
diff --git a/components/bidding/ProjectSelectorBid.tsx b/components/bidding/ProjectSelectorBid.tsx index de9e435e..0fc567b3 100644 --- a/components/bidding/ProjectSelectorBid.tsx +++ b/components/bidding/ProjectSelectorBid.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import { Check, ChevronsUpDown, X } from "lucide-react" +import { Check, ChevronsUpDown } from "lucide-react" import { Button } from "@/components/ui/button" import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command" @@ -10,7 +10,7 @@ import { getProjects, type Project } from "@/lib/rfqs/service" interface ProjectSelectorProps { selectedProjectId?: number | null; - onProjectSelect: (project: Project) => void; + onProjectSelect: (project: Project | null) => void; placeholder?: string; filterType?: string; // 옵션으로 필터 타입 지정 가능 } @@ -76,7 +76,7 @@ export function ProjectSelector({ // 이미 선택된 프로젝트를 다시 선택하면 선택 해제 if (selectedProject?.id === project.id) { setSelectedProject(null); - onProjectSelect(null as any); // 선택 해제를 위해 null 전달 + onProjectSelect(null); // 선택 해제를 위해 null 전달 setOpen(false); return; } @@ -86,12 +86,6 @@ export function ProjectSelector({ setOpen(false); }; - // 프로젝트 선택 해제 - const handleClearSelection = (e: React.MouseEvent) => { - e.stopPropagation(); // Popover가 열리지 않도록 방지 - setSelectedProject(null); - onProjectSelect(null as any); // 선택 해제를 위해 null 전달 - }; return ( <Popover open={open} onOpenChange={setOpen}> @@ -106,20 +100,7 @@ export function ProjectSelector({ {isLoading ? ( "프로젝트 로딩 중..." ) : selectedProject ? ( - <div className="flex items-center justify-between w-full"> - <span>{selectedProject.projectCode}</span> - <div className="flex items-center gap-1"> - <Button - variant="ghost" - size="sm" - className="h-4 w-4 p-0 hover:bg-destructive hover:text-destructive-foreground" - onClick={handleClearSelection} - > - <X className="h-3 w-3" /> - </Button> - <ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" /> - </div> - </div> + <span>{selectedProject.projectCode}</span> ) : ( <div className="flex items-center justify-between w-full"> <span>{placeholder}</span> diff --git a/components/bidding/create/bidding-create-dialog.tsx b/components/bidding/create/bidding-create-dialog.tsx index 9b0a6f66..498d8d1c 100644 --- a/components/bidding/create/bidding-create-dialog.tsx +++ b/components/bidding/create/bidding-create-dialog.tsx @@ -618,7 +618,7 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp {/* 4행: 하도급법적용여부, SHI 지급조건 */}
<div className="grid grid-cols-2 gap-4">
- {/* <FormField
+ <FormField
control={form.control}
name="biddingConditions.isPriceAdjustmentApplicable"
render={({ field }) => (
@@ -637,13 +637,13 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp }}
/>
<FormLabel htmlFor="price-adjustment" className="text-sm">
- 연동제 적용 요건
+ 하도급법 적용여부
</FormLabel>
</div>
<FormMessage />
</FormItem>
)}
- /> */}
+ />
<FormField
control={form.control}
@@ -957,7 +957,7 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp <FormItem>
<FormLabel className="flex items-center gap-1">
<Building className="h-3 w-3" />
- 구매조직
+ 구매조직 <span className="text-red-500">*</span>
</FormLabel>
<Select onValueChange={field.onChange} value={field.value || ''}>
<FormControl>
diff --git a/components/bidding/manage/bidding-basic-info-editor.tsx b/components/bidding/manage/bidding-basic-info-editor.tsx index c2c668a4..3c450065 100644 --- a/components/bidding/manage/bidding-basic-info-editor.tsx +++ b/components/bidding/manage/bidding-basic-info-editor.tsx @@ -721,7 +721,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB <FormItem> <FormLabel className="flex items-center gap-1"> <Building className="h-3 w-3" /> - 구매조직 + 구매조직 <span className="text-red-500">*</span> </FormLabel> <Select onValueChange={field.onChange} value={field.value}> <FormControl> @@ -1019,7 +1019,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB </div> </div> - {/* 4행: 계약 납품일, 연동제 적용 가능 */} + {/* 4행: 계약 납품일, 하도급법 적용여부 */} <div className="grid grid-cols-2 gap-4 mb-4"> <div> <FormLabel>계약 납품일</FormLabel> @@ -1034,6 +1034,24 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB }} /> </div> + <div> + <FormLabel>하도급법 적용여부</FormLabel> + <div className="flex items-center space-x-2"> + <Switch + checked={biddingConditions.isPriceAdjustmentApplicable} + onCheckedChange={(checked) => { + setBiddingConditions(prev => ({ + ...prev, + isPriceAdjustmentApplicable: checked + })) + }} + id="price-adjustment" + /> + <FormLabel htmlFor="price-adjustment" className="text-sm"> + {biddingConditions.isPriceAdjustmentApplicable ? "적용" : "미적용"} + </FormLabel> + </div> + </div> </div> {/* 5행: 스페어파트 옵션 */} diff --git a/components/bidding/manage/bidding-companies-editor.tsx b/components/bidding/manage/bidding-companies-editor.tsx index a5ce1349..da566c82 100644 --- a/components/bidding/manage/bidding-companies-editor.tsx +++ b/components/bidding/manage/bidding-companies-editor.tsx @@ -49,6 +49,7 @@ interface QuotationVendor { currency: string invitationStatus: string isPriceAdjustmentApplicableQuestion?: boolean + businessSize?: string | null } interface BiddingCompaniesEditorProps { @@ -106,17 +107,18 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC try { const result = await getBiddingVendors(biddingId) if (result.success && result.data) { - const vendorsList = result.data.map(v => ({ - ...v, - companyId: v.companyId || undefined, - vendorName: v.vendorName || '', - vendorCode: v.vendorCode || '', - contactPerson: v.contactPerson ?? undefined, - contactEmail: v.contactEmail ?? undefined, - contactPhone: v.contactPhone ?? undefined, - quotationAmount: v.quotationAmount ? parseFloat(v.quotationAmount) : undefined, - isPriceAdjustmentApplicableQuestion: v.isPriceAdjustmentApplicableQuestion ?? false, - })) + const vendorsList = result.data.map(v => ({ + ...v, + companyId: v.companyId || undefined, + vendorName: v.vendorName || '', + vendorCode: v.vendorCode || '', + contactPerson: v.contactPerson ?? undefined, + contactEmail: v.contactEmail ?? undefined, + contactPhone: v.contactPhone ?? undefined, + quotationAmount: v.quotationAmount ? parseFloat(v.quotationAmount) : undefined, + isPriceAdjustmentApplicableQuestion: v.isPriceAdjustmentApplicableQuestion ?? false, + businessSize: v.businessSize ?? undefined, + })) setVendors(vendorsList) // 각 업체별 첫 번째 담당자 정보 로드 @@ -161,6 +163,7 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC currency: v.currency || 'KRW', invitationStatus: v.invitationStatus, isPriceAdjustmentApplicableQuestion: v.isPriceAdjustmentApplicableQuestion ?? false, + businessSize: v.businessSize ?? undefined, })) setVendors(vendorsList) @@ -503,6 +506,7 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC <TableHead className="w-[50px]">선택</TableHead> <TableHead>업체명</TableHead> <TableHead>업체코드</TableHead> + <TableHead>기업규모</TableHead> <TableHead>담당자 이름</TableHead> <TableHead>담당자 이메일</TableHead> <TableHead>담당자 연락처</TableHead> @@ -526,6 +530,7 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC </TableCell> <TableCell className="font-medium">{vendor.vendorName}</TableCell> <TableCell>{vendor.vendorCode}</TableCell> + <TableCell>{vendor.businessSize || '-'}</TableCell> <TableCell> {vendor.companyId && vendorFirstContacts.has(vendor.companyId) ? vendorFirstContacts.get(vendor.companyId)!.contactName diff --git a/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx b/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx index de813121..0dd9f0eb 100644 --- a/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx +++ b/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx @@ -28,7 +28,7 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { ChevronsUpDown, Loader2, X, Plus } from 'lucide-react' import { createBiddingDetailVendor } from '@/lib/bidding/detail/service' -import { searchVendorsForBidding } from '@/lib/bidding/service' +import { searchVendorsForBidding, getVendorsBusinessSize } from '@/lib/bidding/service' import { useToast } from '@/hooks/use-toast' import { useTransition } from 'react' import { Badge } from '@/components/ui/badge' @@ -68,6 +68,9 @@ export function BiddingDetailVendorCreateDialog({ const [selectedVendorsWithQuestion, setSelectedVendorsWithQuestion] = React.useState<SelectedVendorWithQuestion[]>([]) const [vendorOpen, setVendorOpen] = React.useState(false) + // Business size 정보 캐싱 + const [businessSizeMap, setBusinessSizeMap] = React.useState<Record<number, string | null>>({}) + // 벤더 로드 const loadVendors = React.useCallback(async () => { try { @@ -204,6 +207,28 @@ export function BiddingDetailVendorCreateDialog({ const selectedVendors = selectedVendorsWithQuestion.map(item => item.vendor) + // 선택된 vendor들의 businessSize 정보를 useMemo로 캐싱 + const loadBusinessSize = React.useMemo(() => { + const selectedVendorIds = selectedVendors.map(v => v.id) + if (selectedVendorIds.length === 0) return + + // 이미 로드된 vendor들은 제외 + const newVendorIds = selectedVendorIds.filter(id => !(id in businessSizeMap)) + + if (newVendorIds.length > 0) { + getVendorsBusinessSize(newVendorIds).then(result => { + setBusinessSizeMap(prev => ({ ...prev, ...result })) + }).catch(error => { + console.error('Failed to load business size:', error) + }) + } + }, [selectedVendors, businessSizeMap]) + + // selectedVendors가 변경될 때마다 businessSize 로드 + React.useEffect(() => { + loadBusinessSize + }, [loadBusinessSize]) + return ( <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent className="max-w-4xl max-h-[90vh] p-0 flex flex-col"> @@ -382,6 +407,9 @@ export function BiddingDetailVendorCreateDialog({ > 연동제 적용요건 문의 </Label> + <span className="text-xs text-muted-foreground"> + 기업규모: {businessSizeMap[item.vendor.id] || '미정'} + </span> </div> </div> ))} diff --git a/components/bidding/manage/bidding-items-editor.tsx b/components/bidding/manage/bidding-items-editor.tsx index f0287ae4..208cf040 100644 --- a/components/bidding/manage/bidding-items-editor.tsx +++ b/components/bidding/manage/bidding-items-editor.tsx @@ -441,8 +441,8 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems materialGroupInfo: null, materialNumber: null, materialInfo: null, - priceUnit: null, - purchaseUnit: '1', + priceUnit: 1, + purchaseUnit: 'EA', materialWeight: null, wbsCode: null, wbsName: null, @@ -495,8 +495,8 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems prev.map((item) => { if (item.id === id) { const updatedItem = { ...item, ...updates } - // 내정단가, 수량, 중량, 구매단위가 변경되면 내정금액 재계산 - if (updates.targetUnitPrice || updates.quantity || updates.totalWeight || updates.purchaseUnit) { + // 내정단가, 수량, 중량, 가격단위가 변경되면 내정금액 재계산 + if (updates.targetUnitPrice || updates.quantity || updates.totalWeight || updates.priceUnit) { updatedItem.targetAmount = calculateTargetAmount(updatedItem) } return updatedItem @@ -533,20 +533,66 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems const calculateTargetAmount = (item: PRItemInfo): string => { const unitPrice = parseFloat(item.targetUnitPrice?.replace(/,/g, '') || '0') || 0 - const purchaseUnit = parseFloat(item.purchaseUnit || '1') || 1 + const priceUnit = parseFloat(item.priceUnit || '1') || 1 let amount = 0 if (quantityWeightMode === 'quantity') { const quantity = parseFloat(item.quantity || '0') || 0 - amount = (quantity / purchaseUnit) * unitPrice + amount = (quantity / priceUnit) * unitPrice } else { const weight = parseFloat(item.totalWeight || '0') || 0 - amount = (weight / purchaseUnit) * unitPrice + amount = (weight / priceUnit) * unitPrice } return Math.floor(amount).toString() } + // 합계 계산 함수들 + const calculateTotals = () => { + let quantityTotal = 0 + let weightTotal = 0 + let targetAmountTotal = 0 + let budgetAmountTotal = 0 + let actualAmountTotal = 0 + + items.forEach((item) => { + // 수량 합계 + if (item.quantity) { + quantityTotal += parseFloat(item.quantity) || 0 + } + + // 중량 합계 + if (item.totalWeight) { + weightTotal += parseFloat(item.totalWeight) || 0 + } + + // 내정금액 합계 + if (item.targetAmount) { + targetAmountTotal += parseFloat(item.targetAmount.replace(/,/g, '')) || 0 + } + + // 예산금액 합계 + if (item.budgetAmount) { + budgetAmountTotal += parseFloat(item.budgetAmount.replace(/,/g, '')) || 0 + } + + // 실적금액 합계 + if (item.actualAmount) { + actualAmountTotal += parseFloat(item.actualAmount.replace(/,/g, '')) || 0 + } + }) + + return { + quantityTotal, + weightTotal, + targetAmountTotal, + budgetAmountTotal, + actualAmountTotal, + } + } + + const totals = calculateTotals() + if (isLoading) { return ( <div className="flex items-center justify-center p-8"> @@ -572,14 +618,16 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems </th> <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">프로젝트코드</th> <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">프로젝트명</th> - <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">PR 번호</th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">자재그룹코드 <span className="text-red-500">*</span></th> <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">자재그룹명 <span className="text-red-500">*</span></th> <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">자재코드</th> <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">자재명</th> - <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">수량</th> - <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">단위</th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">수량(중량) <span className="text-red-500">*</span></th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">단위 <span className="text-red-500">*</span></th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">가격단위</th> <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">구매단위</th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[100px]">자재순중량</th> <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">내정단가</th> <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">내정금액</th> <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">내정통화</th> @@ -587,19 +635,120 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">예산통화</th> <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">실적금액</th> <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">실적통화</th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">납품요청일 <span className="text-red-500">*</span></th> <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">WBS코드</th> <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">WBS명</th> <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">코스트센터코드</th> <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">코스트센터명</th> <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">GL계정코드</th> <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">GL계정명</th> - <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">납품요청일 <span className="text-red-500">*</span></th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">PR 번호</th> <th className="sticky right-0 z-10 bg-muted/50 border-l px-3 py-3 text-center text-xs font-medium min-w-[100px]"> 액션 </th> </tr> </thead> <tbody> + {/* 합계 행 */} + <tr className="bg-blue-50 border-y-2 border-blue-200 font-semibold"> + <td className="sticky left-0 z-10 bg-blue-50 border-r px-2 py-3 text-center"> + <span className="text-xs">합계</span> + </td> + <td className="sticky left-[50px] z-10 bg-blue-50 border-r px-3 py-3 text-center"> + <span className="text-xs">-</span> + </td> + <td className="border-r px-3 py-3 text-center"> + <span className="text-xs text-muted-foreground">-</span> + </td> + <td className="border-r px-3 py-3 text-center"> + <span className="text-xs text-muted-foreground">-</span> + </td> + <td className="border-r px-3 py-3 text-center"> + <span className="text-xs text-muted-foreground">-</span> + </td> + <td className="border-r px-3 py-3 text-center"> + <span className="text-xs text-muted-foreground">-</span> + </td> + <td className="border-r px-3 py-3 text-center"> + <span className="text-xs text-muted-foreground">-</span> + </td> + <td className="border-r px-3 py-3 text-center"> + <span className="text-xs text-muted-foreground">-</span> + </td> + <td className="border-r px-3 py-3 text-center"> + <span className="text-xs"> + {quantityWeightMode === 'quantity' + ? `${formatNumberWithCommas(totals.quantityTotal.toString())}` + : `${formatNumberWithCommas(totals.weightTotal.toString())}` + } + </span> + </td> + <td className="border-r px-3 py-3 text-center"> + <span className="text-xs"> + {quantityWeightMode === 'quantity' + ? `${items[0]?.quantityUnit || 'EA'}` + : `${items[0]?.weightUnit || 'KG'}` + } + </span> + </td> + <td className="border-r px-3 py-3 text-center"> + <span className="text-xs text-muted-foreground">-</span> + </td> + <td className="border-r px-3 py-3 text-center"> + <span className="text-xs text-muted-foreground">-</span> + </td> + <td className="border-r px-3 py-3 text-center"> + <span className="text-xs text-muted-foreground">-</span> + </td> + <td className="border-r px-3 py-3 text-center"> + <span className="text-xs text-muted-foreground">-</span> + </td> + <td className="border-r px-3 py-3 text-center"> + <span className="text-xs">{formatNumberWithCommas(totals.targetAmountTotal.toString())}</span> + </td> + <td className="border-r px-3 py-3 text-center"> + <span className="text-xs text-muted-foreground">-</span> + </td> + <td className="border-r px-3 py-3 text-center"> + <span className="text-xs">{formatNumberWithCommas(totals.budgetAmountTotal.toString())}</span> + </td> + <td className="border-r px-3 py-3 text-center"> + <span className="text-xs text-muted-foreground">-</span> + </td> + <td className="border-r px-3 py-3 text-center"> + <span className="text-xs">{formatNumberWithCommas(totals.actualAmountTotal.toString())}</span> + </td> + <td className="border-r px-3 py-3 text-center"> + <span className="text-xs text-muted-foreground">-</span> + </td> + <td className="border-r px-3 py-3 text-center"> + <span className="text-xs text-muted-foreground">-</span> + </td> + <td className="border-r px-3 py-3 text-center"> + <span className="text-xs text-muted-foreground">-</span> + </td> + <td className="border-r px-3 py-3 text-center"> + <span className="text-xs text-muted-foreground">-</span> + </td> + <td className="border-r px-3 py-3 text-center"> + <span className="text-xs text-muted-foreground">-</span> + </td> + <td className="border-r px-3 py-3 text-center"> + <span className="text-xs text-muted-foreground">-</span> + </td> + <td className="border-r px-3 py-3 text-center"> + <span className="text-xs text-muted-foreground">-</span> + </td> + <td className="border-r px-3 py-3 text-center"> + <span className="text-xs text-muted-foreground">-</span> + </td> + <td className="border-r px-3 py-3 text-center"> + <span className="text-xs text-muted-foreground">-</span> + </td> + <td className="sticky right-0 z-10 bg-blue-50 border-l px-3 py-3 text-center"> + <span className="text-xs text-muted-foreground">-</span> + </td> + </tr> {items.map((item, index) => ( <tr key={item.id} className="border-t hover:bg-muted/30"> <td className="sticky left-0 z-10 bg-background border-r px-2 py-2 text-center"> @@ -617,10 +766,17 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems <ProjectSelector selectedProjectId={item.projectId || null} onProjectSelect={(project) => { - updatePRItem(item.id, { - projectId: project.id, - projectInfo: project.projectName - }) + if (project) { + updatePRItem(item.id, { + projectId: project.id, + projectInfo: project.projectName + }) + } else { + updatePRItem(item.id, { + projectId: null, + projectInfo: null + }) + } }} placeholder="프로젝트 선택" /> @@ -633,14 +789,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems className="h-8 text-xs bg-muted/50" /> </td> - <td className="border-r px-3 py-2"> - <Input - placeholder="PR 번호" - value={item.prNumber || ''} - readOnly - className="h-8 text-xs bg-muted/50" - /> - </td> + <td className="border-r px-3 py-2"> {biddingType !== 'equipment' ? ( <ProcurementItemSelectorDialogSingle @@ -745,6 +894,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems value={item.quantity || ''} onChange={(e) => updatePRItem(item.id, { quantity: e.target.value })} className="h-8 text-xs" + required /> ) : ( <Input @@ -754,6 +904,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems value={item.totalWeight || ''} onChange={(e) => updatePRItem(item.id, { totalWeight: e.target.value })} className="h-8 text-xs" + required /> )} </td> @@ -762,6 +913,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems <Select value={item.quantityUnit || 'EA'} onValueChange={(value) => updatePRItem(item.id, { quantityUnit: value })} + required > <SelectTrigger className="h-8 text-xs"> <SelectValue /> @@ -779,6 +931,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems <Select value={item.weightUnit || 'KG'} onValueChange={(value) => updatePRItem(item.id, { weightUnit: value })} + required > <SelectTrigger className="h-8 text-xs"> <SelectValue /> @@ -797,9 +950,42 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems type="number" min="1" step="1" - placeholder="구매단위" - value={item.purchaseUnit || ''} - onChange={(e) => updatePRItem(item.id, { purchaseUnit: e.target.value })} + placeholder="가격단위" + value={item.priceUnit || ''} + onChange={(e) => updatePRItem(item.id, { priceUnit: e.target.value })} + className="h-8 text-xs" + /> + </td> + <td className="border-r px-3 py-2"> + <Select + value={item.purchaseUnit || 'EA'} + onValueChange={(value) => updatePRItem(item.id, { purchaseUnit: value })} + > + <SelectTrigger className="h-8 text-xs"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="EA">EA</SelectItem> + <SelectItem value="SET">SET</SelectItem> + <SelectItem value="LOT">LOT</SelectItem> + <SelectItem value="M">M</SelectItem> + <SelectItem value="M2">M²</SelectItem> + <SelectItem value="M3">M³</SelectItem> + <SelectItem value="KG">KG</SelectItem> + <SelectItem value="TON">TON</SelectItem> + <SelectItem value="G">G</SelectItem> + <SelectItem value="LB">LB</SelectItem> + </SelectContent> + </Select> + </td> + <td className="border-r px-3 py-2"> + <Input + type="number" + min="0" + step="0.01" + placeholder="자재순중량" + value={item.materialWeight || ''} + onChange={(e) => updatePRItem(item.id, { materialWeight: e.target.value })} className="h-8 text-xs" /> </td> @@ -888,9 +1074,23 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems </Select> </td> <td className="border-r px-3 py-2"> + <Input + type="date" + value={item.requestedDeliveryDate || ''} + onChange={(e) => updatePRItem(item.id, { requestedDeliveryDate: e.target.value })} + className="h-8 text-xs" + required + /> + </td> + <td className="border-r px-3 py-2"> <Button variant="outline" onClick={() => { + // 재클릭 시 기존 데이터 클리어 + updatePRItem(item.id, { + wbsCode: null, + wbsName: null + }) setSelectedItemForWbs(item.id) setWbsCodeDialogOpen(true) }} @@ -941,6 +1141,11 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems <Button variant="outline" onClick={() => { + // 재클릭 시 기존 데이터 클리어 + updatePRItem(item.id, { + costCenterCode: null, + costCenterName: null + }) setSelectedItemForCostCenter(item.id) setCostCenterDialogOpen(true) }} @@ -992,6 +1197,11 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems <Button variant="outline" onClick={() => { + // 재클릭 시 기존 데이터 클리어 + updatePRItem(item.id, { + glAccountCode: null, + glAccountName: null + }) setSelectedItemForGlAccount(item.id) setGlAccountDialogOpen(true) }} @@ -1039,11 +1249,10 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems </td> <td className="border-r px-3 py-2"> <Input - type="date" - value={item.requestedDeliveryDate || ''} - onChange={(e) => updatePRItem(item.id, { requestedDeliveryDate: e.target.value })} - className="h-8 text-xs" - required + placeholder="PR 번호" + value={item.prNumber || ''} + readOnly + className="h-8 text-xs bg-muted/50" /> </td> <td className="sticky right-0 z-10 bg-background border-l px-3 py-2"> diff --git a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx index c49f6232..cdcf1ef1 100644 --- a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx +++ b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx @@ -51,8 +51,8 @@ import { MaterialGroupSelectorDialogSingle } from "@/components/common/material/ import { MaterialSearchItem } from "@/lib/material/material-group-service"
import { MaterialSelectorDialogSingle } from "@/components/common/selectors/material/material-selector-dialog-single"
import { MaterialSearchItem as SAPMaterialSearchItem } from "@/components/common/selectors/material/material-service"
-import { ProcurementManagerSelector } from "@/components/common/selectors/procurement-manager"
-import type { ProcurementManagerWithUser } from "@/components/common/selectors/procurement-manager/procurement-manager-service"
+import { PurchaseGroupCodeSelector } from "@/components/common/selectors/purchase-group-code/purchase-group-code-selector"
+import { getBiddingById } from "@/lib/bidding/service"
// 아이템 스키마
const itemSchema = z.object({
@@ -122,7 +122,7 @@ export function CreatePreQuoteRfqDialog({ const [isLoading, setIsLoading] = React.useState(false)
const [previewCode, setPreviewCode] = React.useState("")
const [isLoadingPreview, setIsLoadingPreview] = React.useState(false)
- const [selectedManager, setSelectedManager] = React.useState<ProcurementManagerWithUser | undefined>(undefined)
+ const [selectedBidPic, setSelectedBidPic] = React.useState<any | undefined>(undefined)
const { data: session } = useSession()
const userId = React.useMemo(() => {
@@ -170,6 +170,29 @@ export function CreatePreQuoteRfqDialog({ name: "items",
})
+ // 입찰담당자 정보 로드
+ React.useEffect(() => {
+ const loadBiddingInfo = async () => {
+ if (!biddingId || !open) return
+
+ try {
+ const bidding = await getBiddingById(biddingId)
+ if (bidding && bidding.bidPicId) {
+ // 입찰담당자 정보를 로드하는 로직 추가 필요
+ // 현재는 임시로 bidPicId를 사용
+ setSelectedBidPic({
+ USER_ID: bidding.bidPicId,
+ DISPLAY_NAME: bidding.bidPicName || '입찰담당자'
+ })
+ }
+ } catch (error) {
+ console.error('Failed to load bidding info:', error)
+ }
+ }
+
+ loadBiddingInfo()
+ }, [biddingId, open])
+
// 다이얼로그가 열릴 때 폼 초기화
React.useEffect(() => {
if (open) {
@@ -177,7 +200,7 @@ export function CreatePreQuoteRfqDialog({ rfqType: "",
rfqTitle: "",
dueDate: undefined,
- picUserId: undefined,
+ picUserId: selectedBidPic?.USER_ID,
projectId: undefined,
remark: "",
items: initialItems.length > 0 ? initialItems : [
@@ -192,14 +215,13 @@ export function CreatePreQuoteRfqDialog({ },
],
})
- setSelectedManager(undefined)
setPreviewCode("")
}
- }, [open, initialItems, form])
+ }, [open, initialItems, form, selectedBidPic])
// 견적담당자 선택 시 RFQ 코드 미리보기 생성
React.useEffect(() => {
- if (!selectedManager?.user?.id) {
+ if (!selectedBidPic?.USER_ID) {
setPreviewCode("")
return
}
@@ -208,7 +230,7 @@ export function CreatePreQuoteRfqDialog({ (async () => {
setIsLoadingPreview(true)
try {
- const code = await previewGeneralRfqCode(selectedManager.user!.id!)
+ const code = await previewGeneralRfqCode(selectedBidPic.USER_ID)
setPreviewCode(code)
} catch (error) {
console.error("코드 미리보기 오류:", error)
@@ -217,7 +239,7 @@ export function CreatePreQuoteRfqDialog({ setIsLoadingPreview(false)
}
})()
- }, [selectedManager])
+ }, [selectedBidPic])
// 견적 종류 변경
const handleRfqTypeChange = (value: string) => {
@@ -244,8 +266,8 @@ export function CreatePreQuoteRfqDialog({ },
],
})
- setSelectedManager(undefined)
- setPreviewCode("")
+ setSelectedBidPic(undefined)
+ setPreviewCode("")
onOpenChange(false)
}
@@ -255,12 +277,12 @@ export function CreatePreQuoteRfqDialog({ return
}
- if (!selectedManager?.user?.id) {
- toast.error("견적담당자를 선택해주세요")
+ if (!selectedBidPic?.USER_ID) {
+ toast.error("입찰담당자를 선택해주세요")
return
}
- const picUserId = selectedManager.user.id
+ const picUserId = selectedBidPic.USER_ID
setIsLoading(true)
@@ -476,13 +498,13 @@ export function CreatePreQuoteRfqDialog({ 견적담당자 <span className="text-red-500">*</span>
</FormLabel>
<FormControl>
- <ProcurementManagerSelector
- selectedManager={selectedManager}
- onManagerSelect={(manager) => {
- setSelectedManager(manager)
- field.onChange(manager.user?.id)
+ <PurchaseGroupCodeSelector
+ selectedCode={selectedBidPic}
+ onCodeSelect={(code) => {
+ setSelectedBidPic(code)
+ field.onChange(code.USER_ID)
}}
- placeholder="견적담당자를 선택하세요"
+ placeholder="입찰담당자 선택"
/>
</FormControl>
<FormDescription>
diff --git a/components/bidding/price-adjustment-dialog.tsx b/components/bidding/price-adjustment-dialog.tsx index 149b8e9a..6a520eac 100644 --- a/components/bidding/price-adjustment-dialog.tsx +++ b/components/bidding/price-adjustment-dialog.tsx @@ -67,7 +67,7 @@ export function PriceAdjustmentDialog({ <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> + <label className="text-xs text-gray-500">물품등의 명칭</label> <p className="text-sm font-medium">{data.itemName || '-'}</p> </div> <div> @@ -126,16 +126,16 @@ export function PriceAdjustmentDialog({ </div> <div className="grid grid-cols-2 gap-4"> <div> - <label className="text-xs text-gray-500">기준시점</label> + <label className="text-xs text-gray-500">원재료 기준 가격의 변동률 산정을 위한 기준시점</label> <p className="text-sm font-medium">{data.referenceDate ? formatDate(data.referenceDate, "kr") : '-'}</p> </div> <div> - <label className="text-xs text-gray-500">비교시점</label> + <label className="text-xs text-gray-500">원재료 기준 가격의 변동률 산정을 위한 비교시점</label> <p className="text-sm font-medium">{data.comparisonDate ? formatDate(data.comparisonDate, "kr") : '-'}</p> </div> </div> <div> - <label className="text-xs text-gray-500">연동 비율</label> + <label className="text-xs text-gray-500">반영비율</label> <p className="text-sm font-medium"> {data.adjustmentRatio ? `${data.adjustmentRatio}%` : '-'} </p> @@ -166,11 +166,11 @@ export function PriceAdjustmentDialog({ </div> </div> <div> - <label className="text-xs text-gray-500">수탁기업(협력사) 작성자</label> + <label className="text-xs text-gray-500">수탁기업(협력사)작성자</label> <p className="text-sm font-medium">{data.contractorWriter || '-'}</p> </div> <div> - <label className="text-xs text-gray-500">기타 사항</label> + <label className="text-xs text-gray-500">기타사항</label> <p className="text-sm font-medium whitespace-pre-wrap"> {data.notes || '-'} </p> @@ -185,6 +185,17 @@ export function PriceAdjustmentDialog({ <p>작성일: {formatDate(data.createdAt, "kr")}</p> <p>수정일: {formatDate(data.updatedAt, "kr")}</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> diff --git a/components/common/selectors/cost-center/cost-center-selector.tsx b/components/common/selectors/cost-center/cost-center-selector.tsx index 3aad5787..f87b6928 100644 --- a/components/common/selectors/cost-center/cost-center-selector.tsx +++ b/components/common/selectors/cost-center/cost-center-selector.tsx @@ -77,11 +77,6 @@ export function CostCenterSelector({ const [globalFilter, setGlobalFilter] = useState('')
const [isPending, startTransition] = useTransition()
- // 날짜 포맷 함수 (YYYYMMDD -> YYYY-MM-DD)
- const formatDate = (dateStr: string) => {
- if (!dateStr || dateStr.length !== 8) return dateStr
- return `${dateStr.substring(0, 4)}-${dateStr.substring(4, 6)}-${dateStr.substring(6, 8)}`
- }
// Cost Center 선택 핸들러
const handleCodeSelect = useCallback(async (code: CostCenter) => {
@@ -118,27 +113,6 @@ export function CostCenterSelector({ ),
},
{
- accessorKey: 'LTEXT',
- header: '설명',
- cell: ({ row }) => (
- <div>{row.getValue('LTEXT')}</div>
- ),
- },
- {
- accessorKey: 'DATAB',
- header: '시작일',
- cell: ({ row }) => (
- <div className="text-sm">{formatDate(row.getValue('DATAB'))}</div>
- ),
- },
- {
- accessorKey: 'DATBI',
- header: '종료일',
- cell: ({ row }) => (
- <div className="text-sm">{formatDate(row.getValue('DATBI'))}</div>
- ),
- },
- {
id: 'actions',
header: '선택',
cell: ({ row }) => (
diff --git a/components/common/selectors/cost-center/cost-center-service.ts b/components/common/selectors/cost-center/cost-center-service.ts index 844215f0..1e7e43f5 100644 --- a/components/common/selectors/cost-center/cost-center-service.ts +++ b/components/common/selectors/cost-center/cost-center-service.ts @@ -5,19 +5,16 @@ import { oracleKnex } from '@/lib/oracle-db/db' // Cost Center 타입 정의
export interface CostCenter {
KOSTL: string // Cost Center 코드
- DATAB: string // 시작일
- DATBI: string // 종료일
KTEXT: string // 단축 텍스트
- LTEXT: string // 긴 텍스트
}
// 테스트 환경용 폴백 데이터
const FALLBACK_TEST_DATA: CostCenter[] = [
- { KOSTL: 'D6023930', DATAB: '20230101', DATBI: '99991231', KTEXT: '구매팀', LTEXT: '구매팀 Cost Center(테스트데이터 - 오라클 페칭 실패시)' },
- { KOSTL: 'D6023931', DATAB: '20230101', DATBI: '99991231', KTEXT: '자재팀', LTEXT: '자재팀 Cost Center(테스트데이터 - 오라클 페칭 실패시)' },
- { KOSTL: 'D6023932', DATAB: '20230101', DATBI: '99991231', KTEXT: '조달팀', LTEXT: '조달팀 Cost Center(테스트데이터 - 오라클 페칭 실패시)' },
- { KOSTL: 'D6023933', DATAB: '20230101', DATBI: '99991231', KTEXT: '구매1팀', LTEXT: '구매1팀 Cost Center(테스트데이터 - 오라클 페칭 실패시)' },
- { KOSTL: 'D6023934', DATAB: '20230101', DATBI: '99991231', KTEXT: '구매2팀', LTEXT: '구매2팀 Cost Center(테스트데이터 - 오라클 페칭 실패시)' },
+ { KOSTL: 'D6023930', KTEXT: '구매팀(테스트데이터 - 오라클 페칭 실패시)' },
+ { KOSTL: 'D6023931', KTEXT: '자재팀(테스트데이터 - 오라클 페칭 실패시)' },
+ { KOSTL: 'D6023932', KTEXT: '조달팀(테스트데이터 - 오라클 페칭 실패시)' },
+ { KOSTL: 'D6023933', KTEXT: '구매1팀(테스트데이터 - 오라클 페칭 실패시)' },
+ { KOSTL: 'D6023934', KTEXT: '구매2팀(테스트데이터 - 오라클 페칭 실패시)' },
]
/**
@@ -35,19 +32,11 @@ export async function getCostCenters(): Promise<{ console.log('📋 [getCostCenters] Oracle 쿼리 시작...')
const result = await oracleKnex.raw(`
- SELECT
- KOSTL,
- DATAB,
- DATBI,
- KTEXT,
- LTEXT
- FROM CMCTB_COSTCENTER
- WHERE ROWNUM < 100
- AND NVL(BKZKP,' ') = ' '
- AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN DATAB AND DATBI
- AND KOKRS = 'H100'
- ORDER BY KOSTL
+ SELECT KOSTL , KTEXT FROM CMCTB_COSTCENTER
+ WHERE TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN DATAB AND DATBI AND ROWNUM < 10
`)
+ // KOSTL -> CONTCENTER
+ // KTEXT -> CONTCENTER명
// Oracle raw query의 결과는 rows 배열에 들어있음
const rows = (result.rows || result) as Array<Record<string, unknown>>
@@ -56,17 +45,12 @@ export async function getCostCenters(): Promise<{ // null 값 필터링
const cleanedResult = rows
- .filter((item) =>
- item.KOSTL &&
- item.DATAB &&
- item.DATBI
+ .filter((item) =>
+ item.KOSTL
)
.map((item) => ({
KOSTL: String(item.KOSTL),
- DATAB: String(item.DATAB),
- DATBI: String(item.DATBI),
- KTEXT: String(item.KTEXT || ''),
- LTEXT: String(item.LTEXT || '')
+ KTEXT: String(item.KTEXT || '')
}))
console.log(`✅ [getCostCenters] 필터링 후 ${cleanedResult.length}건`)
diff --git a/components/common/selectors/cost-center/cost-center-single-selector.tsx b/components/common/selectors/cost-center/cost-center-single-selector.tsx index e09f782b..952fab3f 100644 --- a/components/common/selectors/cost-center/cost-center-single-selector.tsx +++ b/components/common/selectors/cost-center/cost-center-single-selector.tsx @@ -134,27 +134,6 @@ export function CostCenterSingleSelector({ ), }, { - accessorKey: 'LTEXT', - header: '설명', - cell: ({ row }) => ( - <div>{row.getValue('LTEXT')}</div> - ), - }, - { - accessorKey: 'DATAB', - header: '시작일', - cell: ({ row }) => ( - <div className="text-sm">{formatDate(row.getValue('DATAB'))}</div> - ), - }, - { - accessorKey: 'DATBI', - header: '종료일', - cell: ({ row }) => ( - <div className="text-sm">{formatDate(row.getValue('DATBI'))}</div> - ), - }, - { id: 'actions', header: '선택', cell: ({ row }) => { @@ -283,7 +262,6 @@ export function CostCenterSingleSelector({ <div className="flex items-center gap-2 mt-1"> <span className="font-mono text-sm">[{currentSelectedCode.KOSTL}]</span> <span>{currentSelectedCode.KTEXT}</span> - <span className="text-muted-foreground">- {currentSelectedCode.LTEXT}</span> </div> </div> )} @@ -291,7 +269,7 @@ export function CostCenterSingleSelector({ <div className="flex items-center space-x-2"> <Search className="h-4 w-4" /> <Input - placeholder="코스트센터 코드, 단축명, 설명으로 검색..." + placeholder="코스트센터 코드, 단축명으로 검색..." value={globalFilter} onChange={(e) => handleSearchChange(e.target.value)} className="flex-1" diff --git a/components/common/selectors/gl-account/gl-account-selector.tsx b/components/common/selectors/gl-account/gl-account-selector.tsx index 7e47a072..63eeede8 100644 --- a/components/common/selectors/gl-account/gl-account-selector.tsx +++ b/components/common/selectors/gl-account/gl-account-selector.tsx @@ -5,9 +5,8 @@ * * @description * - 오라클에서 GL 계정들을 조회 - * - SAKNR: 계정(G/L) - * - FIPEX: 세부계정 - * - TEXT1: 계정명 + * - GL: 계정 + * - GL명: 계정명 */ import { useState, useCallback, useMemo, useTransition } from 'react' @@ -52,7 +51,6 @@ export interface GlAccountSelectorProps { export interface GlAccountItem { saknr: string // 계정(G/L) - fipex: string // 세부계정 text1: string // 계정명 displayText: string // 표시용 텍스트 } @@ -76,7 +74,7 @@ export function GlAccountSelector({ // GL 계정 선택 핸들러 const handleCodeSelect = useCallback(async (code: GlAccount) => { // 이미 선택된 계정을 다시 선택하면 선택 해제 - if (selectedCode && selectedCode.SAKNR === code.SAKNR && selectedCode.FIPEX === code.FIPEX) { + if (selectedCode && selectedCode.SAKNR === code.SAKNR) { onCodeSelect(undefined as any) // 선택 해제를 위해 undefined 전달 setOpen(false) return @@ -101,13 +99,6 @@ export function GlAccountSelector({ ), }, { - accessorKey: 'FIPEX', - header: '세부계정', - cell: ({ row }) => ( - <div className="font-mono text-sm">{row.getValue('FIPEX')}</div> - ), - }, - { accessorKey: 'TEXT1', header: '계정명', cell: ({ row }) => ( @@ -206,7 +197,6 @@ export function GlAccountSelector({ {selectedCode ? ( <div className="flex items-center gap-2 w-full"> <span className="font-mono text-sm">[{selectedCode.SAKNR}]</span> - <span className="font-mono text-sm">{selectedCode.FIPEX}</span> <span className="truncate flex-1 text-left">{selectedCode.TEXT1}</span> <Button variant="ghost" @@ -237,7 +227,7 @@ export function GlAccountSelector({ <div className="flex items-center space-x-2"> <Search className="h-4 w-4" /> <Input - placeholder="계정, 세부계정, 계정명으로 검색..." + placeholder="계정, 계정명으로 검색..." value={globalFilter} onChange={(e) => handleSearchChange(e.target.value)} className="flex-1" @@ -284,7 +274,7 @@ export function GlAccountSelector({ )} </TableCell> ))} - {selectedCode && selectedCode.SAKNR === row.original.SAKNR && selectedCode.FIPEX === row.original.FIPEX && ( + {selectedCode && selectedCode.SAKNR === row.original.SAKNR && ( <TableCell className="text-right"> <span className="text-xs text-muted-foreground">(선택됨)</span> </TableCell> diff --git a/components/common/selectors/gl-account/gl-account-service.ts b/components/common/selectors/gl-account/gl-account-service.ts index 75c82c95..3feaf24a 100644 --- a/components/common/selectors/gl-account/gl-account-service.ts +++ b/components/common/selectors/gl-account/gl-account-service.ts @@ -5,17 +5,16 @@ import { oracleKnex } from '@/lib/oracle-db/db' // GL 계정 타입 정의
export interface GlAccount {
SAKNR: string // 계정 (G/L)
- FIPEX: string // 세부계정
TEXT1: string // 계정명
}
// 테스트 환경용 폴백 데이터
const FALLBACK_TEST_DATA: GlAccount[] = [
- { SAKNR: '53351977', FIPEX: 'FIP001', TEXT1: '원재료 구매(테스트데이터 - 오라클 페칭 실패시)' },
- { SAKNR: '53351978', FIPEX: 'FIP002', TEXT1: '소모품 구매(테스트데이터 - 오라클 페칭 실패시)' },
- { SAKNR: '53351979', FIPEX: 'FIP003', TEXT1: '부품 구매(테스트데이터 - 오라클 페칭 실패시)' },
- { SAKNR: '53351980', FIPEX: 'FIP004', TEXT1: '자재 구매(테스트데이터 - 오라클 페칭 실패시)' },
- { SAKNR: '53351981', FIPEX: 'FIP005', TEXT1: '외주 가공비(테스트데이터 - 오라클 페칭 실패시)' },
+ { SAKNR: '53351977', TEXT1: '원재료 구매(테스트데이터 - 오라클 페칭 실패시)' },
+ { SAKNR: '53351978', TEXT1: '소모품 구매(테스트데이터 - 오라클 페칭 실패시)' },
+ { SAKNR: '53351979', TEXT1: '부품 구매(테스트데이터 - 오라클 페칭 실패시)' },
+ { SAKNR: '53351980', TEXT1: '자재 구매(테스트데이터 - 오라클 페칭 실패시)' },
+ { SAKNR: '53351981', TEXT1: '외주 가공비(테스트데이터 - 오라클 페칭 실패시)' },
]
/**
@@ -32,15 +31,10 @@ export async function getGlAccounts(): Promise<{ console.log('📋 [getGlAccounts] Oracle 쿼리 시작...')
const result = await oracleKnex.raw(`
- SELECT
- SAKNR,
- FIPEX,
- TEXT1"
- FROM CMCTB_BGT_MNG_ITM
- WHERE ROWNUM < 100
- AND BUKRS = 'H100'
- ORDER BY SAKNR
+ SELECT SAKNR , TEXT1 FROM CMCTB_BGT_MNG_ITM WHERE ROWNUM < 10
`)
+ // SAKNR -> GL
+ // TEXT1 -> GL명
// Oracle raw query의 결과는 rows 배열에 들어있음
const rows = (result.rows || result) as Array<Record<string, unknown>>
@@ -49,14 +43,12 @@ export async function getGlAccounts(): Promise<{ // null 값 필터링
const cleanedResult = rows
- .filter((item) =>
- item['계정(G/L)'] &&
- item['세부계정']
+ .filter((item) =>
+ item.SAKNR
)
.map((item) => ({
- SAKNR: String(item['계정(G/L)']),
- FIPEX: String(item['세부계정']),
- TEXT1: String(item['계정명'] || '')
+ SAKNR: String(item.SAKNR),
+ TEXT1: String(item.TEXT1 || '')
}))
console.log(`✅ [getGlAccounts] 필터링 후 ${cleanedResult.length}건`)
diff --git a/components/common/selectors/gl-account/gl-account-single-selector.tsx b/components/common/selectors/gl-account/gl-account-single-selector.tsx index 55a58a1f..c3237043 100644 --- a/components/common/selectors/gl-account/gl-account-single-selector.tsx +++ b/components/common/selectors/gl-account/gl-account-single-selector.tsx @@ -77,7 +77,7 @@ export function GlAccountSingleSelector({ const handleCodeSelect = useCallback((code: GlAccount) => { // 이미 선택된 계정을 다시 선택하면 선택 해제 const currentSelected = showConfirmButtons ? tempSelectedCode : selectedCode - if (currentSelected && currentSelected.SAKNR === code.SAKNR && currentSelected.FIPEX === code.FIPEX) { + if (currentSelected && currentSelected.SAKNR === code.SAKNR) { if (showConfirmButtons) { setTempSelectedCode(undefined) } else { @@ -121,13 +121,6 @@ export function GlAccountSingleSelector({ ), }, { - accessorKey: 'FIPEX', - header: '세부계정', - cell: ({ row }) => ( - <div className="font-mono text-sm">{row.getValue('FIPEX')}</div> - ), - }, - { accessorKey: 'TEXT1', header: '계정명', cell: ({ row }) => ( @@ -262,8 +255,7 @@ export function GlAccountSingleSelector({ </div> <div className="flex items-center gap-2 mt-1"> <span className="font-mono text-sm">[{currentSelectedCode.SAKNR}]</span> - <span className="font-mono text-sm">{currentSelectedCode.FIPEX}</span> - <span>- {currentSelectedCode.TEXT1}</span> + <span>{currentSelectedCode.TEXT1}</span> </div> </div> )} @@ -271,7 +263,7 @@ export function GlAccountSingleSelector({ <div className="flex items-center space-x-2"> <Search className="h-4 w-4" /> <Input - placeholder="계정, 세부계정, 계정명으로 검색..." + placeholder="계정, 계정명으로 검색..." value={globalFilter} onChange={(e) => handleSearchChange(e.target.value)} className="flex-1" diff --git a/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx index 90d4975b..84fd85ff 100644 --- a/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx +++ b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx @@ -131,7 +131,7 @@ export function ProcurementItemSelectorDialogSingle({ <Button variant={triggerVariant} size={triggerSize}> {selectedProcurementItem ? ( <span className="truncate"> - {`${selectedProcurementItem.itemCode} - ${selectedProcurementItem.itemName}`} + {`${selectedProcurementItem.itemCode}`} </span> ) : ( <span className="text-muted-foreground">{triggerLabel}</span> diff --git a/components/common/selectors/wbs-code/wbs-code-selector.tsx b/components/common/selectors/wbs-code/wbs-code-selector.tsx index aa5a6a64..5356c723 100644 --- a/components/common/selectors/wbs-code/wbs-code-selector.tsx +++ b/components/common/selectors/wbs-code/wbs-code-selector.tsx @@ -5,10 +5,8 @@ *
* @description
* - 오라클에서 WBS 코드들을 조회
- * - PROJ_NO: 프로젝트 번호
* - WBS_ELMT: WBS 요소
* - WBS_ELMT_NM: WBS 요소명
- * - WBS_LVL: WBS 레벨
*/
import { useState, useCallback, useMemo, useTransition } from 'react'
@@ -49,7 +47,6 @@ export interface WbsCodeSelectorProps { disabled?: boolean
placeholder?: string
className?: string
- projNo?: string // 프로젝트 번호 필터
}
export interface WbsCodeItem {
@@ -66,8 +63,7 @@ export function WbsCodeSelector({ onCodeSelect,
disabled,
placeholder = "WBS 코드를 선택하세요",
- className,
- projNo
+ className
}: WbsCodeSelectorProps) {
const [open, setOpen] = useState(false)
const [codes, setCodes] = useState<WbsCode[]>([])
@@ -81,7 +77,7 @@ export function WbsCodeSelector({ // WBS 코드 선택 핸들러
const handleCodeSelect = useCallback(async (code: WbsCode) => {
// 이미 선택된 코드를 다시 선택하면 선택 해제
- if (selectedCode && selectedCode.PROJ_NO === code.PROJ_NO && selectedCode.WBS_ELMT === code.WBS_ELMT) {
+ if (selectedCode && selectedCode.WBS_ELMT === code.WBS_ELMT) {
onCodeSelect(undefined as any) // 선택 해제를 위해 undefined 전달
setOpen(false)
return
@@ -99,34 +95,20 @@ export function WbsCodeSelector({ // 테이블 컬럼 정의
const columns: ColumnDef<WbsCode>[] = useMemo(() => [
{
- accessorKey: 'PROJ_NO',
- header: '프로젝트 번호',
- cell: ({ row }) => (
- <div className="font-mono text-sm">{row.getValue('PROJ_NO')}</div>
- ),
- },
- {
accessorKey: 'WBS_ELMT',
- header: 'WBS 요소',
+ header: 'WBS_ELMT',
cell: ({ row }) => (
<div className="font-mono text-sm">{row.getValue('WBS_ELMT')}</div>
),
},
{
accessorKey: 'WBS_ELMT_NM',
- header: 'WBS 요소명',
+ header: 'WBS_ELMT_NM',
cell: ({ row }) => (
<div>{row.getValue('WBS_ELMT_NM')}</div>
),
},
{
- accessorKey: 'WBS_LVL',
- header: '레벨',
- cell: ({ row }) => (
- <div className="text-center">{row.getValue('WBS_LVL')}</div>
- ),
- },
- {
id: 'actions',
header: '선택',
cell: ({ row }) => (
@@ -170,7 +152,7 @@ export function WbsCodeSelector({ const loadCodes = useCallback(async () => {
startTransition(async () => {
try {
- const result = await getWbsCodes(projNo)
+ const result = await getWbsCodes()
if (result.success) {
setCodes(result.data)
@@ -192,7 +174,7 @@ export function WbsCodeSelector({ setCodes([])
}
})
- }, [projNo])
+ }, [])
// 다이얼로그 열기 핸들러
const handleDialogOpenChange = useCallback((newOpen: boolean) => {
@@ -217,8 +199,7 @@ export function WbsCodeSelector({ >
{selectedCode ? (
<div className="flex items-center gap-2 w-full">
- <span className="font-mono text-sm">[{selectedCode.PROJ_NO}]</span>
- <span className="font-mono text-sm">{selectedCode.WBS_ELMT}</span>
+ <span className="font-mono text-sm">[{selectedCode.WBS_ELMT}]</span>
<span className="truncate flex-1 text-left">{selectedCode.WBS_ELMT_NM}</span>
<Button
variant="ghost"
@@ -249,7 +230,7 @@ export function WbsCodeSelector({ <div className="flex items-center space-x-2">
<Search className="h-4 w-4" />
<Input
- placeholder="프로젝트 번호, WBS 요소, WBS 요소명으로 검색..."
+ placeholder="WBS 요소, WBS 요소명으로 검색..."
value={globalFilter}
onChange={(e) => handleSearchChange(e.target.value)}
className="flex-1"
@@ -296,7 +277,7 @@ export function WbsCodeSelector({ )}
</TableCell>
))}
- {selectedCode && selectedCode.PROJ_NO === row.original.PROJ_NO && selectedCode.WBS_ELMT === row.original.WBS_ELMT && (
+ {selectedCode && selectedCode.WBS_ELMT === row.original.WBS_ELMT && (
<TableCell className="text-right">
<span className="text-xs text-muted-foreground">(선택됨)</span>
</TableCell>
diff --git a/components/common/selectors/wbs-code/wbs-code-service.ts b/components/common/selectors/wbs-code/wbs-code-service.ts index 7d9c17b1..6ceedcb7 100644 --- a/components/common/selectors/wbs-code/wbs-code-service.ts +++ b/components/common/selectors/wbs-code/wbs-code-service.ts @@ -4,54 +4,35 @@ import { oracleKnex } from '@/lib/oracle-db/db' // WBS 코드 타입 정의
export interface WbsCode {
- PROJ_NO: string // 프로젝트 번호
WBS_ELMT: string // WBS 요소
WBS_ELMT_NM: string // WBS 요소명
- WBS_LVL: string // WBS 레벨
}
// 테스트 환경용 폴백 데이터
const FALLBACK_TEST_DATA: WbsCode[] = [
- { PROJ_NO: 'SN2661', WBS_ELMT: 'WBS001', WBS_ELMT_NM: 'WBS 항목 1(테스트데이터 - 오라클 페칭 실패시)', WBS_LVL: '1' },
- { PROJ_NO: 'SN2661', WBS_ELMT: 'WBS002', WBS_ELMT_NM: 'WBS 항목 2(테스트데이터 - 오라클 페칭 실패시)', WBS_LVL: '2' },
- { PROJ_NO: 'SN2661', WBS_ELMT: 'WBS003', WBS_ELMT_NM: 'WBS 항목 3(테스트데이터 - 오라클 페칭 실패시)', WBS_LVL: '1' },
- { PROJ_NO: 'SN2661', WBS_ELMT: 'WBS004', WBS_ELMT_NM: 'WBS 항목 4(테스트데이터 - 오라클 페칭 실패시)', WBS_LVL: '2' },
- { PROJ_NO: 'SN2661', WBS_ELMT: 'WBS005', WBS_ELMT_NM: 'WBS 항목 5(테스트데이터 - 오라클 페칭 실패시)', WBS_LVL: '3' },
+ { WBS_ELMT: 'WBS001', WBS_ELMT_NM: 'WBS 항목 1' },
+ { WBS_ELMT: 'WBS002', WBS_ELMT_NM: 'WBS 항목 2' },
+ { WBS_ELMT: 'WBS003', WBS_ELMT_NM: 'WBS 항목 3' },
+ { WBS_ELMT: 'WBS004', WBS_ELMT_NM: 'WBS 항목 4' },
+ { WBS_ELMT: 'WBS005', WBS_ELMT_NM: 'WBS 항목 5' },
]
/**
* WBS 코드 목록 조회 (Oracle에서 전체 조회, 실패 시 폴백 데이터 사용)
* CMCTB_PROJ_WBS 테이블에서 조회
- * @param projNo - 프로젝트 번호 (선택적, 없으면 전체 조회)
*/
-export async function getWbsCodes(projNo?: string): Promise<{
+export async function getWbsCodes(): Promise<{
success: boolean
data: WbsCode[]
error?: string
isUsingFallback?: boolean
}> {
try {
- console.log('📋 [getWbsCodes] Oracle 쿼리 시작...', projNo ? `프로젝트: ${projNo}` : '전체')
+ console.log('📋 [getWbsCodes] Oracle 쿼리 시작...')
- let query = `
- SELECT
- PROJ_NO,
- WBS_ELMT,
- WBS_ELMT_NM,
- WBS_LVL
- FROM CMCTB_PROJ_WBS
- WHERE ROWNUM < 100
- `
-
- if (projNo) {
- query += ` AND PROJ_NO = :projNo`
- }
-
- query += ` ORDER BY PROJ_NO, WBS_ELMT`
-
- const result = projNo
- ? await oracleKnex.raw(query, { projNo })
- : await oracleKnex.raw(query)
+ const result = await oracleKnex.raw(`
+ SELECT WBS_ELMT,WBS_ELMT_NM FROM CMCTB_PROJ_WBS WHERE ROWNUM < 30 AND PROJ_NO = 'SN2673'
+ `)
// Oracle raw query의 결과는 rows 배열에 들어있음
const rows = (result.rows || result) as Array<Record<string, unknown>>
@@ -60,16 +41,13 @@ export async function getWbsCodes(projNo?: string): Promise<{ // null 값 필터링
const cleanedResult = rows
- .filter((item) =>
- item.PROJ_NO &&
- item.WBS_ELMT &&
+ .filter((item) =>
+ item.WBS_ELMT &&
item.WBS_ELMT_NM
)
.map((item) => ({
- PROJ_NO: String(item.PROJ_NO),
WBS_ELMT: String(item.WBS_ELMT),
- WBS_ELMT_NM: String(item.WBS_ELMT_NM),
- WBS_LVL: String(item.WBS_LVL || '')
+ WBS_ELMT_NM: String(item.WBS_ELMT_NM)
}))
console.log(`✅ [getWbsCodes] 필터링 후 ${cleanedResult.length}건`)
diff --git a/components/common/selectors/wbs-code/wbs-code-single-selector.tsx b/components/common/selectors/wbs-code/wbs-code-single-selector.tsx index 77a32afe..5c84b8c2 100644 --- a/components/common/selectors/wbs-code/wbs-code-single-selector.tsx +++ b/components/common/selectors/wbs-code/wbs-code-single-selector.tsx @@ -49,7 +49,6 @@ export interface WbsCodeSingleSelectorProps { title?: string description?: string showConfirmButtons?: boolean - projNo?: string // 프로젝트 번호 필터 } export function WbsCodeSingleSelector({ @@ -61,8 +60,7 @@ export function WbsCodeSingleSelector({ onCancel, title = "WBS 코드 선택", description = "WBS 코드를 선택하세요", - showConfirmButtons = false, - projNo + showConfirmButtons = false }: WbsCodeSingleSelectorProps) { const [codes, setCodes] = useState<WbsCode[]>([]) const [sorting, setSorting] = useState<SortingState>([]) @@ -77,7 +75,7 @@ export function WbsCodeSingleSelector({ const handleCodeSelect = useCallback((code: WbsCode) => { // 이미 선택된 코드를 다시 선택하면 선택 해제 const currentSelected = showConfirmButtons ? tempSelectedCode : selectedCode - if (currentSelected && currentSelected.WBS_ELMT === code.WBS_ELMT && currentSelected.PROJ_NO === code.PROJ_NO) { + if (currentSelected && currentSelected.WBS_ELMT === code.WBS_ELMT) { if (showConfirmButtons) { setTempSelectedCode(undefined) } else { @@ -114,40 +112,26 @@ export function WbsCodeSingleSelector({ // 테이블 컬럼 정의 const columns: ColumnDef<WbsCode>[] = useMemo(() => [ { - accessorKey: 'PROJ_NO', - header: '프로젝트 번호', - cell: ({ row }) => ( - <div className="font-mono text-sm">{row.getValue('PROJ_NO')}</div> - ), - }, - { accessorKey: 'WBS_ELMT', - header: 'WBS 요소', + header: 'WBS_ELMT', cell: ({ row }) => ( <div className="font-mono text-sm">{row.getValue('WBS_ELMT')}</div> ), }, { accessorKey: 'WBS_ELMT_NM', - header: 'WBS 요소명', + header: 'WBS_ELMT_NM', cell: ({ row }) => ( <div>{row.getValue('WBS_ELMT_NM')}</div> ), }, { - accessorKey: 'WBS_LVL', - header: '레벨', - cell: ({ row }) => ( - <div className="text-center">{row.getValue('WBS_LVL')}</div> - ), - }, - { id: 'actions', header: '선택', cell: ({ row }) => { const isSelected = showConfirmButtons - ? tempSelectedCode?.WBS_ELMT === row.original.WBS_ELMT && tempSelectedCode?.PROJ_NO === row.original.PROJ_NO - : selectedCode?.WBS_ELMT === row.original.WBS_ELMT && selectedCode?.PROJ_NO === row.original.PROJ_NO + ? tempSelectedCode?.WBS_ELMT === row.original.WBS_ELMT + : selectedCode?.WBS_ELMT === row.original.WBS_ELMT return ( <Button @@ -191,7 +175,7 @@ export function WbsCodeSingleSelector({ const loadCodes = useCallback(async () => { startTransition(async () => { try { - const result = await getWbsCodes(projNo) + const result = await getWbsCodes() if (result.success) { setCodes(result.data) @@ -213,7 +197,7 @@ export function WbsCodeSingleSelector({ setCodes([]) } }) - }, [projNo]) + }, []) // 다이얼로그 열릴 때 코드 로드 (open prop 변화 감지) useEffect(() => { @@ -268,8 +252,7 @@ export function WbsCodeSingleSelector({ </Button> </div> <div className="flex items-center gap-2 mt-1"> - <span className="font-mono text-sm">[{currentSelectedCode.PROJ_NO}]</span> - <span className="font-mono text-sm">{currentSelectedCode.WBS_ELMT}</span> + <span className="font-mono text-sm">[{currentSelectedCode.WBS_ELMT}]</span> <span>{currentSelectedCode.WBS_ELMT_NM}</span> </div> </div> @@ -278,7 +261,7 @@ export function WbsCodeSingleSelector({ <div className="flex items-center space-x-2"> <Search className="h-4 w-4" /> <Input - placeholder="프로젝트 번호, WBS 요소, WBS 요소명으로 검색..." + placeholder="WBS 요소, WBS 요소명으로 검색..." value={globalFilter} onChange={(e) => handleSearchChange(e.target.value)} className="flex-1" @@ -311,8 +294,7 @@ export function WbsCodeSingleSelector({ <TableBody> {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => { - const isRowSelected = currentSelectedCode?.WBS_ELMT === row.original.WBS_ELMT && - currentSelectedCode?.PROJ_NO === row.original.PROJ_NO + const isRowSelected = currentSelectedCode?.WBS_ELMT === row.original.WBS_ELMT return ( <TableRow key={row.id} diff --git a/db/schema/bidding.ts b/db/schema/bidding.ts index 3db9108d..6d968615 100644 --- a/db/schema/bidding.ts +++ b/db/schema/bidding.ts @@ -66,8 +66,6 @@ export const biddingTypeEnum = pgEnum('bidding_type', [ 'construction', // 공사 'service', // 용역 'lease', // 임차 - 'steel_stock', // 형강스톡 - 'piping', // 배관 'transport', // 운송 'waste', // 폐기물 'sale', // 매각 @@ -721,8 +719,6 @@ export const biddingTypeLabels = { construction: '공사', service: '용역', lease: '임차', - steel_stock: '형강스톡', - piping: '배관', transport: '운송', waste: '폐기물', sale: '매각', diff --git a/lib/approval/handlers-registry.ts b/lib/approval/handlers-registry.ts index 5c173565..beb6b971 100644 --- a/lib/approval/handlers-registry.ts +++ b/lib/approval/handlers-registry.ts @@ -68,6 +68,16 @@ export async function initializeApprovalHandlers() { // 입찰초대 핸들러 등록 (결재 승인 후 실행될 함수 requestBiddingInvitationInternal) registerActionHandler('bidding_invitation', requestBiddingInvitationInternal); + // 9. 폐찰 핸들러 + const { requestBiddingClosureInternal } = await import('@/lib/bidding/handlers'); + // 폐찰 핸들러 등록 (결재 승인 후 실행될 함수 requestBiddingClosureInternal) + registerActionHandler('bidding_closure', requestBiddingClosureInternal); + + // 10. 낙찰 핸들러 + const { requestBiddingAwardInternal } = await import('@/lib/bidding/handlers'); + // 낙찰 핸들러 등록 (결재 승인 후 실행될 함수 requestBiddingAwardInternal) + registerActionHandler('bidding_award', requestBiddingAwardInternal); + // ... 추가 핸들러 등록 console.log('[Approval Handlers] All handlers registered successfully'); diff --git a/lib/approval/templates/입찰 결과 업체 선정 품의 요청서.html b/lib/approval/templates/입찰 결과 업체 선정 품의 요청서.html new file mode 100644 index 00000000..50e1ff54 --- /dev/null +++ b/lib/approval/templates/입찰 결과 업체 선정 품의 요청서.html @@ -0,0 +1,788 @@ +<div + style=" + max-width: 1000px; + margin: 0 auto; + font-family: 'Malgun Gothic', 'Segoe UI', sans-serif; + font-size: 14px; + color: #333; + line-height: 1.5; + border: 1px solid #666; /* 전체적인 테두리 추가 */ + " +> + + <!-- 1. 제목 --> + <table + style=" + width: 100%; + border-collapse: collapse; + margin-bottom: 0px; + border-bottom: 2px solid #000; + " + > + <thead> + <tr> + <th + style=" + background-color: #fff; + color: #000; + padding: 15px; + text-align: center; + font-size: 20px; + font-weight: 700; + " + > + 입찰 결과 업체 선정 품의 요청서 ({{제목}}) + </th> + </tr> + <tr> + <td + style=" + padding: 5px 15px; + text-align: right; + font-size: 12px; + color: #666; + border-bottom: 1px solid #ccc; + " + > + *결재 완료 후 낙찰이 반영되며, 협력사로 통보됩니다. + </td> + </tr> + </thead> + </table> + + + + <!-- 2. 입찰 기본 정보 --> + <table + style=" + width: 100%; + border-collapse: collapse; + margin-bottom: 15px; + " + > + <thead> + <tr> + <th + colspan="4" + style=" + background-color: #333; + color: #fff; + padding: 10px; + text-align: left; + font-size: 15px; + font-weight: 600; + border-bottom: 1px solid #666; + " + > + ■ 입찰 기본 정보 + </th> + </tr> + </thead> + <tbody> + <!-- 1행 --> + <tr> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + width: 15%; + border: 1px solid #ccc; + text-align: center; + " + > + 입찰명 + </td> + <td + style=" + padding: 8px 10px; + width: 35%; + border: 1px solid #ccc; + " + > + {{입찰명}} + </td> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + width: 15%; + border: 1px solid #ccc; + text-align: center; + " + > + 입찰번호 + </td> + <td + style=" + padding: 8px 10px; + width: 35%; + border: 1px solid #ccc; + " + > + {{입찰번호}} + </td> + </tr> + <!-- 2행 --> + <tr> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + border: 1px solid #ccc; + text-align: center; + " + > + 낙찰업체수 + </td> + <td + style=" + padding: 8px 10px; + border: 1px solid #ccc; + " + > + {{낙찰업체수}} + </td> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + border: 1px solid #ccc; + text-align: center; + " + > + 계약구분 + </td> + <td + style=" + padding: 8px 10px; + border: 1px solid #ccc; + " + > + {{계약구분}} + </td> + </tr> + <!-- 3행 --> + <tr> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + border: 1px solid #ccc; + text-align: center; + " + > + P/R번호 + </td> + <td + style=" + padding: 8px 10px; + border: 1px solid #ccc; + " + > + {{P/R번호}} + </td> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + border: 1px solid #ccc; + text-align: center; + " + > + 예산 + </td> + <td + style=" + padding: 8px 10px; + border: 1px solid #ccc; + " + > + {{예산}} + </td> + </tr> + <!-- 4행 --> + <tr> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + border: 1px solid #ccc; + text-align: center; + " + > + 내정액 + </td> + <td + style=" + padding: 8px 10px; + border: 1px solid #ccc; + " + > + {{내정액}} + </td> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + border: 1px solid #ccc; + text-align: center; + " + > + 입찰담당자 + </td> + <td + style=" + padding: 8px 10px; + border: 1px solid #ccc; + " + > + {{입찰담당자}} + </td> + </tr> + <!-- 5행: 입찰 개요 --> + <tr> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + border: 1px solid #ccc; + text-align: center; + " + > + 입찰 개요 + </td> + <td + colspan="3" + style=" + padding: 8px 10px; + height: 80px; + border: 1px solid #ccc; + vertical-align: top; + " + > + {{입찰개요}} + </td> + </tr> + </tbody> + </table> + + + + <!-- 3. 업체 선정 결과 --> + <table + style=" + width: 100%; + border-collapse: collapse; + margin-bottom: 15px; + " + > + <thead> + <tr> + <th + colspan="11" + style=" + background-color: #333; + color: #fff; + padding: 10px; + text-align: left; + font-size: 15px; + font-weight: 600; + border-bottom: 1px solid #666; + " + > + ■ 업체 선정 결과 + </th> + </tr> + <tr> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + width: 15%; + border: 1px solid #ccc; + text-align: center; + " + > + 선정 사유 + </td> + <td + colspan="10" + style=" + padding: 8px 10px; + height: 50px; + border: 1px solid #ccc; + vertical-align: top; + " + > + {{업체선정사유}} + </td> + </tr> + <!-- 업체 선정 결과 테이블 헤더 --> + <tr> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 5%; + " + > + 순번 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 10%; + " + > + 협력사 코드 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 15%; + " + > + 협력사명 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 8%; + " + > + 기업규모 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 8%; + " + > + 연동제 희망 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 8%; + " + > + 연동제 적용 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 8%; + " + > + 낙찰 유무 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 12%; + " + > + 확정 금액 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 8%; + " + > + 내정액 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 8%; + " + > + 입찰액 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 10%; + " + > + 입찰액/내정액(%) + </th> + </tr> + </thead> + <tbody> + <!-- 업체 선정 결과 데이터 행 (반복 영역) --> + <tr> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">1</td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{협력사_코드_1}}</td> + <td style="padding: 8px 10px; border: 1px solid #ccc;">{{협력사명_1}}</td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{기업규모_1}}</td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{연동제희망여부_1}}</td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{연동제적용여부_1}}</td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{낙찰유무_1}}</td> + <td style="padding: 8px 10px; text-align: right; border: 1px solid #ccc;">{{확정금액_1}}</td> + <td style="padding: 8px 10px; text-align: right; border: 1px solid #ccc;">{{내정액_1}}</td> + <td style="padding: 8px 10px; text-align: right; border: 1px solid #ccc;">{{입찰액_1}}</td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{비율_1}}</td> + </tr> + <tr> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">2</td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{협력사_코드_2}}</td> + <td style="padding: 8px 10px; border: 1px solid #ccc;">{{협력사명_2}}</td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{기업규모_2}}</td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{연동제희망여부_2}}</td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{연동제적용여부_2}}</td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{낙찰유무_2}}</td> + <td style="padding: 8px 10px; text-align: right; border: 1px solid #ccc;">{{확정금액_2}}</td> + <td style="padding: 8px 10px; text-align: right; border: 1px solid #ccc;">{{내정액_2}}</td> + <td style="padding: 8px 10px; text-align: right; border: 1px solid #ccc;">{{입찰액_2}}</td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{비율_2}}</td> + </tr> + <!-- /업체 선정 결과 데이터 행 --> + </tbody> + </table> + + + + <!-- 4. 자재별 입찰 정보 --> + <table + style=" + width: 100%; + border-collapse: collapse; + margin-bottom: 15px; + " + > + <thead> + <tr> + <th + colspan="13" + style=" + background-color: #333; + color: #fff; + padding: 10px; + text-align: left; + font-size: 15px; + font-weight: 600; + border-bottom: 1px solid #666; + " + > + ■ 품목별 입찰 정보 (총 {{대상_자재_수}} 건 - 결재본문 내 표시 품목은 10건 이하로 제한됩니다) + </th> + </tr> + <tr style="font-size: 12px;"> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 5%; + " + > + 순번 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 10%; + " + > + 자재번호 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 15%; + " + > + 자재내역(품목명) + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 5%; + " + > + 구매단위 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 5%; + " + > + 수량 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 5%; + " + > + 수량단위 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 5%; + " + > + 총중량 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 5%; + " + > + 중량단위 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 5%; + " + > + 통화 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 10%; + " + > + 내정액 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 15%; + " + > + 낙찰 협력사명 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 10%; + " + > + 입찰액 + </th> + </tr> + </thead> + <tbody> + <!-- 자재별 입찰 정보 데이터 행 (반복 영역) --> + <tr style="font-size: 12px;"> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">1</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{자재번호_1}}</td> + <td style="padding: 6px 4px; border: 1px solid #ccc;">{{자재내역_1}}</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{구매단위_1}}</td> + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{수량_1}}</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{수량단위_1}}</td> + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{총중량_1}}</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{중량단위_1}}</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{통화_1}}</td> + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{내정액_1}}</td> + <td style="padding: 6px 4px; border: 1px solid #ccc;">{{협력사명_1}}</td> + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{입찰액_1}}</td> + </tr> + <tr style="font-size: 12px;"> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">2</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{자재번호_2}}</td> + <td style="padding: 6px 4px; border: 1px solid #ccc;">{{자재내역_2}}</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{구매단위_2}}</td> + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{수량_2}}</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{수량단위_2}}</td> + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{총중량_2}}</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{중량단위_2}}</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{통화_2}}</td> + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{내정액_2}}</td> + <td style="padding: 6px 4px; border: 1px solid #ccc;">{{협력사명_2}}</td> + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{입찰액_2}}</td> + </tr> + <!-- /자재별 입찰 정보 데이터 행 --> + </tbody> + </table> + + + + <!-- 5. 연동제 NOTE (템플릿만 - TODO) --> + <table + style=" + width: 100%; + border-collapse: collapse; + margin-bottom: 15px; + " + > + <thead> + <tr> + <th + colspan="3" + style=" + background-color: #333; + color: #fff; + padding: 10px; + text-align: left; + font-size: 15px; + font-weight: 600; + border-bottom: 1px solid #666; + " + > + ■ 연동제 NOTE + </th> + </tr> + <tr> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 33%; + " + > + 업체명 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 33%; + " + > + 연동 합의서 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 34%; + " + > + 미연동 합의서 + </th> + </tr> + </thead> + <tbody> + <!-- 연동제 NOTE 데이터 행 (반복 영역 - 변수 매핑 불필요 - TODO) --> + <tr> + <td style="padding: 8px 10px; border: 1px solid #ccc;">{{업체명_연동제_1}}</td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">[첨부파일 링크 또는 Y/N]</td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">[첨부파일 링크 또는 Y/N]</td> + </tr> + <tr> + <td style="padding: 8px 10px; border: 1px solid #ccc;">{{업체명_연동제_2}}</td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">[첨부파일 링크 또는 Y/N]</td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">[첨부파일 링크 또는 Y/N]</td> + </tr> + <!-- /연동제 NOTE 데이터 행 --> + </tbody> + </table> + +</div> diff --git a/lib/approval/templates/폐찰 품의 요청서.html b/lib/approval/templates/폐찰 품의 요청서.html new file mode 100644 index 00000000..dafda83c --- /dev/null +++ b/lib/approval/templates/폐찰 품의 요청서.html @@ -0,0 +1,581 @@ +<div + style=" + max-width: 1000px; + margin: 0 auto; + font-family: 'Malgun Gothic', 'Segoe UI', sans-serif; + font-size: 14px; + color: #333; + line-height: 1.5; + border: 1px solid #666; /* 전체적인 테두리 추가 */ + " +> + + <!-- 1. 제목 --> + <table + style=" + width: 100%; + border-collapse: collapse; + margin-bottom: 0px; + border-bottom: 2px solid #000; + " + > + <thead> + <tr> + <th + style=" + background-color: #fff; + color: #000; + padding: 15px; + text-align: center; + font-size: 20px; + font-weight: 700; + " + > + 폐찰 품의 요청서 ({{제목}}) + </th> + </tr> + <tr> + <td + style=" + padding: 5px 15px; + text-align: right; + font-size: 12px; + color: #666; + border-bottom: 1px solid #ccc; + " + > + *결재 완료 후 폐찰 처리됩니다. + </td> + </tr> + </thead> + </table> + + + + <!-- 2. 입찰 기본 정보 --> + <table + style=" + width: 100%; + border-collapse: collapse; + margin-bottom: 15px; + " + > + <thead> + <tr> + <th + colspan="4" + style=" + background-color: #333; + color: #fff; + padding: 10px; + text-align: left; + font-size: 15px; + font-weight: 600; + border-bottom: 1px solid #666; + " + > + ■ 입찰 기본 정보 + </th> + </tr> + </thead> + <tbody> + <!-- 1행 --> + <tr> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + width: 15%; + border: 1px solid #ccc; + text-align: center; + " + > + 입찰명 + </td> + <td + style=" + padding: 8px 10px; + width: 35%; + border: 1px solid #ccc; + " + > + {{입찰명}} + </td> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + width: 15%; + border: 1px solid #ccc; + text-align: center; + " + > + 입찰번호 + </td> + <td + style=" + padding: 8px 10px; + width: 35%; + border: 1px solid #ccc; + " + > + {{입찰번호}} + </td> + </tr> + <!-- 2행 --> + <tr> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + border: 1px solid #ccc; + text-align: center; + " + > + 낙찰업체수 + </td> + <td + style=" + padding: 8px 10px; + border: 1px solid #ccc; + " + > + {{낙찰업체수}} + </td> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + border: 1px solid #ccc; + text-align: center; + " + > + 계약구분 + </td> + <td + style=" + padding: 8px 10px; + border: 1px solid #ccc; + " + > + {{계약구분}} + </td> + </tr> + <!-- 3행 --> + <tr> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + border: 1px solid #ccc; + text-align: center; + " + > + 내정가 + </td> + <td + style=" + padding: 8px 10px; + border: 1px solid #ccc; + " + > + {{내정가}} + </td> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + border: 1px solid #ccc; + text-align: center; + " + > + 입찰담당자 + </td> + <td + style=" + padding: 8px 10px; + border: 1px solid #ccc; + " + > + {{입찰담당자}} + </td> + </tr> + <!-- 4행: 입찰 개요 --> + <tr> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + border: 1px solid #ccc; + text-align: center; + " + > + 입찰 개요 + </td> + <td + colspan="3" + style=" + padding: 8px 10px; + height: 80px; + border: 1px solid #ccc; + vertical-align: top; + " + > + {{입찰개요}} + </td> + </tr> + </tbody> + </table> + + + + <!-- 3. 입찰 현황 및 폐찰 결과 --> + <table + style=" + width: 100%; + border-collapse: collapse; + margin-bottom: 15px; + " + > + <thead> + <tr> + <th + colspan="7" + style=" + background-color: #333; + color: #fff; + padding: 10px; + text-align: left; + font-size: 15px; + font-weight: 600; + border-bottom: 1px solid #666; + " + > + ■ 입찰 현황 및 폐찰 결과 + </th> + </tr> + <tr> + <td + style=" + background-color: #f5f5f5; + padding: 8px 10px; + font-weight: 600; + width: 15%; + border: 1px solid #ccc; + text-align: center; + " + > + 폐찰 사유 + </td> + <td + colspan="6" + style=" + padding: 8px 10px; + height: 50px; + border: 1px solid #ccc; + vertical-align: top; + " + > + {{폐찰_사유}} + </td> + </tr> + <!-- 폐찰 결과 테이블 헤더 --> + <tr> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 5%; + " + > + 순번 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 15%; + " + > + 협력사 코드 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 25%; + " + > + 협력사명 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 10%; + " + > + 응찰 유무 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 15%; + " + > + 내정가 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 15%; + " + > + 입찰가 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 8px 10px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 15%; + " + > + 입찰가/내정가(%) + </th> + </tr> + </thead> + <tbody> + <!-- 폐찰 결과 데이터 행 (반복 영역) --> + <tr> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">1</td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{협력사_코드_1}}</td> + <td style="padding: 8px 10px; border: 1px solid #ccc;">{{협력사명_1}}</td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{응찰유무_1}}</td> + <td style="padding: 8px 10px; text-align: right; border: 1px solid #ccc;">{{내정가_1}}</td> + <td style="padding: 8px 10px; text-align: right; border: 1px solid #ccc;">{{입찰가_1}}</td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{비율_1}}</td> + </tr> + <tr> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">2</td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{협력사_코드_2}}</td> + <td style="padding: 8px 10px; border: 1px solid #ccc;">{{협력사명_2}}</td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{응찰유무_2}}</td> + <td style="padding: 8px 10px; text-align: right; border: 1px solid #ccc;">{{내정가_2}}</td> + <td style="padding: 8px 10px; text-align: right; border: 1px solid #ccc;">{{입찰가_2}}</td> + <td style="padding: 8px 10px; text-align: center; border: 1px solid #ccc;">{{비율_2}}</td> + </tr> + <!-- /폐찰 결과 데이터 행 --> + </tbody> + </table> + + + + <!-- 4. 품목별 입찰 정보 --> + <table + style=" + width: 100%; + border-collapse: collapse; + margin-bottom: 15px; + " + > + <thead> + <tr> + <th + colspan="10" + style=" + background-color: #333; + color: #fff; + padding: 10px; + text-align: left; + font-size: 15px; + font-weight: 600; + border-bottom: 1px solid #666; + " + > + ■ 품목별 입찰 정보 + </th> + </tr> + <tr style="font-size: 12px;"> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 5%; + " + > + 순번 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 10%; + " + > + 품목 코드 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 15%; + " + > + 품목명 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 5%; + " + > + 수량 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 5%; + " + > + 단위 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 5%; + " + > + 통화 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 15%; + " + > + 내정가 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 10%; + " + > + 협력사 코드 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 15%; + " + > + 협력사명 + </th> + <th + style=" + background-color: #e8e8e8; + padding: 6px 4px; + text-align: center; + font-weight: 600; + border: 1px solid #ccc; + width: 15%; + " + > + 입찰가 + </th> + </tr> + </thead> + <tbody> + <!-- 품목별 입찰 정보 데이터 행 (반복 영역) --> + <tr style="font-size: 12px;"> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">1</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{품목코드_1}}</td> + <td style="padding: 6px 4px; border: 1px solid #ccc;">{{품목명_1}}</td> + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{수량_1}}</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{단위_1}}</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{통화_1}}</td> + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{내정가_1}}</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{협력사코드_1}}</td> + <td style="padding: 6px 4px; border: 1px solid #ccc;">{{협력사명_1}}</td> + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{입찰가_1}}</td> + </tr> + <tr style="font-size: 12px;"> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">2</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{품목코드_2}}</td> + <td style="padding: 6px 4px; border: 1px solid #ccc;">{{품목명_2}}</td> + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{수량_2}}</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{단위_2}}</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{통화_2}}</td> + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{내정가_2}}</td> + <td style="padding: 6px 4px; text-align: center; border: 1px solid #ccc;">{{협력사코드_2}}</td> + <td style="padding: 6px 4px; border: 1px solid #ccc;">{{협력사명_2}}</td> + <td style="padding: 6px 4px; text-align: right; border: 1px solid #ccc;">{{입찰가_2}}</td> + </tr> + <!-- /품목별 입찰 정보 데이터 행 --> + </tbody> + </table> + +</div> diff --git a/lib/bidding/approval-actions.ts b/lib/bidding/approval-actions.ts index 3a82b08f..6f02e80c 100644 --- a/lib/bidding/approval-actions.ts +++ b/lib/bidding/approval-actions.ts @@ -12,7 +12,7 @@ 'use server'; import { ApprovalSubmissionSaga } from '@/lib/approval'; -import { mapBiddingInvitationToTemplateVariables } from './handlers'; +import { mapBiddingInvitationToTemplateVariables, mapBiddingClosureToTemplateVariables, mapBiddingAwardToTemplateVariables } from './handlers'; import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils'; /** @@ -99,7 +99,7 @@ export async function prepareBiddingApprovalData(data: { materialCode: prItemsForBidding.materialNumber, materialCodeName: prItemsForBidding.materialInfo, quantity: prItemsForBidding.quantity, - purchasingUnit: prItemsForBidding.purchaseUnit, + purchasingUnit: prItemsForBidding.priceUnit, targetUnitPrice: prItemsForBidding.targetUnitPrice, quantityUnit: prItemsForBidding.quantityUnit, totalWeight: prItemsForBidding.totalWeight, @@ -241,3 +241,324 @@ export async function requestBiddingInvitationWithApproval(data: { return result; } + +/** + * 폐찰 결재를 거쳐 입찰 폐찰을 처리하는 서버 액션 + * + * ✅ 사용법 (클라이언트 컴포넌트에서): + * ```typescript + * const result = await requestBiddingClosureWithApproval({ + * biddingId: 123, + * description: "폐찰 사유", + * currentUser: { id: 1, epId: 'EP001', email: 'user@example.com' }, + * approvers: ['EP002', 'EP003'] + * }); + * + * if (result.status === 'pending_approval') { + * toast.success(`폐찰 결재가 상신되었습니다. (ID: ${result.approvalId})`); + * } + * ``` + */ +/** + * 폐찰 결재를 위한 공통 데이터 준비 헬퍼 함수 + */ +export async function prepareBiddingClosureApprovalData(data: { + biddingId: number; + description: string; +}) { + // 1. 입찰 정보 조회 (템플릿 변수용) + debugLog('[BiddingClosureApproval] 입찰 정보 조회 시작'); + const { default: db } = await import('@/db/db'); + const { biddings } = await import('@/db/schema'); + const { eq } = await import('drizzle-orm'); + + const biddingInfo = await db + .select({ + id: biddings.id, + title: biddings.title, + }) + .from(biddings) + .where(eq(biddings.id, data.biddingId)) + .limit(1); + + if (biddingInfo.length === 0) { + debugError('[BiddingClosureApproval] 입찰 정보를 찾을 수 없음'); + throw new Error('입찰 정보를 찾을 수 없습니다'); + } + + debugLog('[BiddingClosureApproval] 입찰 정보 조회 완료', { + biddingId: data.biddingId, + title: biddingInfo[0].title, + }); + + // 2. 템플릿 변수 매핑 + debugLog('[BiddingClosureApproval] 템플릿 변수 매핑 시작'); + const requestedAt = new Date(); + const { mapBiddingClosureToTemplateVariables } = await import('./handlers'); + const variables = await mapBiddingClosureToTemplateVariables({ + biddingId: data.biddingId, + description: data.description, + requestedAt, + }); + debugLog('[BiddingClosureApproval] 템플릿 변수 매핑 완료', { + variableKeys: Object.keys(variables), + }); + + return { + bidding: biddingInfo[0], + variables, + }; +} + +export async function requestBiddingClosureWithApproval(data: { + biddingId: number; + description: string; + files?: File[]; + currentUser: { id: number; epId: string | null; email?: string }; + approvers?: string[]; // Knox EP ID 배열 (결재선) +}) { + debugLog('[BiddingClosureApproval] 폐찰 결재 서버 액션 시작', { + biddingId: data.biddingId, + description: data.description, + userId: data.currentUser.id, + hasEpId: !!data.currentUser.epId, + }); + + // 1. 입력 검증 + if (!data.currentUser.epId) { + debugError('[BiddingClosureApproval] Knox EP ID 없음'); + throw new Error('Knox EP ID가 필요합니다'); + } + + if (!data.description.trim()) { + debugError('[BiddingClosureApproval] 폐찰 사유 없음'); + throw new Error('폐찰 사유를 입력해주세요'); + } + // 유찰상태인지 확인 + const { bidding } = await db + .select() + .from(biddings) + .where(eq(biddings.id, data.biddingId)) + .limit(1); + + if (bidding.status !== 'bidding_disposal') { + debugError('[BiddingClosureApproval] 유찰 상태가 아닙니다.'); + throw new Error('유찰 상태인 입찰만 폐찰할 수 있습니다.'); + } + + // 2. 입찰 상태를 결재 진행중으로 변경 + debugLog('[BiddingClosureApproval] 입찰 상태 변경 시작'); + const { default: db } = await import('@/db/db'); + const { biddings } = await import('@/db/schema'); + const { eq } = await import('drizzle-orm'); + + await db + .update(biddings) + .set({ + status: 'closure_pending', // 폐찰 결재 진행중 상태 + updatedBy: data.currentUser.epId, + updatedAt: new Date() + }) + .where(eq(biddings.id, data.biddingId)); + + debugLog('[BiddingClosureApproval] 입찰 상태 변경 완료', { + biddingId: data.biddingId, + newStatus: 'closure_pending' + }); + + // 3. 결재 데이터 준비 + const { bidding: approvalBidding, variables } = await prepareBiddingClosureApprovalData({ + biddingId: data.biddingId, + description: data.description, + }); + + // 4. 결재 워크플로우 시작 (Saga 패턴) + debugLog('[BiddingClosureApproval] ApprovalSubmissionSaga 생성'); + const saga = new ApprovalSubmissionSaga( + // actionType: 핸들러를 찾을 때 사용할 키 + 'bidding_closure', + + // actionPayload: 결재 승인 후 핸들러에 전달될 데이터 (최소 데이터만) + { + biddingId: data.biddingId, + description: data.description, + files: data.files, + currentUserId: data.currentUser.id, // ✅ 결재 승인 후 핸들러 실행 시 필요 + }, + + // approvalConfig: 결재 상신 정보 (템플릿 포함) + { + title: `폐찰 - ${approvalBidding.title}`, + description: `${approvalBidding.title} 입찰 폐찰 결재`, + templateName: '폐찰 품의 요청서', // 한국어 템플릿명 + variables, // 치환할 변수들 + approvers: data.approvers, + currentUser: data.currentUser, + } + ); + + debugLog('[BiddingClosureApproval] Saga 실행 시작'); + const result = await saga.execute(); + + debugSuccess('[BiddingClosureApproval] 폐찰 결재 워크플로우 완료', { + approvalId: result.approvalId, + pendingActionId: result.pendingActionId, + status: result.status, + }); + + return result; +} + +/** + * 낙찰 결재를 거쳐 입찰 낙찰을 처리하는 서버 액션 + * + * ✅ 사용법 (클라이언트 컴포넌트에서): + * ```typescript + * const result = await requestBiddingAwardWithApproval({ + * biddingId: 123, + * selectionReason: "낙찰 사유", + * currentUser: { id: 1, epId: 'EP001', email: 'user@example.com' }, + * approvers: ['EP002', 'EP003'] + * }); + * + * if (result.status === 'pending_approval') { + * toast.success(`낙찰 결재가 상신되었습니다. (ID: ${result.approvalId})`); + * } + * ``` + */ +/** + * 낙찰 결재를 위한 공통 데이터 준비 헬퍼 함수 + */ +export async function prepareBiddingAwardApprovalData(data: { + biddingId: number; + selectionReason: string; +}) { + // 1. 입찰 정보 조회 (템플릿 변수용) + debugLog('[BiddingAwardApproval] 입찰 정보 조회 시작'); + const { default: db } = await import('@/db/db'); + const { biddings } = await import('@/db/schema'); + const { eq } = await import('drizzle-orm'); + + const biddingInfo = await db + .select({ + id: biddings.id, + title: biddings.title, + }) + .from(biddings) + .where(eq(biddings.id, data.biddingId)) + .limit(1); + + if (biddingInfo.length === 0) { + debugError('[BiddingAwardApproval] 입찰 정보를 찾을 수 없음'); + throw new Error('입찰 정보를 찾을 수 없습니다'); + } + + debugLog('[BiddingAwardApproval] 입찰 정보 조회 완료', { + biddingId: data.biddingId, + title: biddingInfo[0].title, + }); + + // 2. 템플릿 변수 매핑 + debugLog('[BiddingAwardApproval] 템플릿 변수 매핑 시작'); + const requestedAt = new Date(); + const { mapBiddingAwardToTemplateVariables } = await import('./handlers'); + const variables = await mapBiddingAwardToTemplateVariables({ + biddingId: data.biddingId, + selectionReason: data.selectionReason, + requestedAt, + }); + debugLog('[BiddingAwardApproval] 템플릿 변수 매핑 완료', { + variableKeys: Object.keys(variables), + }); + + return { + bidding: biddingInfo[0], + variables, + }; +} + +export async function requestBiddingAwardWithApproval(data: { + biddingId: number; + selectionReason: string; + currentUser: { id: number; epId: string | null; email?: string }; + approvers?: string[]; // Knox EP ID 배열 (결재선) +}) { + debugLog('[BiddingAwardApproval] 낙찰 결재 서버 액션 시작', { + biddingId: data.biddingId, + selectionReason: data.selectionReason, + userId: data.currentUser.id, + hasEpId: !!data.currentUser.epId, + }); + + // 1. 입력 검증 + if (!data.currentUser.epId) { + debugError('[BiddingAwardApproval] Knox EP ID 없음'); + throw new Error('Knox EP ID가 필요합니다'); + } + + if (!data.selectionReason.trim()) { + debugError('[BiddingAwardApproval] 낙찰 사유 없음'); + throw new Error('낙찰 사유를 입력해주세요'); + } + + // 2. 입찰 상태를 결재 진행중으로 변경 + debugLog('[BiddingAwardApproval] 입찰 상태 변경 시작'); + const { default: db } = await import('@/db/db'); + const { biddings } = await import('@/db/schema'); + const { eq } = await import('drizzle-orm'); + + await db + .update(biddings) + .set({ + status: 'award_pending', // 낙찰 결재 진행중 상태 + updatedBy: data.currentUser.epId, + updatedAt: new Date() + }) + .where(eq(biddings.id, data.biddingId)); + + debugLog('[BiddingAwardApproval] 입찰 상태 변경 완료', { + biddingId: data.biddingId, + newStatus: 'award_pending' + }); + + // 3. 결재 데이터 준비 + const { bidding, variables } = await prepareBiddingAwardApprovalData({ + biddingId: data.biddingId, + selectionReason: data.selectionReason, + }); + + // 4. 결재 워크플로우 시작 (Saga 패턴) + debugLog('[BiddingAwardApproval] ApprovalSubmissionSaga 생성'); + const saga = new ApprovalSubmissionSaga( + // actionType: 핸들러를 찾을 때 사용할 키 + 'bidding_award', + + // actionPayload: 결재 승인 후 핸들러에 전달될 데이터 (최소 데이터만) + { + biddingId: data.biddingId, + selectionReason: data.selectionReason, + currentUserId: data.currentUser.id, // ✅ 결재 승인 후 핸들러 실행 시 필요 + }, + + // approvalConfig: 결재 상신 정보 (템플릿 포함) + { + title: `낙찰 - ${bidding.title}`, + description: `${bidding.title} 입찰 낙찰 결재`, + templateName: '입찰 결과 업체 선정 품의 요청서', // 한국어 템플릿명 + variables, // 치환할 변수들 + approvers: data.approvers, + currentUser: data.currentUser, + } + ); + + debugLog('[BiddingAwardApproval] Saga 실행 시작'); + const result = await saga.execute(); + + debugSuccess('[BiddingAwardApproval] 낙찰 결재 워크플로우 완료', { + approvalId: result.approvalId, + pendingActionId: result.pendingActionId, + status: result.status, + }); + + return result; +} 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} + /> + )} </> ) } diff --git a/lib/bidding/failure/biddings-closure-dialog.tsx b/lib/bidding/failure/biddings-closure-dialog.tsx index 64aba42f..93ba0eda 100644 --- a/lib/bidding/failure/biddings-closure-dialog.tsx +++ b/lib/bidding/failure/biddings-closure-dialog.tsx @@ -2,8 +2,9 @@ "use client" import { useState } from "react" +import { useSession } from "next-auth/react" import { toast } from "sonner" -import { bidClosureAction } from "@/lib/bidding/actions" +import { requestBiddingClosureWithApproval } from "@/lib/bidding/approval-actions" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" @@ -19,17 +20,29 @@ interface BiddingsClosureDialogProps { title: string; biddingNumber: string; } | null; - userId: string; onSuccess?: () => void; + onApprovalPreview?: (data: { + templateName: string + variables: Record<string, string> + title: string + description: string + files?: File[] + }) => void } + +interface ClosureFormData { + description: string; + files: File[]; +} export function BiddingsClosureDialog({ open, onOpenChange, bidding, - userId, - onSuccess + onSuccess, + onApprovalPreview }: BiddingsClosureDialogProps) { + const { data: session } = useSession() const [description, setDescription] = useState('') const [files, setFiles] = useState<File[]>([]) const [isSubmitting, setIsSubmitting] = useState(false) @@ -42,36 +55,44 @@ interface BiddingsClosureDialogProps { return } - setIsSubmitting(true) - + // 결재 템플릿 변수 준비 + const { mapBiddingClosureToTemplateVariables } = await import('@/lib/bidding/handlers') + try { - const result = await bidClosureAction(bidding.id, { + const variables = await mapBiddingClosureToTemplateVariables({ + biddingId: bidding.id, description: description.trim(), - files - }, userId) - - if (result.success) { - toast.success(result.message) - onOpenChange(false) - onSuccess?.() - // 페이지 새로고침 또는 상태 업데이트 - window.location.reload() - } else { - toast.error(result.error || '폐찰 처리 중 오류가 발생했습니다.') + requestedAt: new Date() + }) + + // 상위 컴포넌트로 결재 미리보기 데이터 전달 + if (onApprovalPreview) { + onApprovalPreview({ + templateName: '폐찰 품의 요청서', + variables, + title: `폐찰 - ${bidding.title}`, + description: description.trim(), + files + }) } + + onOpenChange(false) + // 폼 초기화 + setDescription('') + setFiles([]) } catch (error) { - toast.error('폐찰 처리 중 오류가 발생했습니다.') - } finally { - setIsSubmitting(false) + console.error('폐찰 템플릿 변수 준비 실패:', error) + toast.error('결재 문서 준비 중 오류가 발생했습니다.') } } - + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { if (e.target.files) { setFiles(Array.from(e.target.files)) } } - + + if (!bidding) return null return ( @@ -99,7 +120,7 @@ interface BiddingsClosureDialogProps { required /> </div> - + <div className="space-y-2"> <Label htmlFor="files">첨부파일</Label> <Input @@ -116,7 +137,7 @@ interface BiddingsClosureDialogProps { </div> )} </div> - + <div className="flex justify-end gap-2 pt-4"> <Button type="button" @@ -131,12 +152,12 @@ interface BiddingsClosureDialogProps { variant="destructive" disabled={isSubmitting || !description.trim()} > - {isSubmitting ? '처리 중...' : '폐찰하기'} + {isSubmitting ? '상신 중...' : '결재 상신'} </Button> </div> </form> </DialogContent> </Dialog> - ) - } + </> + )
\ No newline at end of file diff --git a/lib/bidding/failure/biddings-failure-table.tsx b/lib/bidding/failure/biddings-failure-table.tsx index 43020322..a0f98466 100644 --- a/lib/bidding/failure/biddings-failure-table.tsx +++ b/lib/bidding/failure/biddings-failure-table.tsx @@ -24,6 +24,8 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D import { FileX, RefreshCw, Undo2 } from "lucide-react"
import { bidClosureAction, cancelDisposalAction } from "@/lib/bidding/actions"
import { increaseRoundOrRebid } from "@/lib/bidding/service"
+import { ApprovalPreviewDialog } from "@/lib/approval/approval-preview-dialog"
+import { requestBiddingClosureWithApproval } from "@/lib/bidding/approval-actions"
import { useToast } from "@/hooks/use-toast"
type BiddingFailureItem = {
@@ -88,6 +90,14 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) { const [selectedBidding, setSelectedBidding] = React.useState<BiddingFailureItem | null>(null)
const [isRebidDialogOpen, setIsRebidDialogOpen] = React.useState(false)
const [selectedBiddingForRebid, setSelectedBiddingForRebid] = React.useState<BiddingFailureItem | null>(null)
+ const [approvalPreviewData, setApprovalPreviewData] = React.useState<{
+ templateName: string
+ variables: Record<string, string>
+ title: string
+ description: string
+ files?: File[]
+ } | null>(null)
+ const [isApprovalPreviewDialogOpen, setIsApprovalPreviewDialogOpen] = React.useState(false)
const { toast } = useToast()
const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingFailureItem> | null>(null)
@@ -424,11 +434,14 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) { open={biddingClosureDialogOpen}
onOpenChange={handleBiddingClosureDialogClose}
bidding={selectedBidding}
- userId={session.user.id}
onSuccess={() => {
router.refresh()
handleBiddingClosureDialogClose()
}}
+ onApprovalPreview={(data) => {
+ setApprovalPreviewData(data)
+ setIsApprovalPreviewDialogOpen(true)
+ }}
/>
)}
@@ -465,6 +478,72 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) { </DialogFooter>
</DialogContent>
</Dialog>
+
+ {/* 폐찰 결재 미리보기 다이얼로그 */}
+ {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={handleClosureApprovalConfirm}
+ />
+ )}
</>
)
+
+ // 폐찰 결재 상신 핸들러
+ const handleClosureApprovalConfirm = async (data: { approvers: string[]; title: string; attachments?: File[] }) => {
+ if (!session?.user?.id || !approvalPreviewData || !selectedBidding) return
+
+ try {
+ const result = await requestBiddingClosureWithApproval({
+ biddingId: selectedBidding.id,
+ description: approvalPreviewData.description,
+ files: approvalPreviewData.files,
+ 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)
+ handleBiddingClosureDialogClose()
+ router.refresh()
+ } else {
+ toast({
+ title: '오류',
+ description: '폐찰 결재 상신 중 오류가 발생했습니다.',
+ variant: 'destructive',
+ })
+ }
+ } catch (error) {
+ console.error('폐찰 결재 상신 실패:', error)
+ toast({
+ title: '오류',
+ description: '폐찰 결재 상신 중 오류가 발생했습니다.',
+ variant: 'destructive',
+ })
+ }
+ }
}
diff --git a/lib/bidding/handlers.ts b/lib/bidding/handlers.ts index fc2951d4..d55107c0 100644 --- a/lib/bidding/handlers.ts +++ b/lib/bidding/handlers.ts @@ -281,3 +281,432 @@ export async function mapBiddingInvitationToTemplateVariables(payload: { ...materialVariables, }; } + +/** + * 폐찰 데이터를 결재 템플릿 변수로 매핑 + * + * @param payload - 폐찰 데이터 + * @returns 템플릿 변수 객체 (Record<string, string>) + */ +export async function mapBiddingClosureToTemplateVariables(payload: { + biddingId: number; + description: string; + requestedAt: Date; +}): Promise<Record<string, string>> { + const { biddingId, description, requestedAt } = payload; + + // 1. 입찰 정보 조회 + debugLog('[BiddingClosureMapper] 입찰 정보 조회 시작'); + const { default: db } = await import('@/db/db'); + const { biddings, prItemsForBidding, biddingCompanies, biddingVendorSubmissions } = await import('@/db/schema'); + const { eq, leftJoin } = await import('drizzle-orm'); + + const biddingInfo = await db + .select({ + id: biddings.id, + title: biddings.title, + biddingNumber: biddings.biddingNumber, + projectName: biddings.projectName, + itemName: biddings.itemName, + biddingType: biddings.biddingType, + bidPicName: biddings.bidPicName, + supplyPicName: biddings.supplyPicName, + targetPrice: biddings.targetPrice, + winnerCount: biddings.winnerCount, + }) + .from(biddings) + .where(eq(biddings.id, biddingId)) + .limit(1); + + if (biddingInfo.length === 0) { + debugError('[BiddingClosureMapper] 입찰 정보를 찾을 수 없음'); + throw new Error('입찰 정보를 찾을 수 없습니다'); + } + + const bidding = biddingInfo[0]; + + // 2. 입찰 대상 자재 정보 조회 + const biddingItemsInfo = await db + .select({ + id: prItemsForBidding.id, + materialCode: prItemsForBidding.materialNumber, + materialCodeName: prItemsForBidding.materialInfo, + quantity: prItemsForBidding.quantity, + quantityUnit: prItemsForBidding.quantityUnit, + targetUnitPrice: prItemsForBidding.targetUnitPrice, + currency: prItemsForBidding.targetCurrency, + }) + .from(prItemsForBidding) + .where(eq(prItemsForBidding.biddingId, biddingId)); + + // 3. 입찰 참여 업체 및 제출 정보 조회 + const vendorSubmissions = await db + .select({ + vendorId: biddingCompanies.vendorId, + vendorName: biddingCompanies.vendorName, + vendorCode: biddingCompanies.vendorCode, + targetPrice: biddingVendorSubmissions.targetPrice, + bidPrice: biddingVendorSubmissions.bidPrice, + submitted: biddingVendorSubmissions.submitted, + }) + .from(biddingCompanies) + .leftJoin(biddingVendorSubmissions, eq(biddingCompanies.id, biddingVendorSubmissions.biddingCompanyId)) + .where(eq(biddingCompanies.biddingId, biddingId)); + + debugLog('[BiddingClosureMapper] 입찰 정보 조회 완료', { + biddingId, + itemCount: biddingItemsInfo.length, + vendorCount: vendorSubmissions.length, + }); + + // 기본 정보 매핑 + const title = bidding.title || '폐찰'; + const biddingTitle = bidding.title || ''; + const biddingNumber = bidding.biddingNumber || ''; + const winnerCount = (bidding.winnerCount || 1).toString(); + const contractType = bidding.biddingType || ''; + const targetPrice = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : ''; + const biddingManager = bidding.bidPicName || bidding.supplyPicName || ''; + const biddingOverview = bidding.itemName || ''; + + // 폐찰 사유 + const closureReason = description; + + // 협력사별 입찰 현황 매핑 + const vendorVariables: Record<string, string> = {}; + vendorSubmissions.forEach((vendor, index) => { + const num = index + 1; + vendorVariables[`협력사_코드_${num}`] = vendor.vendorCode || ''; + vendorVariables[`협력사명_${num}`] = vendor.vendorName || ''; + vendorVariables[`응찰유무_${num}`] = vendor.submitted ? '응찰' : '미응찰'; + vendorVariables[`내정가_${num}`] = vendor.targetPrice ? vendor.targetPrice.toLocaleString() : ''; + vendorVariables[`입찰가_${num}`] = vendor.bidPrice ? vendor.bidPrice.toLocaleString() : ''; + vendorVariables[`비율_${num}`] = (vendor.targetPrice && vendor.bidPrice && vendor.targetPrice > 0) + ? ((vendor.bidPrice / vendor.targetPrice) * 100).toFixed(2) + '%' + : ''; + }); + + // 품목별 입찰 정보 매핑 (간소화 - 첫 번째 품목 기준으로 매핑) + const materialVariables: Record<string, string> = {}; + biddingItemsInfo.forEach((item, index) => { + const num = index + 1; + materialVariables[`품목코드_${num}`] = item.materialCode || ''; + materialVariables[`품목명_${num}`] = item.materialCodeName || ''; + materialVariables[`수량_${num}`] = item.quantity ? item.quantity.toLocaleString() : ''; + materialVariables[`단위_${num}`] = item.quantityUnit || ''; + materialVariables[`통화_${num}`] = item.currency || ''; + materialVariables[`내정가_${num}`] = item.targetUnitPrice ? item.targetUnitPrice.toLocaleString() : ''; + + // 각 품목에 대한 협력사별 입찰가 (간소화: 동일 품목에 대한 모든 업체 입찰가 표시) + vendorSubmissions.forEach((vendor, vendorIndex) => { + const vendorNum = vendorIndex + 1; + materialVariables[`협력사코드_${num}`] = vendor.vendorCode || ''; + materialVariables[`협력사명_${num}`] = vendor.vendorName || ''; + materialVariables[`입찰가_${num}`] = vendor.bidPrice ? vendor.bidPrice.toLocaleString() : ''; + }); + }); + + return { + 제목: title, + 입찰명: biddingTitle, + 입찰번호: biddingNumber, + 낙찰업체수: winnerCount, + 계약구분: contractType, + 내정가: targetPrice, + 입찰담당자: biddingManager, + 입찰개요: biddingOverview, + 폐찰_사유: closureReason, + ...vendorVariables, + ...materialVariables, + }; +} + +/** + * 폐찰 핸들러 (결재 승인 후 실행됨) + * + * ✅ Internal 함수: 결재 워크플로우에서 자동 호출됨 (직접 호출 금지) + * + * @param payload - withApproval()에서 전달한 actionPayload (최소 데이터만) + */ +export async function requestBiddingClosureInternal(payload: { + biddingId: number; + description: string; + files?: File[]; + currentUserId: number; // ✅ 결재 상신한 사용자 ID +}) { + debugLog('[BiddingClosureHandler] 폐찰 핸들러 시작', { + biddingId: payload.biddingId, + description: payload.description, + currentUserId: payload.currentUserId, + }); + + // ✅ userId 검증: 핸들러에서 userId가 없으면 잘못된 상황 (예외 처리) + if (!payload.currentUserId || payload.currentUserId <= 0) { + const errorMessage = 'currentUserId가 없습니다. actionPayload에 currentUserId가 포함되지 않았습니다.'; + debugError('[BiddingClosureHandler]', errorMessage); + throw new Error(errorMessage); + } + + try { + // 1. 입찰 상태를 폐찰로 변경 + const { default: db } = await import('@/db/db'); + const { biddings } = await import('@/db/schema'); + const { eq } = await import('drizzle-orm'); + + await db + .update(biddings) + .set({ + status: 'closed', + updatedBy: payload.currentUserId.toString(), + updatedAt: new Date(), + remarks: payload.description, // 폐찰 사유를 remarks에 저장 + }) + .where(eq(biddings.id, payload.biddingId)); + + debugSuccess('[BiddingClosureHandler] 폐찰 완료', { + biddingId: payload.biddingId, + description: payload.description, + }); + + // 4. 첨부파일들 저장 (evaluation_doc로 저장) + if (payload.files && payload.files.length > 0) { + const { saveFile } = await import('@/lib/file-stroage'); + const { biddingDocuments } = await import('@/db/schema'); + + for (const file of payload.files) { + try { + const saveResult = await saveFile({ + file, + directory: `biddings/${payload.biddingId}/closure-documents`, + originalName: file.name, + userId: payload.currentUserId.toString() + }) + + if (saveResult.success) { + await db.insert(biddingDocuments).values({ + biddingId: payload.biddingId, + documentType: 'evaluation_doc', + fileName: saveResult.fileName!, + originalFileName: saveResult.originalName!, + fileSize: saveResult.fileSize!, + mimeType: file.type, + filePath: saveResult.publicPath!, + title: `폐찰 문서 - ${file.name}`, + description: payload.description, + isPublic: false, + isRequired: false, + uploadedBy: payload.currentUserId.toString(), + }) + } else { + console.error(`Failed to save closure file: ${file.name}`, saveResult.error) + } + } catch (error) { + console.error(`Error saving closure file: ${file.name}`, error) + } + } + } + + + return { + success: true, + biddingId: payload.biddingId, + message: `입찰이 폐찰 처리되었습니다.`, + }; + } catch (error) { + debugError('[BiddingClosureHandler] 폐찰 중 에러', error); + throw error; + } +} + +/** + * 낙찰 핸들러 (결재 승인 후 실행됨) + * + * ✅ Internal 함수: 결재 워크플로우에서 자동 호출됨 (직접 호출 금지) + * + * @param payload - withApproval()에서 전달한 actionPayload (최소 데이터만) + */ +export async function requestBiddingAwardInternal(payload: { + biddingId: number; + selectionReason: string; + currentUserId: number; // ✅ 결재 상신한 사용자 ID +}) { + debugLog('[BiddingAwardHandler] 낙찰 핸들러 시작', { + biddingId: payload.biddingId, + selectionReason: payload.selectionReason, + currentUserId: payload.currentUserId, + }); + + // ✅ userId 검증: 핸들러에서 userId가 없으면 잘못된 상황 (예외 처리) + if (!payload.currentUserId || payload.currentUserId <= 0) { + const errorMessage = 'currentUserId가 없습니다. actionPayload에 currentUserId가 포함되지 않았습니다.'; + debugError('[BiddingAwardHandler]', errorMessage); + throw new Error(errorMessage); + } + + try { + // 기존 awardBidding 함수 로직을 재구성하여 실행 + const { awardBidding } = await import('@/lib/bidding/detail/service'); + + const result = await awardBidding(payload.biddingId, payload.selectionReason, payload.currentUserId.toString()); + + if (!result.success) { + debugError('[BiddingAwardHandler] 낙찰 처리 실패', result.error); + throw new Error(result.error || '낙찰 처리에 실패했습니다.'); + } + + debugSuccess('[BiddingAwardHandler] 낙찰 완료', { + biddingId: payload.biddingId, + selectionReason: payload.selectionReason, + }); + + return { + success: true, + biddingId: payload.biddingId, + message: `입찰이 낙찰 처리되었습니다.`, + }; + } catch (error) { + debugError('[BiddingAwardHandler] 낙찰 중 에러', error); + throw error; + } +} + +/** + * 낙찰 데이터를 결재 템플릿 변수로 매핑 + * + * @param payload - 낙찰 데이터 + * @returns 템플릿 변수 객체 (Record<string, string>) + */ +export async function mapBiddingAwardToTemplateVariables(payload: { + biddingId: number; + selectionReason: string; + requestedAt: Date; +}): Promise<Record<string, string>> { + const { biddingId, selectionReason, requestedAt } = payload; + + // 1. 입찰 정보 조회 + debugLog('[BiddingAwardMapper] 입찰 정보 조회 시작'); + const { default: db } = await import('@/db/db'); + const { biddings, prItemsForBidding } = await import('@/db/schema'); + const { eq } = await import('drizzle-orm'); + + const biddingInfo = await db + .select({ + id: biddings.id, + title: biddings.title, + biddingNumber: biddings.biddingNumber, + projectName: biddings.projectName, + itemName: biddings.itemName, + biddingType: biddings.biddingType, + bidPicName: biddings.bidPicName, + supplyPicName: biddings.supplyPicName, + targetPrice: biddings.targetPrice, + winnerCount: biddings.winnerCount, + }) + .from(biddings) + .where(eq(biddings.id, biddingId)) + .limit(1); + + if (biddingInfo.length === 0) { + debugError('[BiddingAwardMapper] 입찰 정보를 찾을 수 없음'); + throw new Error('입찰 정보를 찾을 수 없습니다'); + } + + const bidding = biddingInfo[0]; + + // 2. 낙찰된 업체 정보 조회 + const { getAwardedCompanies } = await import('@/lib/bidding/detail/service'); + const awardedCompanies = await getAwardedCompanies(biddingId); + + // 3. 입찰 대상 자재 정보 조회 + const biddingItemsInfo = await db + .select({ + id: prItemsForBidding.id, + materialNumber: prItemsForBidding.materialNumber, + materialInfo: prItemsForBidding.materialInfo, + priceUnit: prItemsForBidding.priceUnit, + quantity: prItemsForBidding.quantity, + quantityUnit: prItemsForBidding.quantityUnit, + totalWeight: prItemsForBidding.totalWeight, + weightUnit: prItemsForBidding.weightUnit, + targetUnitPrice: prItemsForBidding.targetUnitPrice, + currency: prItemsForBidding.targetCurrency, + }) + .from(prItemsForBidding) + .where(eq(prItemsForBidding.biddingId, biddingId)); + + debugLog('[BiddingAwardMapper] 입찰 정보 조회 완료', { + biddingId, + itemCount: biddingItemsInfo.length, + awardedCompanyCount: awardedCompanies.length, + }); + + // 기본 정보 매핑 + const title = bidding.title || '낙찰'; + const biddingTitle = bidding.title || ''; + const biddingNumber = bidding.biddingNumber || ''; + const winnerCount = (bidding.winnerCount || 1).toString(); + const contractType = bidding.biddingType || ''; + const budget = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : ''; + const targetPrice = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : ''; + const biddingManager = bidding.bidPicName || bidding.supplyPicName || ''; + const biddingOverview = bidding.itemName || ''; + + // 업체 선정 사유 + const selectionReasonMapped = selectionReason; + + // 낙찰된 업체 정보 매핑 + const vendorVariables: Record<string, string> = {}; + awardedCompanies.forEach((company, index) => { + const num = index + 1; + vendorVariables[`협력사_코드_${num}`] = company.vendorCode || ''; + vendorVariables[`협력사명_${num}`] = company.companyName || ''; + vendorVariables[`기업규모_${num}`] = company.companySize || ''; // TODO: 기업규모 정보가 없으므로 빈 값 + vendorVariables[`연동제희망여부_${num}`] = 'N'; // TODO: 연동제 정보 미개발 + vendorVariables[`연동제적용여부_${num}`] = 'N'; // TODO: 연동제 정보 미개발 + vendorVariables[`낙찰유무_${num}`] = '낙찰'; + vendorVariables[`확정금액_${num}`] = (company.finalQuoteAmount * company.awardRatio / 100).toLocaleString(); + vendorVariables[`내정액_${num}`] = company.targetPrice ? company.targetPrice.toLocaleString() : ''; + vendorVariables[`입찰액_${num}`] = company.finalQuoteAmount.toLocaleString(); + vendorVariables[`비율_${num}`] = company.targetPrice && company.targetPrice > 0 + ? ((company.finalQuoteAmount / company.targetPrice) * 100).toFixed(2) + '%' + : ''; + }); + + // 품목별 입찰 정보 매핑 + const materialVariables: Record<string, string> = {}; + biddingItemsInfo.forEach((item, index) => { + const num = index + 1; + materialVariables[`자재번호_${num}`] = item.materialNumber || ''; + materialVariables[`자재내역_${num}`] = item.materialInfo || ''; + materialVariables[`구매단위_${num}`] = item.priceUnit || ''; + materialVariables[`수량_${num}`] = item.quantity ? item.quantity.toLocaleString() : ''; + materialVariables[`수량단위_${num}`] = item.quantityUnit || ''; + materialVariables[`총중량_${num}`] = item.totalWeight ? item.totalWeight.toLocaleString() : ''; + materialVariables[`중량단위_${num}`] = item.weightUnit || ''; + materialVariables[`통화_${num}`] = item.currency || ''; + materialVariables[`내정액_${num}`] = item.targetUnitPrice ? item.targetUnitPrice.toLocaleString() : ''; + + // 각 품목에 대한 낙찰 협력사 정보 (낙찰된 업체만 표시) + awardedCompanies.forEach((company, companyIndex) => { + const companyNum = companyIndex + 1; + materialVariables[`협력사명_${num}`] = company.companyName || ''; + materialVariables[`입찰액_${num}`] = company.finalQuoteAmount.toLocaleString(); + }); + }); + + return { + 제목: title, + 입찰명: biddingTitle, + 입찰번호: biddingNumber, + 낙찰업체수: winnerCount, + 계약구분: contractType, + 예산: budget, + 내정액: targetPrice, + 입찰담당자: biddingManager, + 입찰개요: biddingOverview, + 업체선정사유: selectionReasonMapped, + 대상_자재_수: biddingItemsInfo.length.toString(), + ...vendorVariables, + ...materialVariables, + }; +} diff --git a/lib/bidding/list/bidding-pr-documents-dialog.tsx b/lib/bidding/list/bidding-pr-documents-dialog.tsx index ad377ee5..9d291ad8 100644 --- a/lib/bidding/list/bidding-pr-documents-dialog.tsx +++ b/lib/bidding/list/bidding-pr-documents-dialog.tsx @@ -304,7 +304,7 @@ export function PrDocumentsDialog({ </div>
</TableCell>
<TableCell className="text-xs">
- {item.purchaseUnit || "-"}
+ {item.priceUnit || "-"}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
diff --git a/lib/bidding/list/create-bidding-dialog.tsx b/lib/bidding/list/create-bidding-dialog.tsx index 2f458873..90abda57 100644 --- a/lib/bidding/list/create-bidding-dialog.tsx +++ b/lib/bidding/list/create-bidding-dialog.tsx @@ -201,8 +201,8 @@ export function CreateBiddingDialog() { materialGroupInfo: '', materialNumber: '', materialInfo: '', - priceUnit: '', - purchaseUnit: '1', + priceUnit: '1', + purchaseUnit: 'EA', materialWeight: '', wbsCode: '', wbsName: '', @@ -427,8 +427,8 @@ export function CreateBiddingDialog() { materialGroupInfo: '', materialNumber: '', materialInfo: '', - priceUnit: '', - purchaseUnit: '1', + priceUnit: '1', + purchaseUnit: 'EA', materialWeight: '', wbsCode: '', wbsName: '', @@ -471,8 +471,8 @@ export function CreateBiddingDialog() { prev.map((item) => { if (item.id === id) { const updatedItem = { ...item, ...updates } - // 내정단가, 수량, 중량, 구매단위가 변경되면 내정금액 재계산 - if (updates.targetUnitPrice || updates.quantity || updates.totalWeight || updates.purchaseUnit) { + // 내정단가, 수량, 중량, 가격단위가 변경되면 내정금액 재계산 + if (updates.targetUnitPrice || updates.quantity || updates.totalWeight || updates.priceUnit) { updatedItem.targetAmount = calculateTargetAmount(updatedItem) } return updatedItem @@ -497,17 +497,17 @@ export function CreateBiddingDialog() { const calculateTargetAmount = (item: PRItemInfo) => { const unitPrice = parseFloat(item.targetUnitPrice) || 0 - const purchaseUnit = parseFloat(item.purchaseUnit) || 1 // 기본값 1 + const priceUnit = parseFloat(item.priceUnit) || 1 // 기본값 1 let amount = 0 if (quantityWeightMode === 'quantity') { const quantity = parseFloat(item.quantity) || 0 - // (수량 / 구매단위) * 내정단가 - amount = (quantity / purchaseUnit) * unitPrice + // (수량 / 가격단위) * 내정단가 + amount = (quantity / priceUnit) * unitPrice } else { const weight = parseFloat(item.totalWeight) || 0 - // (중량 / 구매단위) * 내정단가 - amount = (weight / purchaseUnit) * unitPrice + // (중량 / 가격단위) * 내정단가 + amount = (weight / priceUnit) * unitPrice } // 소수점 버림 @@ -772,6 +772,7 @@ export function CreateBiddingDialog() { <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">자재명</th> <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">수량</th> <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">단위</th> + <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">가격단위</th> <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">구매단위</th> <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">내정단가</th> <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">내정금액</th> @@ -955,13 +956,48 @@ export function CreateBiddingDialog() { type="number" min="1" step="1" - placeholder="구매단위" - value={item.purchaseUnit || ''} - onChange={(e) => updatePRItem(item.id, { purchaseUnit: e.target.value })} + placeholder="가격단위" + value={item.priceUnit || ''} + onChange={(e) => updatePRItem(item.id, { priceUnit: e.target.value })} className="h-8 text-xs" /> </td> <td className="border-r px-3 py-2"> + {quantityWeightMode === 'quantity' ? ( + <Select + value={item.purchaseUnit || item.quantityUnit || 'EA'} + onValueChange={(value) => updatePRItem(item.id, { purchaseUnit: value })} + > + <SelectTrigger className="h-8 text-xs"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="EA">EA</SelectItem> + <SelectItem value="SET">SET</SelectItem> + <SelectItem value="LOT">LOT</SelectItem> + <SelectItem value="M">M</SelectItem> + <SelectItem value="M2">M²</SelectItem> + <SelectItem value="M3">M³</SelectItem> + </SelectContent> + </Select> + ) : ( + <Select + value={item.purchaseUnit || item.weightUnit || 'KG'} + onValueChange={(value) => updatePRItem(item.id, { purchaseUnit: value })} + > + <SelectTrigger className="h-8 text-xs"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="KG">KG</SelectItem> + <SelectItem value="TON">TON</SelectItem> + <SelectItem value="G">G</SelectItem> + <SelectItem value="LB">LB</SelectItem> + </SelectContent> + </Select> + )} + </td> + <td className="border-r px-3 py-2"> <Input type="number" min="0" diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts index 0261ad57..fe37eaea 100644 --- a/lib/bidding/service.ts +++ b/lib/bidding/service.ts @@ -2363,7 +2363,7 @@ export async function updateBiddingSchedule( .insert(specificationMeetings) .values({ biddingId, - meetingDate: specificationMeeting.meetingDate, + meetingDate: parseDate(specificationMeeting.meetingDate), meetingTime: specificationMeeting.meetingTime || null, location: specificationMeeting.location, address: specificationMeeting.address || null, @@ -2545,6 +2545,39 @@ export async function removeBiddingItem(itemId: 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 addPRItemForBidding( biddingId: number, @@ -2620,6 +2653,9 @@ export async function addPRItemForBidding( hasSpecDocument: item.hasSpecDocument || false, }).returning() + // PR 아이템 금액 합산하여 bidding 업데이트 + await updateBiddingAmounts(biddingId) + revalidatePath(`/evcp/bid/${biddingId}/info`) revalidatePath(`/evcp/bid/${biddingId}`) @@ -2653,6 +2689,7 @@ export async function getBiddingVendors(biddingId: number) { currency: sql<string>`'KRW'`, invitationStatus: biddingCompanies.invitationStatus, isPriceAdjustmentApplicableQuestion: biddingCompanies.isPriceAdjustmentApplicableQuestion, + businessSize: vendors.businessSize, }) .from(biddingCompanies) .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) @@ -3223,7 +3260,7 @@ export async function searchVendorsForBidding(searchTerm: string = "", biddingId ) ) .orderBy(asc(vendorsWithTypesView.vendorName)); - + return result; } catch (error) { @@ -3232,6 +3269,34 @@ export async function searchVendorsForBidding(searchTerm: string = "", biddingId } } +// 선택된 vendor들의 businessSize 정보를 가져오는 함수 +export async function getVendorsBusinessSize(vendorIds: number[]) { + try { + if (vendorIds.length === 0) { + return {}; + } + + const result = await db + .select({ + id: vendors.id, + businessSize: vendors.businessSize, + }) + .from(vendors) + .where(inArray(vendors.id, vendorIds)); + + // Map 형태로 변환하여 반환 + const businessSizeMap: Record<number, string | null> = {}; + result.forEach(vendor => { + businessSizeMap[vendor.id] = vendor.businessSize; + }); + + return businessSizeMap; + } catch (error) { + console.error('Error getting vendors business size:', error); + return {}; + } +} + // 차수증가 또는 재입찰 함수 export async function increaseRoundOrRebid(biddingId: number, userId: string | undefined, type: 'round_increase' | 'rebidding') { if (!userId) { diff --git a/lib/bidding/validation.ts b/lib/bidding/validation.ts index f70e498e..73c2fe21 100644 --- a/lib/bidding/validation.ts +++ b/lib/bidding/validation.ts @@ -114,7 +114,7 @@ export const createBiddingSchema = z.object({ isUrgent: z.boolean().default(false), // 구매조직 - purchasingOrganization: z.string().optional(), + purchasingOrganization: z.string().min(1, "구매조직을 선택해주세요"), // 담당자 정보 (개선된 구조) bidPicId: z.number().int().positive().optional(), diff --git a/lib/bidding/vendor/components/pr-items-pricing-table.tsx b/lib/bidding/vendor/components/pr-items-pricing-table.tsx index efa10af2..22051a13 100644 --- a/lib/bidding/vendor/components/pr-items-pricing-table.tsx +++ b/lib/bidding/vendor/components/pr-items-pricing-table.tsx @@ -294,10 +294,10 @@ export function PrItemsPricingTable({ <TableHead>자재내역</TableHead> <TableHead>수량</TableHead> <TableHead>단위</TableHead> - <TableHead>구매단위</TableHead> + <TableHead>가격단위</TableHead> <TableHead>중량</TableHead> <TableHead>중량단위</TableHead> - <TableHead>가격단위</TableHead> + <TableHead>구매단위</TableHead> <TableHead>SHI 납품요청일</TableHead> <TableHead>견적단가</TableHead> <TableHead>견적금액</TableHead> @@ -336,12 +336,12 @@ export function PrItemsPricingTable({ {item.quantity ? parseFloat(item.quantity).toLocaleString() : '-'} </TableCell> <TableCell>{item.quantityUnit || '-'}</TableCell> - <TableCell>{item.purchaseUnit || '-'}</TableCell> + <TableCell>{item.priceUnit || '-'}</TableCell> <TableCell className="text-right"> {item.totalWeight ? parseFloat(item.totalWeight).toLocaleString() : '-'} </TableCell> <TableCell>{item.weightUnit || '-'}</TableCell> - <TableCell>{item.priceUnit || '-'}</TableCell> + <TableCell>{item.purchaseUnit || '-'}</TableCell> <TableCell> {item.requestedDeliveryDate ? formatDate(item.requestedDeliveryDate, 'KR') : '-' diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx index 0215bcb6..504fc916 100644 --- a/lib/bidding/vendor/partners-bidding-detail.tsx +++ b/lib/bidding/vendor/partners-bidding-detail.tsx @@ -854,7 +854,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD <Label className="text-sm font-medium text-muted-foreground mb-2 block">제출 마감 정보</Label> {(() => { const now = new Date() - const deadline = new Date(biddingDetail.submissionEndDate) + const deadline = new Date(biddingDetail.submissionEndDate.toISOString().slice(0, 16).replace('T', ' ')) const isExpired = deadline < now const timeLeft = deadline.getTime() - now.getTime() const daysLeft = Math.floor(timeLeft / (1000 * 60 * 60 * 24)) @@ -873,7 +873,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD <Calendar className="w-5 h-5" /> <span className="font-medium">제출 마감일:</span> <span className="text-lg font-semibold"> - {formatDate(biddingDetail.submissionEndDate, 'KR')} + {biddingDetail.submissionEndDate.toISOString().slice(0, 16).replace('T', ' ')} </span> </div> {isExpired ? ( @@ -1025,7 +1025,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD </div> {/* <div> - <Label className="text-muted-foreground">연동제 적용</Label> + <Label className="text-muted-foreground">하도급법 적용여부</Label> <div className="mt-1 p-3 bg-muted rounded-md"> <p className="font-medium">{biddingConditions.isPriceAdjustmentApplicable ? "적용 가능" : "적용 불가"}</p> </div> @@ -1179,7 +1179,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD <CardContent className="space-y-4"> {/* 공통 필드 - 품목등의 명칭 */} <div className="space-y-2"> - <Label htmlFor="itemName">품목등의 명칭 *</Label> + <Label htmlFor="itemName">물품등의 명칭 *</Label> <Input id="itemName" value={priceAdjustmentForm.itemName} @@ -1205,7 +1205,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD </div> <div className="space-y-2"> - <Label htmlFor="adjustmentRatio">연동 비율 (%) *</Label> + <Label htmlFor="adjustmentRatio">반영비율 (%) *</Label> <Input id="adjustmentRatio" type="number" @@ -1229,7 +1229,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD </div> <div className="space-y-2"> - <Label htmlFor="referenceDate">기준시점 *</Label> + <Label htmlFor="referenceDate">원재료 기준 가격의 변동률 산정을 위한 기준시점 *</Label> <Input id="referenceDate" type="date" @@ -1240,7 +1240,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD </div> <div className="space-y-2"> - <Label htmlFor="comparisonDate">비교시점 *</Label> + <Label htmlFor="comparisonDate">원재료 기준 가격의 변동률 산정을 위한 비교시점 *</Label> <Input id="comparisonDate" type="date" @@ -1251,7 +1251,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD </div> <div className="space-y-2"> - <Label htmlFor="contractorWriter">수탁기업(협력사) 작성자 *</Label> + <Label htmlFor="contractorWriter">수탁기업(협력사)작성자 *</Label> <Input id="contractorWriter" value={priceAdjustmentForm.contractorWriter} @@ -1322,7 +1322,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD </div> <div className="space-y-2"> - <Label htmlFor="priceAdjustmentNotes">기타 사항</Label> + <Label htmlFor="priceAdjustmentNotes">기타사항</Label> <Textarea id="priceAdjustmentNotes" value={priceAdjustmentForm.notes} @@ -1376,6 +1376,15 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD </CardContent> </Card> )} + + {/* 참고 경고문 */} + <div className="text-xs text-red-600 space-y-2 bg-red-50 p-3 rounded-md border border-red-200 mt-4"> + <p className="font-medium">※ 참고사항</p> + <div className="space-y-1"> + <p>• 납품대금의 10% 이상을 차지하는 주요 원재료가 있는 경우 모든 주요 원재료에 대해서 적용 또는 미적용에 대한 연동표를 작성해야 한다.</p> + <p>• 납품대급연동표를 허위로 작성하거나 근거자료를 허위로 제출할 경우 본 계약이 체결되지 않을 수 있으며, 본 계약이 체결되었더라도 계약의 전부 또는 일부를 해제 또는 해지할 수 있다.</p> + </div> + </div> </> )} diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx index 63d097c0..8cbddb3d 100644 --- a/lib/bidding/vendor/partners-bidding-list-columns.tsx +++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx @@ -169,6 +169,19 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL } const handleView = () => { + // 입찰기간 체크 (현 시간 기준으로 입찰기간 시작 전이면 접근 불가) + const now = new Date() + const startDate = row.original.submissionStartDate ? new Date(row.original.submissionStartDate) : null + const endDate = row.original.submissionEndDate ? new Date(row.original.submissionEndDate) : null + + if (startDate && now < startDate) { + toast.warning('입찰기간 전 접근 제한', { + description: `입찰기간이 아직 시작되지 않았습니다. 입찰 시작일: ${format(startDate, "yyyy-MM-dd HH:mm")}`, + duration: 5000, + }) + return + } + // 사양설명회 체크 if (!checkSpecificationMeeting()) { return diff --git a/lib/rfq-last/quotation-compare-view.tsx b/lib/rfq-last/quotation-compare-view.tsx index c6710d7b..be19f738 100644 --- a/lib/rfq-last/quotation-compare-view.tsx +++ b/lib/rfq-last/quotation-compare-view.tsx @@ -119,8 +119,6 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { { value: 'construction', label: '공사' }, { value: 'service', label: '용역' }, { value: 'lease', label: '임차' }, - { value: 'steel_stock', label: '형강스톡' }, - { value: 'piping', label: '배관' }, { value: 'transport', label: '운송' }, { value: 'waste', label: '폐기물' }, { value: 'sale', label: '매각' } diff --git a/lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx b/lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx index 41592e46..463a36c9 100644 --- a/lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx +++ b/lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx @@ -805,7 +805,7 @@ export default function CommercialTermsForm({ rfqDetail, rfq, onCurrencyDecimalP {/* 공통 필드 - 품목등의 명칭 */} {watch("priceAdjustmentForm.priceAdjustmentResponse") !== null && watch("priceAdjustmentForm.priceAdjustmentResponse") !== undefined && ( <div className="space-y-2"> - <Label htmlFor="priceAdjustmentForm.itemName">품목등의 명칭 *</Label> + <Label htmlFor="priceAdjustmentForm.itemName">물품등의 명칭 *</Label> <Input id="priceAdjustmentForm.itemName" {...register("priceAdjustmentForm.itemName")} @@ -830,7 +830,7 @@ export default function CommercialTermsForm({ rfqDetail, rfq, onCurrencyDecimalP </div> <div className="space-y-2"> - <Label htmlFor="priceAdjustmentForm.adjustmentRatio">연동 비율 (%) *</Label> + <Label htmlFor="priceAdjustmentForm.adjustmentRatio">반영비율 (%) *</Label> <Input id="priceAdjustmentForm.adjustmentRatio" type="number" @@ -852,7 +852,7 @@ export default function CommercialTermsForm({ rfqDetail, rfq, onCurrencyDecimalP </div> <div className="space-y-2"> - <Label htmlFor="priceAdjustmentForm.referenceDate">기준시점 *</Label> + <Label htmlFor="priceAdjustmentForm.referenceDate">원재료 기준 가격의 변동률 산정을 위한 기준시점 *</Label> <Input id="priceAdjustmentForm.referenceDate" type="date" @@ -862,7 +862,7 @@ export default function CommercialTermsForm({ rfqDetail, rfq, onCurrencyDecimalP </div> <div className="space-y-2"> - <Label htmlFor="priceAdjustmentForm.comparisonDate">비교시점 *</Label> + <Label htmlFor="priceAdjustmentForm.comparisonDate">원재료 기준 가격의 변동률 산정을 위한 비교시점 *</Label> <Input id="priceAdjustmentForm.comparisonDate" type="date" @@ -872,7 +872,7 @@ export default function CommercialTermsForm({ rfqDetail, rfq, onCurrencyDecimalP </div> <div className="space-y-2"> - <Label htmlFor="priceAdjustmentForm.contractorWriter">수탁기업(협력사) 작성자 *</Label> + <Label htmlFor="priceAdjustmentForm.contractorWriter">수탁기업(협력사)작성자 *</Label> <Input id="priceAdjustmentForm.contractorWriter" {...register("priceAdjustmentForm.contractorWriter")} @@ -937,7 +937,7 @@ export default function CommercialTermsForm({ rfqDetail, rfq, onCurrencyDecimalP </div> <div className="space-y-2"> - <Label htmlFor="priceAdjustmentForm.notes">기타 사항</Label> + <Label htmlFor="priceAdjustmentForm.notes">기타사항</Label> <Textarea id="priceAdjustmentForm.notes" {...register("priceAdjustmentForm.notes")} @@ -963,7 +963,7 @@ export default function CommercialTermsForm({ rfqDetail, rfq, onCurrencyDecimalP </div> <div className="space-y-2"> - <Label htmlFor="priceAdjustmentForm.contractorWriterNonApplicable">수탁기업(협력사) 작성자 *</Label> + <Label htmlFor="priceAdjustmentForm.contractorWriterNonApplicable">수탁기업(협력사)작성자 *</Label> <Input id="priceAdjustmentForm.contractorWriterNonApplicable" {...register("priceAdjustmentForm.contractorWriter")} @@ -988,6 +988,15 @@ export default function CommercialTermsForm({ rfqDetail, rfq, onCurrencyDecimalP </CardContent> </Card> )} + + {/* 참고 경고문 */} + <div className="text-xs text-red-600 space-y-2 bg-red-50 p-3 rounded-md border border-red-200 mt-4"> + <p className="font-medium">※ 참고사항</p> + <div className="space-y-1"> + <p>• 납품대금의 10% 이상을 차지하는 주요 원재료가 있는 경우 모든 주요 원재료에 대해서 적용 또는 미적용에 대한 연동표를 작성해야 한다.</p> + <p>• 납품대급연동표를 허위로 작성하거나 근거자료를 허위로 제출할 경우 본 계약이 체결되지 않을 수 있으며, 본 계약이 체결되었더라도 계약의 전부 또는 일부를 해제 또는 해지할 수 있다.</p> + </div> + </div> </> )} </CardContent> diff --git a/lib/rfq-last/vendor/price-adjustment-dialog.tsx b/lib/rfq-last/vendor/price-adjustment-dialog.tsx index b7fd48a6..56cf5bb0 100644 --- a/lib/rfq-last/vendor/price-adjustment-dialog.tsx +++ b/lib/rfq-last/vendor/price-adjustment-dialog.tsx @@ -95,7 +95,7 @@ export function PriceAdjustmentDialog({ <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>
+ <label className="text-xs text-gray-500">물품등의 명칭</label>
<p className="text-sm font-medium">{data.itemName || '-'}</p>
</div>
<div>
@@ -182,17 +182,17 @@ export function PriceAdjustmentDialog({ </div>
<div className="grid grid-cols-2 gap-4">
<div>
- <label className="text-xs text-gray-500">기준시점</label>
+ <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>
+ <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>
+ <label className="text-xs text-gray-500">반영비율</label>
<p className="text-sm font-medium">
{data.adjustmentRatio}%
</p>
@@ -224,12 +224,12 @@ export function PriceAdjustmentDialog({ </div>
</div>
<div>
- <label className="text-xs text-gray-500">수탁기업(협력사) 작성자</label>
+ <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>
+ <label className="text-xs text-gray-500">기타사항</label>
<p className="text-sm font-medium whitespace-pre-wrap">
{data.notes}
</p>
@@ -260,6 +260,17 @@ export function PriceAdjustmentDialog({ <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>
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx index 28b281f4..72f03dc3 100644 --- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx +++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx @@ -287,7 +287,13 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps const result = await sendTechSalesRfqToVendors({
rfqId: selectedRfqId,
vendorIds: vendorIds as number[],
- selectedContacts: selectedContacts
+ selectedContacts: selectedContacts,
+ currentUser: {
+ id: Number(session.data.user.id),
+ epId: session.data.user.epId || null,
+ name: session.data.user.name || undefined,
+ email: session.data.user.email || undefined,
+ },
});
if (result.success) {
@@ -308,7 +314,7 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps } finally {
setIsSendingRfq(false);
}
- }, [selectedRfqId, selectedRows, handleRefreshData]);
+ }, [selectedRfqId, selectedRows, handleRefreshData, session.data?.user]);
// 벤더 선택 핸들러 추가
const [isAcceptingVendors, setIsAcceptingVendors] = useState(false);
diff --git a/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet-copy-1118.tsx b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet-copy-1118.tsx deleted file mode 100644 index 82f83b7c..00000000 --- a/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet-copy-1118.tsx +++ /dev/null @@ -1,710 +0,0 @@ -"use client"
-
-import * as React from "react"
-import { z } from "zod"
-import { useForm, useFieldArray } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import {
- Sheet,
- SheetContent,
- SheetHeader,
- SheetTitle,
- SheetDescription,
- SheetFooter,
- SheetClose,
-} from "@/components/ui/sheet"
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
- FormDescription
-} from "@/components/ui/form"
-import { Loader, Download, X, Eye, AlertCircle } from "lucide-react"
-import { toast } from "sonner"
-import { Badge } from "@/components/ui/badge"
-
-import {
- Dropzone,
- DropzoneDescription,
- DropzoneInput,
- DropzoneTitle,
- DropzoneUploadIcon,
- DropzoneZone,
-} from "@/components/ui/dropzone"
-import {
- FileList,
- FileListAction,
- FileListDescription,
- FileListHeader,
- FileListIcon,
- FileListInfo,
- FileListItem,
- FileListName,
-} from "@/components/ui/file-list"
-
-import prettyBytes from "pretty-bytes"
-import { formatDate } from "@/lib/utils"
-import { processTechSalesRfqAttachments } from "@/lib/techsales-rfq/service"
-import { useSession } from "next-auth/react"
-import { ApprovalPreviewDialog } from "@/lib/approval/client"
-import { ApplicationReasonDialog } from "@/lib/rfq-last/vendor/application-reason-dialog"
-import { requestRfqResendWithDrmApproval } from "@/lib/techsales-rfq/approval-actions"
-import { mapTechSalesRfqSendToTemplateVariables } from "@/lib/techsales-rfq/approval-handlers"
-
-const MAX_FILE_SIZE = 6e8 // 600MB
-
-/** 기존 첨부 파일 정보 (techSalesAttachments 테이블 구조) */
-export interface ExistingTechSalesAttachment {
- id: number
- techSalesRfqId: number
- fileName: string
- originalFileName: string
- filePath: string
- fileSize?: number
- fileType?: string
- attachmentType: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
- description?: string
- createdBy: number
- createdAt: Date
-}
-
-/** 새로 업로드할 파일 */
-const newUploadSchema = z.object({
- fileObj: z.any().optional(), // 실제 File
- attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]).default("RFQ_COMMON"),
- description: z.string().optional(),
-})
-
-/** 기존 첨부 (react-hook-form에서 관리) */
-const existingAttachSchema = z.object({
- id: z.number(),
- techSalesRfqId: z.number(),
- fileName: z.string(),
- originalFileName: z.string(),
- filePath: z.string(),
- fileSize: z.number().optional(),
- fileType: z.string().optional(),
- attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]),
- description: z.string().optional(),
- createdBy: z.number(),
- createdAt: z.custom<Date>(),
-})
-
-/** RHF 폼 전체 스키마 */
-const attachmentsFormSchema = z.object({
- techSalesRfqId: z.number().int(),
- existing: z.array(existingAttachSchema),
- newUploads: z.array(newUploadSchema),
-})
-
-type AttachmentsFormValues = z.infer<typeof attachmentsFormSchema>
-
-// TechSalesRfq 타입 (간단 버전)
-interface TechSalesRfq {
- id: number
- rfqCode: string | null
- status: string
- // 필요한 다른 필드들...
-}
-
-interface TechSalesRfqAttachmentsSheetProps
- extends React.ComponentPropsWithRef<typeof Sheet> {
- defaultAttachments?: ExistingTechSalesAttachment[]
- rfq: TechSalesRfq | null
- /** 첨부파일 타입 */
- attachmentType?: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
- /** 읽기 전용 모드 (벤더용) */
- readOnly?: boolean
- /** 업로드/삭제 후 상위 테이블에 attachmentCount 등을 업데이트하기 위한 콜백 */
- // onAttachmentsUpdated?: (rfqId: number, newAttachmentCount: number) => void
-
-}
-
-export function TechSalesRfqAttachmentsSheet({
- defaultAttachments = [],
- // onAttachmentsUpdated,
- rfq,
- attachmentType = "RFQ_COMMON",
- readOnly = false,
- ...props
-}: TechSalesRfqAttachmentsSheetProps) {
- const [isPending, setIsPending] = React.useState(false)
- const session = useSession()
-
- // 재발송 결재 관련 상태
- const [showResendApprovalDialog, setShowResendApprovalDialog] = React.useState(false)
- const [showApplicationReasonDialog, setShowApplicationReasonDialog] = React.useState(false)
- const [resendApprovalData, setResendApprovalData] = React.useState<{
- rfqId: number
- drmFiles: Array<{
- file: File
- attachmentType: string
- description?: string
- }>
- } | null>(null)
- const [approvalPreviewData, setApprovalPreviewData] = React.useState<{
- templateVariables: Record<string, string>
- applicationReason: string
- } | null>(null)
-
- // 파일 다운로드 핸들러
- const handleDownloadClick = React.useCallback(async (filePath: string, fileName: string) => {
- try {
- const { downloadFile } = await import('@/lib/file-download')
- await downloadFile(filePath, fileName, {
- showToast: true,
- onError: (error) => {
- console.error('다운로드 오류:', error)
- toast.error(error)
- },
- onSuccess: (fileName, fileSize) => {
- console.log(`다운로드 성공: ${fileName} (${fileSize} bytes)`)
- }
- })
- } catch (error) {
- console.error('다운로드 오류:', error)
- toast.error('파일 다운로드 중 오류가 발생했습니다.')
- }
- }, [])
- // 첨부파일 타입별 제목과 설명 설정
- const attachmentConfig = React.useMemo(() => {
- switch (attachmentType) {
- case "TBE_RESULT":
- return {
- title: "TBE 결과 첨부파일",
- description: "기술 평가(TBE) 결과 파일을 관리합니다.",
- fileTypeLabel: "TBE 결과",
- canEdit: !readOnly
- }
- case "CBE_RESULT":
- return {
- title: "CBE 결과 첨부파일",
- description: "상업성 평가(CBE) 결과 파일을 관리합니다.",
- fileTypeLabel: "CBE 결과",
- canEdit: !readOnly
- }
- default: // RFQ_COMMON
- return {
- title: "RFQ 첨부파일",
- description: readOnly ? "RFQ 공통 첨부파일을 조회합니다." : "RFQ 공통 첨부파일을 관리합니다.",
- fileTypeLabel: "공통",
- canEdit: !readOnly
- }
- }
- }, [attachmentType, readOnly])
-
- // // RFQ 상태에 따른 편집 가능 여부 결정 (readOnly prop이 true면 항상 false)
- // const isEditable = React.useMemo(() => {
- // if (!rfq) return false
- // return attachmentConfig.canEdit
- // }, [rfq, attachmentConfig.canEdit])
-
- const form = useForm<AttachmentsFormValues>({
- resolver: zodResolver(attachmentsFormSchema),
- defaultValues: {
- techSalesRfqId: rfq?.id || 0,
- existing: defaultAttachments.map(att => ({
- id: att.id,
- techSalesRfqId: att.techSalesRfqId,
- fileName: att.fileName,
- originalFileName: att.originalFileName,
- filePath: att.filePath,
- fileSize: att.fileSize || undefined,
- fileType: att.fileType || undefined,
- attachmentType: att.attachmentType,
- description: att.description || undefined,
- createdBy: att.createdBy,
- createdAt: att.createdAt,
- })),
- newUploads: [],
- },
- })
-
- // useFieldArray for existing and new uploads
- const {
- fields: existingFields,
- remove: removeExisting,
- } = useFieldArray({
- control: form.control,
- name: "existing",
- })
-
- const {
- fields: newUploadFields,
- append: appendNewUpload,
- remove: removeNewUpload,
- } = useFieldArray({
- control: form.control,
- name: "newUploads",
- })
-
- // Reset form when defaultAttachments changes
- React.useEffect(() => {
- if (defaultAttachments) {
- form.reset({
- techSalesRfqId: rfq?.id || 0,
- existing: defaultAttachments.map(att => ({
- id: att.id,
- techSalesRfqId: att.techSalesRfqId,
- fileName: att.fileName,
- originalFileName: att.originalFileName,
- filePath: att.filePath,
- fileSize: att.fileSize || undefined,
- fileType: att.fileType || undefined,
- attachmentType: att.attachmentType,
- description: att.description || undefined,
- createdBy: att.createdBy,
- createdAt: att.createdAt,
- })),
- newUploads: [],
- })
- }
- }, [defaultAttachments, rfq?.id, form])
-
- // Handle dropzone accept
- const handleDropAccepted = React.useCallback((acceptedFiles: File[]) => {
- acceptedFiles.forEach((file) => {
- appendNewUpload({
- fileObj: file,
- attachmentType: "RFQ_COMMON",
- description: "",
- })
- })
- }, [appendNewUpload])
-
- // Handle dropzone reject
- const handleDropRejected = React.useCallback(() => {
- toast.error("파일 크기가 너무 크거나 지원하지 않는 파일 형식입니다.")
- }, [])
-
- // Handle remove existing attachment
- const handleRemoveExisting = React.useCallback((index: number) => {
- removeExisting(index)
- }, [removeExisting])
-
- // Handle form submission
- const onSubmit = async (data: AttachmentsFormValues) => {
- if (!rfq) {
- toast.error("RFQ 정보를 찾을 수 없습니다.")
- return
- }
-
- setIsPending(true)
- try {
- // 삭제할 첨부파일 ID 수집
- const deleteAttachmentIds = defaultAttachments
- .filter((original) => !data.existing.find(existing => existing.id === original.id))
- .map(attachment => attachment.id)
-
- // 새 파일 정보 수집
- const newFiles = data.newUploads
- .filter(upload => upload.fileObj)
- .map(upload => ({
- file: upload.fileObj as File,
- attachmentType: attachmentType,
- description: upload.description,
- }))
-
- // 실제 API 호출
- const result = await processTechSalesRfqAttachments({
- techSalesRfqId: rfq.id,
- newFiles,
- deleteAttachmentIds,
- createdBy: parseInt(session.data?.user.id || "0"),
- })
-
- if (result.error) {
- // DRM 파일 추가로 인한 재발송 결재 필요
- if (result.error === "DRM_FILE_ADDED_TO_SENT_RFQ") {
- // DRM 파일만 필터링
- const drmFiles = newFiles.filter((_, index) => {
- // DRM 파일 검출은 서버에서 이미 완료되었으므로, 업로드된 파일 중 DRM 파일만 추출
- // 실제로는 서버에서 반환된 정보를 사용해야 하지만, 여기서는 업로드된 파일을 그대로 사용
- return true // 임시로 모든 새 파일을 DRM 파일로 간주 (실제로는 서버에서 필터링 필요)
- })
-
- setResendApprovalData({
- rfqId: rfq.id,
- drmFiles: newFiles, // 모든 새 파일을 DRM 파일로 간주
- })
- setShowApplicationReasonDialog(true)
- setIsPending(false)
- return
- } else {
- toast.error(result.error)
- return
- }
- }
-
- // 성공 메시지 표시 (업로드된 파일 수 포함)
- const uploadedCount = newFiles.length
- const deletedCount = deleteAttachmentIds.length
-
- let successMessage = "첨부파일이 저장되었습니다."
- if (uploadedCount > 0 && deletedCount > 0) {
- successMessage = `${uploadedCount}개 파일 업로드, ${deletedCount}개 파일 삭제 완료`
- } else if (uploadedCount > 0) {
- successMessage = `${uploadedCount}개 파일이 업로드되었습니다.`
- } else if (deletedCount > 0) {
- successMessage = `${deletedCount}개 파일이 삭제되었습니다.`
- }
-
- toast.success(successMessage)
-
- // 다이얼로그 자동 닫기
- props.onOpenChange?.(false)
-
- // // 즉시 첨부파일 목록 새로고침
- // const refreshResult = await getTechSalesRfqAttachments(rfq.id)
- // if (refreshResult.error) {
- // console.error("첨부파일 목록 새로고침 실패:", refreshResult.error)
- // toast.warning("첨부파일 목록 새로고침에 실패했습니다. 시트를 다시 열어주세요.")
- // } else {
- // // 새로운 첨부파일 목록으로 폼 업데이트
- // const refreshedAttachments = refreshResult.data.map(att => ({
- // id: att.id,
- // techSalesRfqId: att.techSalesRfqId || rfq.id,
- // fileName: att.fileName,
- // originalFileName: att.originalFileName,
- // filePath: att.filePath,
- // fileSize: att.fileSize,
- // fileType: att.fileType,
- // attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT",
- // description: att.description,
- // createdBy: att.createdBy,
- // createdAt: att.createdAt,
- // }))
-
- // // 폼을 새로운 데이터로 리셋 (새 업로드 목록은 비움)
- // form.reset({
- // techSalesRfqId: rfq.id,
- // existing: refreshedAttachments.map(att => ({
- // ...att,
- // fileSize: att.fileSize || undefined,
- // fileType: att.fileType || undefined,
- // description: att.description || undefined,
- // })),
- // newUploads: [],
- // })
-
- // // 즉시 UI 업데이트를 위한 추가 피드백
- // if (uploadedCount > 0) {
- // toast.success("첨부파일 목록이 업데이트되었습니다.", { duration: 2000 })
- // }
- // }
-
- // // 콜백으로 상위 컴포넌트에 변경사항 알림
- // const newAttachmentCount = refreshResult.error ?
- // (data.existing.length + newFiles.length - deleteAttachmentIds.length) :
- // refreshResult.data.length
- // onAttachmentsUpdated?.(rfq.id, newAttachmentCount)
-
- } catch (error) {
- console.error("첨부파일 저장 오류:", error)
- toast.error("첨부파일 저장 중 오류가 발생했습니다.")
- } finally {
- setIsPending(false)
- }
- }
-
- // 신청사유 입력 완료 핸들러
- const handleApplicationReasonConfirm = React.useCallback(async (reason: string) => {
- if (!resendApprovalData) {
- toast.error("결재 데이터가 없습니다.")
- return
- }
-
- try {
- // 템플릿 변수 생성 (신청사유 포함)
- const templateVariables = await mapTechSalesRfqSendToTemplateVariables({
- attachments: resendApprovalData.drmFiles.map(f => ({
- fileName: f.file.name,
- fileSize: f.file.size,
- })),
- vendorNames: [], // 기존 벤더 목록은 후처리에서 조회
- applicationReason: reason,
- })
-
- // 결재 미리보기 데이터 업데이트
- setApprovalPreviewData({
- templateVariables,
- applicationReason: reason,
- })
-
- // 신청사유 다이얼로그 닫고 결재 미리보기 열기
- setShowApplicationReasonDialog(false)
- setShowResendApprovalDialog(true)
- } catch (error) {
- console.error("템플릿 변수 생성 실패:", error)
- toast.error("결재 문서 생성에 실패했습니다.")
- }
- }, [resendApprovalData])
-
- // 결재 미리보기 확인 핸들러
- const handleApprovalConfirm = React.useCallback(async (approvalData: {
- approvers: string[]
- title: string
- description?: string
- }) => {
- if (!resendApprovalData || !approvalPreviewData || !session?.data?.user) {
- toast.error("결재 데이터가 없습니다.")
- return
- }
-
- try {
- const result = await requestRfqResendWithDrmApproval({
- rfqId: resendApprovalData.rfqId,
- rfqCode: rfq?.rfqCode || undefined,
- drmFiles: resendApprovalData.drmFiles,
- applicationReason: approvalPreviewData.applicationReason,
- currentUser: {
- id: Number(session.data.user.id),
- epId: session.data.user.epId || null,
- name: session.data.user.name || undefined,
- email: session.data.user.email || undefined,
- },
- approvers: approvalData.approvers,
- })
-
- if (result.success) {
- toast.success(result.message)
- setShowResendApprovalDialog(false)
- setResendApprovalData(null)
- setApprovalPreviewData(null)
- props.onOpenChange?.(false)
- }
- } catch (error) {
- console.error("재발송 결재 상신 실패:", error)
- toast.error(error instanceof Error ? error.message : "재발송 결재 상신에 실패했습니다.")
- }
- }, [resendApprovalData, approvalPreviewData, session, rfq, props])
-
- return (
- <Sheet {...props}>
- <SheetContent className="flex flex-col gap-6 sm:max-w-md">
- <SheetHeader className="text-left">
- <SheetTitle>{attachmentConfig.title}</SheetTitle>
- <SheetDescription>
- <div>RFQ: {rfq?.rfqCode || "N/A"}</div>
- <div className="mt-1">{attachmentConfig.description}</div>
- {!attachmentConfig.canEdit && (
- <div className="mt-2 flex items-center gap-2 text-amber-600">
- <AlertCircle className="h-4 w-4" />
- <span className="text-sm">현재 상태에서는 편집할 수 없습니다</span>
- </div>
- )}
- </SheetDescription>
- </SheetHeader>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-1 flex-col gap-6">
- {/* 1) Existing attachments */}
- <div className="grid gap-4">
- <h6 className="font-semibold leading-none tracking-tight">
- 기존 첨부파일 ({existingFields.length}개)
- </h6>
- {existingFields.map((field, index) => {
- const typeLabel = attachmentConfig.fileTypeLabel
- const sizeText = field.fileSize ? prettyBytes(field.fileSize) : "알 수 없음"
- const dateText = field.createdAt ? formatDate(field.createdAt, "KR") : ""
-
- return (
- <div key={field.id} className="flex items-start justify-between p-3 border rounded-md gap-3">
- <div className="flex-1 min-w-0 overflow-hidden">
- <div className="flex items-center gap-2 mb-1 flex-wrap">
- <p className="text-sm font-medium break-words leading-tight">
- {field.originalFileName || field.fileName}
- </p>
- <Badge variant="outline" className="text-xs shrink-0">
- {typeLabel}
- </Badge>
- </div>
- <p className="text-xs text-muted-foreground">
- {sizeText} • {dateText}
- </p>
- {field.description && (
- <p className="text-xs text-muted-foreground mt-1 break-words">
- {field.description}
- </p>
- )}
- </div>
-
- <div className="flex items-center gap-1 shrink-0">
- {/* Download button */}
- {field.filePath && (
- <Button
- variant="ghost"
- size="icon"
- type="button"
- className="h-8 w-8"
- onClick={() => handleDownloadClick(field.filePath, field.originalFileName || field.fileName)}
- title="다운로드"
- >
- <Download className="h-4 w-4" />
- </Button>
- )}
- {/* Remove button - 편집 가능할 때만 표시 */}
- {attachmentConfig.canEdit && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="h-8 w-8"
- onClick={() => handleRemoveExisting(index)}
- >
- <X className="h-4 w-4" />
- </Button>
- )}
- </div>
- </div>
- )
- })}
- </div>
-
- {/* 2) Dropzone for new uploads - 편집 가능할 때만 표시 */}
- {attachmentConfig.canEdit ? (
- <>
- <Dropzone
- maxSize={MAX_FILE_SIZE}
- onDropAccepted={handleDropAccepted}
- onDropRejected={handleDropRejected}
- >
- {({ maxSize }) => (
- <FormField
- control={form.control}
- name="newUploads"
- render={() => (
- <FormItem>
- <FormLabel>새 파일 업로드</FormLabel>
- <DropzoneZone className="flex justify-center">
- <FormControl>
- <DropzoneInput />
- </FormControl>
- <div className="flex items-center gap-6">
- <DropzoneUploadIcon />
- <div className="grid gap-0.5">
- <DropzoneTitle>파일을 드래그하거나 클릭하세요</DropzoneTitle>
- <DropzoneDescription>
- 최대 크기: {maxSize ? prettyBytes(maxSize) : "600MB"}
- </DropzoneDescription>
- </div>
- </div>
- </DropzoneZone>
- <FormDescription>파일을 여러 개 선택할 수 있습니다.</FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
- )}
- </Dropzone>
-
- {/* newUpload fields -> FileList */}
- {newUploadFields.length > 0 && (
- <div className="grid gap-4">
- <h6 className="font-semibold leading-none tracking-tight">
- 새 파일 ({newUploadFields.length}개)
- </h6>
- <FileList>
- {newUploadFields.map((field, idx) => {
- const fileObj = form.getValues(`newUploads.${idx}.fileObj`)
- if (!fileObj) return null
-
- const fileName = fileObj.name
- const fileSize = fileObj.size
- return (
- <FileListItem key={field.id}>
- <FileListHeader>
- <FileListIcon />
- <FileListInfo>
- <FileListName>{fileName}</FileListName>
- <FileListDescription>
- {prettyBytes(fileSize)}
- </FileListDescription>
- </FileListInfo>
- <FileListAction onClick={() => removeNewUpload(idx)}>
- <X />
- <span className="sr-only">제거</span>
- </FileListAction>
- </FileListHeader>
-
- </FileListItem>
- )
- })}
- </FileList>
- </div>
- )}
- </>
- ) : (
- <div className="p-3 bg-muted rounded-md flex items-center justify-center">
- <div className="text-center text-sm text-muted-foreground">
- <Eye className="h-4 w-4 mx-auto mb-2" />
- <p>보기 모드에서는 파일 첨부를 할 수 없습니다.</p>
- </div>
- </div>
- )}
-
- <SheetFooter className="gap-2 pt-2 sm:space-x-0">
- <SheetClose asChild>
- <Button type="button" variant="outline">
- {attachmentConfig.canEdit ? "취소" : "닫기"}
- </Button>
- </SheetClose>
- {attachmentConfig.canEdit && (
- <Button
- type="submit"
- disabled={
- isPending ||
- (
- form.getValues().newUploads.length === 0 &&
- form.getValues().existing.length === defaultAttachments.length &&
- form.getValues().existing.every(existing =>
- defaultAttachments.some(original => original.id === existing.id)
- )
- )
- }
- >
- {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}
- {isPending ? "저장 중..." : "저장"}
- </Button>
- )}
- </SheetFooter>
- </form>
- </Form>
- </SheetContent>
-
- {/* 신청사유 입력 다이얼로그 */}
- {resendApprovalData && (
- <ApplicationReasonDialog
- open={showApplicationReasonDialog}
- onOpenChange={setShowApplicationReasonDialog}
- onConfirm={handleApplicationReasonConfirm}
- vendorCount={0} // 재발송이므로 기존 벤더에게 발송
- attachmentCount={resendApprovalData.drmFiles.length}
- />
- )}
-
- {/* 결재 미리보기 다이얼로그 */}
- {resendApprovalData && session?.data?.user?.epId && approvalPreviewData && (
- <ApprovalPreviewDialog
- open={showResendApprovalDialog}
- onOpenChange={setShowResendApprovalDialog}
- templateName="암호화해제 신청"
- variables={approvalPreviewData.templateVariables}
- title={`DRM 파일 재발송 결재 - ${rfq?.rfqCode || 'RFQ'}`}
- currentUser={{
- id: Number(session.data.user.id),
- epId: session.data.user.epId,
- name: session.data.user.name || undefined,
- email: session.data.user.email || undefined,
- }}
- onConfirm={handleApprovalConfirm}
- allowTitleEdit={false}
- />
- )}
- </Sheet>
- )
-}
\ No newline at end of file diff --git a/lib/vendors/bid-history-table/bid-history-table-columns.tsx b/lib/vendors/bid-history-table/bid-history-table-columns.tsx index b235917f..7afecab4 100644 --- a/lib/vendors/bid-history-table/bid-history-table-columns.tsx +++ b/lib/vendors/bid-history-table/bid-history-table-columns.tsx @@ -132,8 +132,6 @@ export function getColumns({ setRowAction, onViewDetails }: GetColumnsProps): Co construction: "공사",
service: "용역",
lease: "임차",
- steel_stock: "형강스톡",
- piping: "배관",
transport: "운송",
waste: "폐기물",
sale: "매각"
diff --git a/lib/vendors/bid-history-table/bid-history-table.tsx b/lib/vendors/bid-history-table/bid-history-table.tsx index ec810429..e41db58a 100644 --- a/lib/vendors/bid-history-table/bid-history-table.tsx +++ b/lib/vendors/bid-history-table/bid-history-table.tsx @@ -92,8 +92,6 @@ export function VendorBidHistoryTable({ promises, lng }: BidHistoryTableProps) { { label: "공사", value: "construction" },
{ label: "용역", value: "service" },
{ label: "임차", value: "lease" },
- { label: "형강스톡", value: "steel_stock" },
- { label: "배관", value: "piping" },
{ label: "운송", value: "transport" },
{ label: "폐기물", value: "waste" },
{ label: "매각", value: "sale" }
|
