From 25749225689c3934bc10ad1e8285e13020b61282 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 4 Dec 2025 09:04:09 +0000 Subject: (최겸)구매 입찰, 계약 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../detail/general-contract-basic-info.tsx | 478 +++++++++++++++------ .../detail/general-contract-items-table.tsx | 43 +- lib/general-contracts/service.ts | 11 +- 3 files changed, 397 insertions(+), 135 deletions(-) (limited to 'lib/general-contracts') diff --git a/lib/general-contracts/detail/general-contract-basic-info.tsx b/lib/general-contracts/detail/general-contract-basic-info.tsx index b0378912..d7533d2e 100644 --- a/lib/general-contracts/detail/general-contract-basic-info.tsx +++ b/lib/general-contracts/detail/general-contract-basic-info.tsx @@ -8,7 +8,21 @@ import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Textarea } from '@/components/ui/textarea' import { Button } from '@/components/ui/button' -import { Save, LoaderIcon } from 'lucide-react' +import { Save, LoaderIcon, Check, ChevronsUpDown } from 'lucide-react' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command' +import { cn } from '@/lib/utils' import { updateContractBasicInfo, getContractBasicInfo } from '../service' import { toast } from 'sonner' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' @@ -140,19 +154,28 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) { // paymentDelivery에서 퍼센트와 타입 분리 const paymentDeliveryValue = contractData?.paymentDelivery || '' + console.log(paymentDeliveryValue,"paymentDeliveryValue") let paymentDeliveryType = '' let paymentDeliveryPercentValue = '' - if (paymentDeliveryValue.includes('%')) { + // "60일 이내" 또는 "추가조건"은 그대로 사용 + if (paymentDeliveryValue === '납품완료일로부터 60일 이내 지급' || paymentDeliveryValue === '추가조건') { + paymentDeliveryType = paymentDeliveryValue + } else if (paymentDeliveryValue.includes('%')) { + // 퍼센트가 포함된 경우 (예: "10% L/C") const match = paymentDeliveryValue.match(/(\d+)%\s*(.+)/) if (match) { paymentDeliveryPercentValue = match[1] paymentDeliveryType = match[2] + } else { + paymentDeliveryType = paymentDeliveryValue } } else { + // 일반 지급조건 코드 (예: "P008") paymentDeliveryType = paymentDeliveryValue } - + console.log(paymentDeliveryType,"paymentDeliveryType") + console.log(paymentDeliveryPercentValue,"paymentDeliveryPercentValue") setPaymentDeliveryPercent(paymentDeliveryPercentValue) // 합의계약(AD, AW)인 경우 인도조건 기본값 설정 @@ -309,6 +332,7 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) { loadShippingPlaces(); loadDestinationPlaces(); }, [loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces]); + const handleSaveContractInfo = async () => { if (!userId) { toast.error('사용자 정보를 찾을 수 없습니다.') @@ -342,12 +366,29 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) { return } - // paymentDelivery와 paymentDeliveryPercent 합쳐서 저장 + // paymentDelivery 저장 로직 + // 1. "60일 이내" 또는 "추가조건"은 그대로 저장 + // 2. L/C 또는 T/T이고 퍼센트가 있으면 "퍼센트% 코드" 형식으로 저장 + // 3. 그 외의 경우는 그대로 저장 + let paymentDeliveryToSave = formData.paymentDelivery + + if ( + formData.paymentDelivery !== '납품완료일로부터 60일 이내 지급' && + formData.paymentDelivery !== '추가조건' && + (formData.paymentDelivery === 'L/C' || formData.paymentDelivery === 'T/T') && + paymentDeliveryPercent + ) { + paymentDeliveryToSave = `${paymentDeliveryPercent}% ${formData.paymentDelivery}` + } + console.log(paymentDeliveryToSave,"paymentDeliveryToSave") + const dataToSave = { ...formData, - paymentDelivery: (formData.paymentDelivery === 'L/C' || formData.paymentDelivery === 'T/T') && paymentDeliveryPercent - ? `${paymentDeliveryPercent}% ${formData.paymentDelivery}` - : formData.paymentDelivery + paymentDelivery: paymentDeliveryToSave, + // 추가조건 선택 시에만 추가 텍스트 저장, 그 외에는 빈 문자열 또는 undefined + paymentDeliveryAdditionalText: formData.paymentDelivery === '추가조건' + ? (formData.paymentDeliveryAdditionalText || '') + : '' } await updateContractBasicInfo(contractId, dataToSave, userId as number) @@ -1026,20 +1067,100 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
- + + + + + + + + + 검색 결과가 없습니다. + + {paymentTermsOptions.map((option) => ( + { + setFormData(prev => ({ ...prev, paymentDelivery: option.code })) + }} + > + + {option.code} {option.description && `(${option.description})`} + + ))} + { + setFormData(prev => ({ ...prev, paymentDelivery: '납품완료일로부터 60일 이내 지급' })) + }} + > + + 60일 이내 + + { + setFormData(prev => ({ ...prev, paymentDelivery: '추가조건' })) + }} + > + + 추가조건 + + + + + + {formData.paymentDelivery === '추가조건' && (
- {/* 지불조건 -> 세금조건 (지불조건 삭제됨) */} + {/*세금조건*/}
- {/* 지불조건 필드 삭제됨 -
- - -
- */}
- + + + + + + + + + 검색 결과가 없습니다. + + {TAX_CONDITIONS.map((condition) => ( + { + setFormData(prev => ({ ...prev, taxType: condition.code })) + }} + > + + {condition.name} + + ))} + + + + +
@@ -1266,79 +1393,178 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) { {/* 인도조건 */}
- + + + + + + + + + 검색 결과가 없습니다. + + {incotermsOptions.length > 0 ? ( + incotermsOptions.map((option) => ( + { + setFormData(prev => ({ ...prev, deliveryTerm: option.code })) + }} + > + + {option.code} {option.description && `(${option.description})`} + + )) + ) : ( + + 로딩중... + + )} + + + + +
{/* 선적지 */}
- + + + + + + + + + 검색 결과가 없습니다. + + {shippingPlaces.length > 0 ? ( + shippingPlaces.map((place) => ( + { + setFormData(prev => ({ ...prev, shippingLocation: place.code })) + }} + > + + {place.code} {place.description && `(${place.description})`} + + )) + ) : ( + + 로딩중... + + )} + + + + +
{/* 하역지 */}
- + + + + + + + + + 검색 결과가 없습니다. + + {destinationPlaces.length > 0 ? ( + destinationPlaces.map((place) => ( + { + setFormData(prev => ({ ...prev, dischargeLocation: place.code })) + }} + > + + {place.code} {place.description && `(${place.description})`} + + )) + ) : ( + + 로딩중... + + )} + + + + +
{/* 계약납기일 */} diff --git a/lib/general-contracts/detail/general-contract-items-table.tsx b/lib/general-contracts/detail/general-contract-items-table.tsx index 15e5c926..e5fc6cf2 100644 --- a/lib/general-contracts/detail/general-contract-items-table.tsx +++ b/lib/general-contracts/detail/general-contract-items-table.tsx @@ -30,6 +30,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from import { ProjectSelector } from '@/components/ProjectSelector' import { MaterialGroupSelectorDialogSingle } from '@/components/common/material/material-group-selector-dialog-single' import { MaterialSearchItem } from '@/lib/material/material-group-service' +import { ProcurementItemSelectorDialogSingle } from '@/components/common/selectors/procurement-item/procurement-item-selector-dialog-single' +import { ProcurementSearchItem } from '@/components/common/selectors/procurement-item/procurement-item-service' interface ContractItem { id?: number @@ -174,7 +176,7 @@ export function ContractItemsTable({ const errors: string[] = [] for (let index = 0; index < localItems.length; index++) { const item = localItems[index] - if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`) + // if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`) if (!item.itemInfo) errors.push(`${index + 1}번째 품목의 Item 정보`) if (!item.quantity || item.quantity <= 0) errors.push(`${index + 1}번째 품목의 수량`) if (!item.contractUnitPrice || item.contractUnitPrice <= 0) errors.push(`${index + 1}번째 품목의 단가`) @@ -271,6 +273,34 @@ export function ContractItemsTable({ onItemsChange(updatedItems) } + // 1회성 품목 선택 시 행 추가 + const handleOneTimeItemSelect = (item: ProcurementSearchItem | null) => { + if (!item) return + + const newItem: ContractItem = { + projectId: null, + itemCode: item.itemCode, + itemInfo: item.itemName, + materialGroupCode: '', + materialGroupDescription: '', + specification: item.specification || '', + quantity: 0, + quantityUnit: item.unit || 'EA', + totalWeight: 0, + weightUnit: 'KG', + contractDeliveryDate: '', + contractUnitPrice: 0, + contractAmount: 0, + contractCurrency: 'KRW', + isSelected: false + } + + const updatedItems = [...localItems, newItem] + setLocalItems(updatedItems) + onItemsChange(updatedItems) + toast.success('1회성 품목이 추가되었습니다.') + } + // 일괄입력 적용 const applyBatchInput = () => { if (localItems.length === 0) { @@ -382,6 +412,17 @@ export function ContractItemsTable({ 행 추가 +
diff --git a/lib/bidding/selection/bidding-item-table.tsx b/lib/bidding/selection/bidding-item-table.tsx index c101f7e7..aa2b34ec 100644 --- a/lib/bidding/selection/bidding-item-table.tsx +++ b/lib/bidding/selection/bidding-item-table.tsx @@ -2,10 +2,7 @@ import * as React from 'react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { - getPRItemsForBidding, - getVendorPricesForBidding -} from '@/lib/bidding/detail/service' +import { getBiddingSelectionItemsAndPrices } from '@/lib/bidding/service' import { formatNumber } from '@/lib/utils' import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' @@ -21,26 +18,55 @@ export function BiddingItemTable({ biddingId }: BiddingItemTableProps) { const [loading, setLoading] = React.useState(true) React.useEffect(() => { + let isMounted = true + const loadData = async () => { try { setLoading(true) - const [prItems, vendorPrices] = await Promise.all([ - getPRItemsForBidding(biddingId), - getVendorPricesForBidding(biddingId) - ]) - console.log('prItems', prItems) - console.log('vendorPrices', vendorPrices) - setData({ prItems, vendorPrices }) + const { prItems, vendorPrices } = await getBiddingSelectionItemsAndPrices(biddingId) + + if (isMounted) { + console.log('prItems', prItems) + console.log('vendorPrices', vendorPrices) + setData({ prItems, vendorPrices }) + } } catch (error) { console.error('Failed to load bidding items:', error) } finally { - setLoading(false) + if (isMounted) { + setLoading(false) + } } } loadData() + + return () => { + isMounted = false + } }, [biddingId]) + // Memoize calculations + const totals = React.useMemo(() => { + const { prItems } = data + return { + quantity: prItems.reduce((sum, item) => sum + Number(item.quantity || 0), 0), + weight: prItems.reduce((sum, item) => sum + Number(item.totalWeight || 0), 0), + targetAmount: prItems.reduce((sum, item) => sum + Number(item.targetAmount || 0), 0) + } + }, [data.prItems]) + + const vendorTotals = React.useMemo(() => { + const { vendorPrices } = data + return vendorPrices.map(vendor => { + const total = vendor.itemPrices.reduce((sum: number, item: any) => sum + Number(item.amount || 0), 0) + return { + companyId: vendor.companyId, + totalAmount: total + } + }) + }, [data.vendorPrices]) + if (loading) { return ( @@ -58,19 +84,6 @@ export function BiddingItemTable({ biddingId }: BiddingItemTableProps) { const { prItems, vendorPrices } = data - // Calculate Totals - const totalQuantity = prItems.reduce((sum, item) => sum + Number(item.quantity || 0), 0) - const totalWeight = prItems.reduce((sum, item) => sum + Number(item.totalWeight || 0), 0) - const totalTargetAmount = prItems.reduce((sum, item) => sum + Number(item.targetAmount || 0), 0) - - // Calculate Vendor Totals - const vendorTotals = vendorPrices.map(vendor => { - const total = vendor.itemPrices.reduce((sum: number, item: any) => sum + Number(item.amount || 0), 0) - return { - companyId: vendor.companyId, - totalAmount: total - } - }) return ( @@ -118,17 +131,17 @@ export function BiddingItemTable({ biddingId }: BiddingItemTableProps) { {/* Summary Row */} 합계 - {formatNumber(totalQuantity)} + {formatNumber(totals.quantity)} - - {formatNumber(totalWeight)} + {formatNumber(totals.weight)} - - - {formatNumber(totalTargetAmount)} + {formatNumber(totals.targetAmount)} KRW {vendorPrices.map((vendor) => { const vTotal = vendorTotals.find(t => t.companyId === vendor.companyId)?.totalAmount || 0 - const ratio = totalTargetAmount > 0 ? (vTotal / totalTargetAmount) * 100 : 0 + const ratio = totals.targetAmount > 0 ? (vTotal / totals.targetAmount) * 100 : 0 return ( - diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts index 27dae87d..453989c1 100644 --- a/lib/bidding/service.ts +++ b/lib/bidding/service.ts @@ -41,7 +41,8 @@ import { revalidatePath } from 'next/cache' import { filterColumns } from '@/lib/filter-columns' import { GetBiddingsSchema, CreateBiddingSchema } from './validation' import { saveFile } from '../file-stroage' - +import { getVendorPricesForBidding } from './detail/service' +import { getPrItemsForBidding } from './pre-quote/service' // 사용자 이메일로 사용자 코드 조회 @@ -3906,4 +3907,22 @@ export async function getBiddingsForFailure(input: GetBiddingsSchema) { console.error("Error in getBiddingsForFailure:", err) return { data: [], pageCount: 0, total: 0 } } -} \ No newline at end of file +} + + +export async function getBiddingSelectionItemsAndPrices(biddingId: number) { + try { + const [prItems, vendorPrices] = await Promise.all([ + getPrItemsForBidding(biddingId), + getVendorPricesForBidding(biddingId) + ]) + + return { + prItems, + vendorPrices + } + } catch (error) { + console.error('Failed to get bidding selection items and prices:', error) + throw error + } +} diff --git a/lib/general-contracts/detail/general-contract-items-table.tsx b/lib/general-contracts/detail/general-contract-items-table.tsx index e5fc6cf2..4f74cfbb 100644 --- a/lib/general-contracts/detail/general-contract-items-table.tsx +++ b/lib/general-contracts/detail/general-contract-items-table.tsx @@ -32,6 +32,7 @@ import { MaterialGroupSelectorDialogSingle } from '@/components/common/material/ import { MaterialSearchItem } from '@/lib/material/material-group-service' import { ProcurementItemSelectorDialogSingle } from '@/components/common/selectors/procurement-item/procurement-item-selector-dialog-single' import { ProcurementSearchItem } from '@/components/common/selectors/procurement-item/procurement-item-service' +import { cn } from '@/lib/utils' interface ContractItem { id?: number @@ -43,12 +44,12 @@ interface ContractItem { materialGroupCode?: string materialGroupDescription?: string specification: string - quantity: number + quantity: number | string // number | string으로 변경하여 입력 중 포맷팅 지원 quantityUnit: string - totalWeight: number + totalWeight: number | string // number | string으로 변경하여 입력 중 포맷팅 지원 weightUnit: string contractDeliveryDate: string - contractUnitPrice: number + contractUnitPrice: number | string // number | string으로 변경하여 입력 중 포맷팅 지원 contractAmount: number contractCurrency: string isSelected?: boolean @@ -105,6 +106,34 @@ export function ContractItemsTable({ contractUnitPrice: '' }) + // 천단위 콤마 포맷팅 헬퍼 함수들 + const formatNumberWithCommas = (value: string | number | null | undefined): string => { + if (value === null || value === undefined || value === '') return '' + const str = value.toString() + const parts = str.split('.') + const integerPart = parts[0].replace(/,/g, '') + + // 정수부가 비어있거나 '-' 만 있는 경우 처리 + if (integerPart === '' || integerPart === '-') { + return str + } + + const num = parseFloat(integerPart) + if (isNaN(num)) return str + + const formattedInt = num.toLocaleString() + + if (parts.length > 1) { + return `${formattedInt}.${parts[1]}` + } + + return formattedInt + } + + const parseNumberFromCommas = (value: string): string => { + return value.replace(/,/g, '') + } + // 초기 데이터 로드 React.useEffect(() => { const loadItems = async () => { @@ -125,6 +154,8 @@ export function ContractItemsTable({ } } + // number 타입을 string으로 변환하지 않고 일단 그대로 둠 (렌더링 시 포맷팅) + // 단, 입력 중 편의를 위해 string이 들어올 수 있으므로 ContractItem 타입 변경함 return { id: item.id, projectId: item.projectId || null, @@ -174,8 +205,17 @@ export function ContractItemsTable({ // validation 체크 const errors: string[] = [] - for (let index = 0; index < localItems.length; index++) { - const item = localItems[index] + // 저장 시 number로 변환된 데이터 준비 + const itemsToSave = localItems.map(item => ({ + ...item, + quantity: parseFloat(item.quantity.toString().replace(/,/g, '')) || 0, + totalWeight: parseFloat(item.totalWeight.toString().replace(/,/g, '')) || 0, + contractUnitPrice: parseFloat(item.contractUnitPrice.toString().replace(/,/g, '')) || 0, + contractAmount: parseFloat(item.contractAmount.toString().replace(/,/g, '')) || 0, + })); + + for (let index = 0; index < itemsToSave.length; index++) { + const item = itemsToSave[index] // if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`) if (!item.itemInfo) errors.push(`${index + 1}번째 품목의 Item 정보`) if (!item.quantity || item.quantity <= 0) errors.push(`${index + 1}번째 품목의 수량`) @@ -188,7 +228,7 @@ export function ContractItemsTable({ return } - await updateContractItems(contractId, localItems as any) + await updateContractItems(contractId, itemsToSave as any) toast.success('품목정보가 저장되었습니다.') } catch (error) { console.error('Error saving contract items:', error) @@ -199,9 +239,18 @@ export function ContractItemsTable({ } // 총 금액 계산 - const totalAmount = localItems.reduce((sum, item) => sum + item.contractAmount, 0) - const totalQuantity = localItems.reduce((sum, item) => sum + item.quantity, 0) - const totalUnitPrice = localItems.reduce((sum, item) => sum + item.contractUnitPrice, 0) + const totalAmount = localItems.reduce((sum, item) => { + const amount = parseFloat(item.contractAmount.toString().replace(/,/g, '')) || 0 + return sum + amount + }, 0) + const totalQuantity = localItems.reduce((sum, item) => { + const quantity = parseFloat(item.quantity.toString().replace(/,/g, '')) || 0 + return sum + quantity + }, 0) + const totalUnitPrice = localItems.reduce((sum, item) => { + const unitPrice = parseFloat(item.contractUnitPrice.toString().replace(/,/g, '')) || 0 + return sum + unitPrice + }, 0) const amountDifference = availableBudget - totalAmount const budgetRatio = availableBudget > 0 ? (totalAmount / availableBudget) * 100 : 0 @@ -213,12 +262,14 @@ export function ContractItemsTable({ // 아이템 업데이트 const updateItem = (index: number, field: keyof ContractItem, value: string | number | boolean | undefined) => { const updatedItems = [...localItems] - updatedItems[index] = { ...updatedItems[index], [field]: value } + const updatedItem = { ...updatedItems[index], [field]: value } + updatedItems[index] = updatedItem // 단가나 수량이 변경되면 금액 자동 계산 if (field === 'contractUnitPrice' || field === 'quantity') { - const item = updatedItems[index] - updatedItems[index].contractAmount = item.contractUnitPrice * item.quantity + const quantity = parseFloat(updatedItem.quantity.toString().replace(/,/g, '')) || 0 + const unitPrice = parseFloat(updatedItem.contractUnitPrice.toString().replace(/,/g, '')) || 0 + updatedItem.contractAmount = unitPrice * quantity } setLocalItems(updatedItems) @@ -326,7 +377,8 @@ export function ContractItemsTable({ if (batchInputData.contractUnitPrice) { updatedItem.contractUnitPrice = parseFloat(batchInputData.contractUnitPrice) || 0 // 단가가 변경되면 계약금액도 재계산 - updatedItem.contractAmount = updatedItem.contractUnitPrice * updatedItem.quantity + const quantity = parseFloat(updatedItem.quantity.toString().replace(/,/g, '')) || 0 + updatedItem.contractAmount = (parseFloat(batchInputData.contractUnitPrice) || 0) * quantity } return updatedItem @@ -712,14 +764,23 @@ export function ContractItemsTable({ )} */} + {readOnly ? ( + {item.quantity.toLocaleString()} + ) : ( updateItem(index, 'quantity', parseFloat(e.target.value) || 0)} + type="text" + value={formatNumberWithCommas(item.quantity)} + onChange={(e) => { + const val = parseNumberFromCommas(e.target.value) + if (val === '' || /^-?\d*\.?\d*$/.test(val)) { + updateItem(index, 'quantity', val) + } + }} className="h-8 text-sm text-right" placeholder="0" - disabled={!isEnabled} + disabled={!isEnabled || isQuantityDisabled} /> + )} {readOnly ? ( @@ -748,9 +809,14 @@ export function ContractItemsTable({ {item.totalWeight.toLocaleString()} ) : ( updateItem(index, 'totalWeight', parseFloat(e.target.value) || 0)} + type="text" + value={formatNumberWithCommas(item.totalWeight)} + onChange={(e) => { + const val = parseNumberFromCommas(e.target.value) + if (val === '' || /^-?\d*\.?\d*$/.test(val)) { + updateItem(index, 'totalWeight', val) + } + }} className="h-8 text-sm text-right" placeholder="0" disabled={!isEnabled || isQuantityDisabled} @@ -797,9 +863,14 @@ export function ContractItemsTable({ {item.contractUnitPrice.toLocaleString()} ) : ( updateItem(index, 'contractUnitPrice', parseFloat(e.target.value) || 0)} + type="text" + value={formatNumberWithCommas(item.contractUnitPrice)} + onChange={(e) => { + const val = parseNumberFromCommas(e.target.value) + if (val === '' || /^-?\d*\.?\d*$/.test(val)) { + updateItem(index, 'contractUnitPrice', val) + } + }} className="h-8 text-sm text-right" placeholder="0" disabled={!isEnabled} -- cgit v1.2.3 From e467b3b7905a200b98daa3787565c08a309a6dda Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 5 Dec 2025 09:18:28 +0000 Subject: (최겸) 계약 승인 요청 시 결재 상신 개발, 입찰 화학물질 soap 개발 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/general-contracts/upload-pdf/route.ts | 73 + db/schema/bidding.ts | 12 +- db/schema/generalContract.ts | 2 +- lib/approval/handlers-registry.ts | 7 +- ...\204\354\225\275 \352\262\260\354\236\254.html" | 3024 ++++++++++++++++++++ lib/bidding/actions.ts | 22 +- .../detail/table/price-adjustment-dialog.tsx | 10 +- lib/bidding/service.ts | 322 +++ lib/general-contracts/approval-actions.ts | 136 + .../approval-template-variables.ts | 369 +++ .../general-contract-approval-request-dialog.tsx | 163 +- lib/general-contracts/handlers.ts | 157 + lib/general-contracts/service.ts | 2 +- lib/soap/ecc/mapper/bidding-and-pr-mapper.ts | 6 +- lib/soap/ecc/send/chemical-substance-check.ts | 449 +++ 15 files changed, 4715 insertions(+), 39 deletions(-) create mode 100644 app/api/general-contracts/upload-pdf/route.ts create mode 100644 "lib/approval/templates/\354\235\274\353\260\230\352\263\204\354\225\275 \352\262\260\354\236\254.html" create mode 100644 lib/general-contracts/approval-actions.ts create mode 100644 lib/general-contracts/approval-template-variables.ts create mode 100644 lib/general-contracts/handlers.ts create mode 100644 lib/soap/ecc/send/chemical-substance-check.ts (limited to 'lib/general-contracts') diff --git a/app/api/general-contracts/upload-pdf/route.ts b/app/api/general-contracts/upload-pdf/route.ts new file mode 100644 index 00000000..9480f7f5 --- /dev/null +++ b/app/api/general-contracts/upload-pdf/route.ts @@ -0,0 +1,73 @@ +/** + * 일반계약 PDF 업로드 API + * 클라이언트에서 생성된 PDF를 서버에 저장 + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { saveBuffer } from '@/lib/file-stroage'; + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: '인증이 필요합니다' }, + { status: 401 } + ); + } + + const formData = await request.formData(); + const file = formData.get('file') as File; + const contractId = formData.get('contractId') as string; + + if (!file) { + return NextResponse.json( + { success: false, error: '파일이 제공되지 않았습니다' }, + { status: 400 } + ); + } + + // 파일을 ArrayBuffer로 읽기 + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + // saveBuffer 함수를 사용해서 파일 저장 + const saveResult = await saveBuffer({ + buffer: buffer, + fileName: `${Date.now()}_${file.name}`, + directory: "generalContracts", + originalName: file.name, + userId: session.user.id + }); + + if (!saveResult.success) { + return NextResponse.json( + { success: false, error: saveResult.error || 'PDF 파일 저장에 실패했습니다.' }, + { status: 500 } + ); + } + + const finalFilePath = saveResult.publicPath + ? saveResult.publicPath.replace('/api/files/', '') + : `/generalContracts/${saveResult.fileName}`; + + return NextResponse.json({ + success: true, + filePath: finalFilePath, + fileName: saveResult.fileName, + publicPath: saveResult.publicPath, + }); + } catch (error) { + console.error('PDF 업로드 오류:', error); + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'PDF 업로드 중 오류가 발생했습니다.' + }, + { status: 500 } + ); + } +} + diff --git a/db/schema/bidding.ts b/db/schema/bidding.ts index fa3f1df5..c5370174 100644 --- a/db/schema/bidding.ts +++ b/db/schema/bidding.ts @@ -176,8 +176,10 @@ export const biddings = pgTable('biddings', { // 일정 관리 preQuoteDate: date('pre_quote_date'), // 사전견적일 biddingRegistrationDate: date('bidding_registration_date'), // 입찰등록일 - submissionStartDate: timestamp('submission_start_date'), // 입찰서제출기간 시작 - submissionEndDate: timestamp('submission_end_date'), // 입찰서제출기간 끝 + submissionStartDate: timestamp('submission_start_date'), // 입찰서제출기간 시작 (시간만 저장, 결재완료 후 실제 날짜로 계산) + submissionEndDate: timestamp('submission_end_date'), // 입찰서제출기간 끝 (시간만 저장, 결재완료 후 실제 날짜로 계산) + submissionStartOffset: integer('submission_start_offset'), // 시작일 오프셋 (결재완료일 + n일) + submissionDurationDays: integer('submission_duration_days'), // 입찰 기간 (시작일 + n일) evaluationDate: timestamp('evaluation_date'), // 사양설명회 @@ -188,6 +190,7 @@ export const biddings = pgTable('biddings', { budget: decimal('budget', { precision: 15, scale: 2 }), // 예산 targetPrice: decimal('target_price', { precision: 15, scale: 2 }), // 내정가 targetPriceCalculationCriteria: text('target_price_calculation_criteria'), // 내정가 산정 기준 + actualPrice: decimal('actual_price', { precision: 15, scale: 2 }), // 실적가 finalBidPrice: decimal('final_bid_price', { precision: 15, scale: 2 }), // 최종입찰가 // PR 정보 @@ -403,6 +406,11 @@ export const biddingCompanies = pgTable('bidding_companies', { //연동제 적용요건 문의 여부 isPriceAdjustmentApplicableQuestion: boolean('is_price_adjustment_applicable_question').default(false), // 연동제 적용요건 문의 여부 + // SHI 연동제 적용여부 및 관련 정보 + shiPriceAdjustmentApplied: boolean('shi_price_adjustment_applied'), // SHI 연동제 적용여부 (null: 미정, true: 적용, false: 미적용) + priceAdjustmentNote: text('price_adjustment_note'), // 연동제 Note (textarea) + hasChemicalSubstance: boolean('has_chemical_substance'), // 화학물질여부 + // 기타 notes: text('notes'), // 특이사항 contactPerson: varchar('contact_person', { length: 100 }), // 업체 담당자 diff --git a/db/schema/generalContract.ts b/db/schema/generalContract.ts index 6f48581f..7cc6cd6e 100644 --- a/db/schema/generalContract.ts +++ b/db/schema/generalContract.ts @@ -37,7 +37,7 @@ export const generalContracts = pgTable('general_contracts', { // ═══════════════════════════════════════════════════════════════ // 계약 분류 및 상태 // ═══════════════════════════════════════════════════════════════ - status: varchar('status', { length: 50 }).notNull(), // 계약 상태 (Draft, Complete the Contract, Contract Delete 등) + status: varchar('status', { length: 50 }).notNull(), // 계약 상태 (Draft, Complete the Contract, Contract Delete, approval request 등) category: varchar('category', { length: 50 }).notNull(), // 계약구분 (단가계약, 일반계약, 매각계약) type: varchar('type', { length: 50 }), // 계약종류 (UP, LE, IL, AL 등) executionMethod: varchar('execution_method', { length: 50 }), // 체결방식 (오프라인, 온라인 등) diff --git a/lib/approval/handlers-registry.ts b/lib/approval/handlers-registry.ts index beb6b971..235c9b7b 100644 --- a/lib/approval/handlers-registry.ts +++ b/lib/approval/handlers-registry.ts @@ -40,9 +40,10 @@ export async function initializeApprovalHandlers() { // 벤더 가입 승인 핸들러 등록 (결재 승인 후 실행될 함수 approveVendorWithMDGInternal) registerActionHandler('vendor_approval', approveVendorWithMDGInternal); - // 5. 계약 승인 핸들러 - // const { approveContractInternal } = await import('@/lib/contract/handlers'); - // registerActionHandler('contract_approval', approveContractInternal); + // 5. 일반계약 승인 핸들러 + const { approveContractInternal } = await import('@/lib/general-contracts/handlers'); + // 일반계약 승인 핸들러 등록 (결재 승인 후 실행될 함수 approveContractInternal) + registerActionHandler('general_contract_approval', approveContractInternal); // 6. RFQ 발송 핸들러 (첨부파일이 있는 경우) const { sendRfqWithApprovalInternal } = await import('@/lib/rfq-last/approval-handlers'); diff --git "a/lib/approval/templates/\354\235\274\353\260\230\352\263\204\354\225\275 \352\262\260\354\236\254.html" "b/lib/approval/templates/\354\235\274\353\260\230\352\263\204\354\225\275 \352\262\260\354\236\254.html" new file mode 100644 index 00000000..99389030 --- /dev/null +++ "b/lib/approval/templates/\354\235\274\353\260\230\352\263\204\354\225\275 \352\262\260\354\236\254.html" @@ -0,0 +1,3024 @@ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + 계약 체결 진행 품의 요청서 (구매성) + +
+ + *결재 완료 후 계약 체결을 진행할 수 있습니다. + +
+ + * 본 계약은 계약 갱신이 불필요하여 만료 알림이 설정되지 않았습니다. + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + ■ 계약 기본 정보 + +
+ + 계약번호 + + + + {{계약번호}} + + + + 계약명 + + + + {{계약명}} + + + + 계약체결방식 + + + + {{계약체결방식}} + +
+ + 계약종류 + + + + {{계약종류}} + + + + 구매담당자 + + + + {{구매담당자}} + + + + 업체선정방식 + + + + {{업체선정방식}} + +
+ + 입찰번호 + + + + {{입찰번호}} + + + + 입찰명 + + + + {{입찰명}} + + + + 계약기간 + + + + {{계약기간}} + +
+ + 계약일자 + + + + {{계약일자}} + + + + 매입 부가가치세 + + + + {{매입_부가가치세}} + + + + 계약 담당자 + + + + {{계약_담당자}} + +
+ + 계약부서 + + + + {{계약부서}} + + + + 계약 금액 + + + + {{계약금액}} + + + + SHI 지급조건 + + + + {{SHI_지급조건}} + +
+ + SHI 인도조건 + + + + {{SHI_인도조건}} + + + + SHI 인도조건(옵션) + + + + {{SHI_인도조건_옵션}} + + + + 선적지 + + + + {{선적지}} + +
+ + 하역지 + + + + {{하역지}} + + + + 사외업체 야드 투입 여부 + + + + {{사외업체_야드_투입여부}} + + + + 프로젝트 + + + + {{프로젝트}} + +
+ + 직종 + + + + {{직종}} + + + + 재하도 협력사 + + + + {{재하도_협력사}} + +
+ + 계약 내용 + + + + {{계약내용}} + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + ■ 계약 협력사 및 담당자 정보 + +
+ + 협력사 코드 + + + + 협력사명 + + + + 담당자 + + + + 전화번호 + + + + 이메일 + + + + 비고 + +
{{협력사코드}}{{협력사명}}{{협력사_담당자}}{{전화번호}}{{이메일}}{{비고}}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + ■ 계약 대상 자재 정보 (총 {{대상_자재_수}}건 - 결재본문 내 표시 자재는 100건 이하로 제한되어 있습니다) + +
+ + 순번 + + + + 플랜트 + + + + 프로젝트 + + + + 자재그룹 + + + + 자재그룹명 + + + + 자재번호 + + + + 자재상세 + + + + 연간단가 여부 + + + + 수량 + + + + 구매단위 + + + + 계약단가 + + + + 수량단위 + + + + 총중량 + + + + 중량단위 + + + + 계약금액 + +
1{{플랜트_1}}{{프로젝트_1}}{{자재그룹_1}}{{자재그룹명_1}}{{자재번호_1}}{{자재상세_1}}{{연간단가여부_1}}{{수량_1}}{{구매단위_1}}{{계약단가_1}}{{수량단위_1}}{{총중량_1}}{{중량단위_1}}{{계약금액_1}}
2{{플랜트_2}}{{프로젝트_2}}{{자재그룹_2}}{{자재그룹명_2}}{{자재번호_2}}{{자재상세_2}}{{연간단가여부_2}}{{수량_2}}{{구매단위_2}}{{계약단가_2}}{{수량단위_2}}{{총중량_2}}{{중량단위_2}}{{계약금액_2}}
총 계약 금액{{총_계약금액}}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + ■ 하도급 자율점검 Check List + +
+ + 계약 시 [계약체결 단계] + +
+ + 작업 前
서면발급 + +
+ + 1. 계약서면 발급 + + + + 2. 부당하도급대
금 결정 행위
(대금결정방법) + +
+ + 점검결과
"준수"
"위반"
"위반의심" + +
+ + 위반/위반의심 시 a~c 작성 欄 + +
+ + 6대 법정 기재사항 명기 여부 + +
+ + ①위탁일자
/위탁내용 + +
+ + ②인도시기
/장소 + +
+ + ③검사방법
/시기 + +
+ + ④대금지급
방법/기일 + +
+ + ⑤원재료지급
방법/기일 + +
+ + ⑥원재료가격변동
에 따른 대금조정 등 + +
+ + a. 귀책부서 + + + + b. 원인 + + + + c. 대책 + +
+ + {{작업전_서면발급_체크}} + + + + {{기재사항_1}} + + + + {{기재사항_2}} + + + + {{기재사항_3}} + + + + {{기재사항_4}} + + + + {{기재사항_5}} + + + + {{기재사항_6}} + + + + {{부당대금_결정}} + + + + {{점검결과}} + + + + {{귀책부서}} + + + + {{원인}} + + + + {{대책}} + +
+ +
+ diff --git a/lib/bidding/actions.ts b/lib/bidding/actions.ts index 6bedbab5..64dc3aa8 100644 --- a/lib/bidding/actions.ts +++ b/lib/bidding/actions.ts @@ -20,7 +20,7 @@ import { createPurchaseOrder } from "@/lib/soap/ecc/send/create-po-bidding" import { getCurrentSAPDate } from "@/lib/soap/utils" import { generateContractNumber } from "@/lib/general-contracts/service" import { saveFile } from "@/lib/file-stroage" - +import { checkAndSaveChemicalSubstancesForBidding } from "./service" // TO Contract export async function transmitToContract(biddingId: number, userId: number) { try { @@ -738,6 +738,26 @@ export async function openBiddingAction(biddingId: number) { }) .where(eq(biddings.id, biddingId)) + // 4. 화학물질 조회 실행 (비동기로 실행해서 개찰 성능에 영향 없도록) + try { + // 개찰 트랜잭션이 완료된 후 화학물질 조회 시작 + setImmediate(async () => { + try { + const result = await checkAndSaveChemicalSubstancesForBidding(biddingId) + if (result.success) { + console.log(`입찰 ${biddingId} 화학물질 조회 완료: ${result.results.filter(r => r.success).length}/${result.results.length}개 업체`) + } else { + console.error(`입찰 ${biddingId} 화학물질 조회 실패:`, result.message) + } + } catch (error) { + console.error(`입찰 ${biddingId} 화학물질 조회 중 오류:`, error) + } + }) + } catch (error) { + // 화학물질 조회 실패해도 개찰은 성공으로 처리 + console.error('화학물질 조회 시작 실패:', error) + } + return { success: true, message: isDeadlinePassed ? '개찰이 완료되었습니다.' : '조기개찰이 완료되었습니다.' } }) diff --git a/lib/bidding/detail/table/price-adjustment-dialog.tsx b/lib/bidding/detail/table/price-adjustment-dialog.tsx index 14bbd843..96a3af0c 100644 --- a/lib/bidding/detail/table/price-adjustment-dialog.tsx +++ b/lib/bidding/detail/table/price-adjustment-dialog.tsx @@ -94,13 +94,13 @@ export function PriceAdjustmentDialog({ 연동제 적용 설정 - {vendor.vendorName} 업체의 연동제 적용 여부 및 화학물질 정보를 설정합니다. + {vendor.vendorName} 업체의 연동제 적용 여부를 설정합니다.
{/* 업체가 제출한 연동제 요청 여부 (읽기 전용) */} -
+ {/*

@@ -110,7 +110,7 @@ export function PriceAdjustmentDialog({ {vendor.isPriceAdjustmentApplicableQuestion === null ? '미정' : vendor.isPriceAdjustmentApplicableQuestion ? '예' : '아니오'} -

+
*/} {/* SHI 연동제 적용여부 */}
@@ -147,7 +147,7 @@ export function PriceAdjustmentDialog({
{/* 화학물질 여부 */} -
+ {/*

@@ -166,7 +166,7 @@ export function PriceAdjustmentDialog({ 해당

-
+
*/}
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts index d45e9286..71ee01ab 100644 --- a/lib/bidding/service.ts +++ b/lib/bidding/service.ts @@ -44,6 +44,7 @@ import { saveFile, saveBuffer } from '../file-stroage' import { decryptBufferWithServerAction } from '@/components/drm/drmUtils' import { getVendorPricesForBidding } from './detail/service' import { getPrItemsForBidding } from './pre-quote/service' +import { checkChemicalSubstance, checkMultipleChemicalSubstances, type ChemicalSubstanceResult } from '@/lib/soap/ecc/send/chemical-substance-check' // 사용자 이메일로 사용자 코드 조회 @@ -3987,3 +3988,324 @@ export async function getBiddingSelectionItemsAndPrices(biddingId: number) { throw error } } + +// ======================================== +// 화학물질 조회 및 저장 관련 함수들 +// ======================================== + +/** + * 입찰 참여업체의 화학물질 정보를 조회하고 DB에 저장 + */ +// export async function checkAndSaveChemicalSubstanceForBiddingCompany(biddingCompanyId: number) { +// try { +// // 입찰 참여업체 정보 조회 (벤더 정보 포함) +// const biddingCompanyInfo = await db +// .select({ +// id: biddingCompanies.id, +// biddingId: biddingCompanies.biddingId, +// companyId: biddingCompanies.companyId, +// hasChemicalSubstance: biddingCompanies.hasChemicalSubstance, +// vendors: { +// vendorCode: vendors.vendorCode +// } +// }) +// .from(biddingCompanies) +// .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) +// .where(eq(biddingCompanies.id, biddingCompanyId)) +// .limit(1) + +// if (!biddingCompanyInfo[0]) { +// throw new Error(`입찰 참여업체를 찾을 수 없습니다: ${biddingCompanyId}`) +// } + +// const companyInfo = biddingCompanyInfo[0] + +// // 이미 화학물질 검사가 완료된 경우 스킵 +// if (companyInfo.hasChemicalSubstance !== null && companyInfo.hasChemicalSubstance !== undefined) { +// console.log(`이미 화학물질 검사가 완료된 입찰 참여업체: ${biddingCompanyId}`) +// return { +// success: true, +// message: '이미 화학물질 검사가 완료되었습니다.', +// hasChemicalSubstance: companyInfo.hasChemicalSubstance +// } +// } + +// // 벤더 코드가 없는 경우 스킵 +// if (!companyInfo.vendors?.vendorCode) { +// console.log(`벤더 코드가 없는 입찰 참여업체: ${biddingCompanyId}`) +// return { +// success: false, +// message: '벤더 코드가 없습니다.' +// } +// } + +// // 입찰의 PR 아이템들 조회 (자재번호 있는 것만) +// const prItems = await db +// .select({ +// id: prItemsForBidding.id, +// materialNumber: prItemsForBidding.materialNumber +// }) +// .from(prItemsForBidding) +// .where(and( +// eq(prItemsForBidding.biddingId, companyInfo.biddingId), +// isNotNull(prItemsForBidding.materialNumber), +// sql`${prItemsForBidding.materialNumber} != ''` +// )) + +// if (prItems.length === 0) { +// console.log(`자재번호가 있는 PR 아이템이 없는 입찰: ${companyInfo.biddingId}`) +// return { +// success: false, +// message: '조회할 자재가 없습니다.' +// } +// } + +// // 각 자재에 대해 화학물질 조회 +// let hasAnyChemicalSubstance = false +// const results: Array<{ materialNumber: string; hasChemicalSubstance: boolean; message: string }> = [] + +// for (const prItem of prItems) { +// try { +// const checkResult = await checkChemicalSubstance({ +// bukrs: 'H100', // 회사코드는 H100 고정 +// werks: 'PM11', // WERKS는 PM11 고정 +// lifnr: companyInfo.vendors.vendorCode, +// matnr: prItem.materialNumber! +// }) + +// if (checkResult.success) { +// const itemHasChemical = checkResult.hasChemicalSubstance || false +// hasAnyChemicalSubstance = hasAnyChemicalSubstance || itemHasChemical + +// results.push({ +// materialNumber: prItem.materialNumber!, +// hasChemicalSubstance: itemHasChemical, +// message: checkResult.message || '조회 성공' +// }) +// } else { +// results.push({ +// materialNumber: prItem.materialNumber!, +// hasChemicalSubstance: false, +// message: checkResult.message || '조회 실패' +// }) +// } + +// // API 호출 간 지연 +// await new Promise(resolve => setTimeout(resolve, 500)) + +// } catch (error) { +// results.push({ +// materialNumber: prItem.materialNumber!, +// hasChemicalSubstance: false, +// message: error instanceof Error ? error.message : 'Unknown error' +// }) +// } +// } + +// // 하나라도 Y(Y=true)이면 true, 모두 N(false)이면 false +// const finalHasChemicalSubstance = hasAnyChemicalSubstance + +// // DB에 결과 저장 +// await db +// .update(biddingCompanies) +// .set({ +// hasChemicalSubstance: finalHasChemicalSubstance, +// updatedAt: new Date() +// }) +// .where(eq(biddingCompanies.id, biddingCompanyId)) + +// console.log(`화학물질 정보 저장 완료: 입찰 참여업체 ${biddingCompanyId}, 화학물질 ${finalHasChemicalSubstance ? '있음' : '없음'} (${results.filter(r => r.hasChemicalSubstance).length}/${results.length})`) + +// return { +// success: true, +// message: `화학물질 조회 및 저장이 완료되었습니다. (${results.filter(r => r.hasChemicalSubstance).length}/${results.length}개 자재에 화학물질 있음)`, +// hasChemicalSubstance: finalHasChemicalSubstance, +// results +// } + +// } catch (error) { +// console.error(`화학물질 조회 실패 (입찰 참여업체 ${biddingCompanyId}):`, error) +// return { +// success: false, +// message: error instanceof Error ? error.message : 'Unknown error', +// hasChemicalSubstance: null, +// results: [] +// } +// } +// } + +/** + * 입찰의 모든 참여업체에 대한 화학물질 정보를 일괄 조회하고 저장 + */ +export async function checkAndSaveChemicalSubstancesForBidding(biddingId: number) { + try { + // 입찰의 모든 참여업체 조회 (벤더 코드 있는 것만) + const biddingCompaniesList = await db + .select({ + id: biddingCompanies.id, + companyId: biddingCompanies.companyId, + hasChemicalSubstance: biddingCompanies.hasChemicalSubstance, + vendors: { + vendorCode: vendors.vendorCode, + vendorName: vendors.vendorName + } + }) + .from(biddingCompanies) + .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + isNotNull(vendors.vendorCode), + sql`${vendors.vendorCode} != ''` + )) + + if (biddingCompaniesList.length === 0) { + return { + success: true, + message: '벤더 코드가 있는 참여업체가 없습니다.', + results: [] + } + } + + // 입찰의 PR 아이템들 조회 (자재번호 있는 것만) + const prItems = await db + .select({ + materialNumber: prItemsForBidding.materialNumber + }) + .from(prItemsForBidding) + .where(and( + eq(prItemsForBidding.biddingId, biddingId), + isNotNull(prItemsForBidding.materialNumber), + sql`${prItemsForBidding.materialNumber} != ''` + )) + + if (prItems.length === 0) { + return { + success: false, + message: '조회할 자재가 없습니다.', + results: [] + } + } + + const materialNumbers = prItems.map(item => item.materialNumber!).filter(Boolean) + + // 각 참여업체에 대해 화학물질 조회 + const results: Array<{ + biddingCompanyId: number; + vendorCode: string; + vendorName: string; + success: boolean; + hasChemicalSubstance?: boolean; + message: string; + materialResults?: Array<{ materialNumber: string; hasChemicalSubstance: boolean; message: string }>; + }> = [] + + for (const biddingCompany of biddingCompaniesList) { + try { + // 이미 검사가 완료된 경우 스킵 + if (biddingCompany.hasChemicalSubstance !== null && biddingCompany.hasChemicalSubstance !== undefined) { + results.push({ + biddingCompanyId: biddingCompany.id, + vendorCode: biddingCompany.vendors!.vendorCode!, + vendorName: biddingCompany.vendors!.vendorName || '', + success: true, + hasChemicalSubstance: biddingCompany.hasChemicalSubstance, + message: '이미 검사가 완료되었습니다.' + }) + continue + } + + // 각 자재에 대해 화학물질 조회 + let hasAnyChemicalSubstance = false + const materialResults: Array<{ materialNumber: string; hasChemicalSubstance: boolean; message: string }> = [] + + for (const materialNumber of materialNumbers) { + try { + const checkResult = await checkChemicalSubstance({ + bukrs: 'H100', // 회사코드는 H100 고정 + werks: 'PM11', // WERKS는 PM11 고정 + lifnr: biddingCompany.vendors!.vendorCode!, + matnr: materialNumber + }) + + if (checkResult.success) { + const itemHasChemical = checkResult.hasChemicalSubstance || false + hasAnyChemicalSubstance = hasAnyChemicalSubstance || itemHasChemical + + materialResults.push({ + materialNumber, + hasChemicalSubstance: itemHasChemical, + message: checkResult.message || '조회 성공' + }) + } else { + materialResults.push({ + materialNumber, + hasChemicalSubstance: false, + message: checkResult.message || '조회 실패' + }) + } + + // API 호출 간 지연 + await new Promise(resolve => setTimeout(resolve, 500)) + + } catch (error) { + materialResults.push({ + materialNumber, + hasChemicalSubstance: false, + message: error instanceof Error ? error.message : 'Unknown error' + }) + } + } + + // 하나라도 Y이면 true, 모두 N이면 false + const finalHasChemicalSubstance = hasAnyChemicalSubstance + + // DB에 결과 저장 + await db + .update(biddingCompanies) + .set({ + hasChemicalSubstance: finalHasChemicalSubstance, + updatedAt: new Date() + }) + .where(eq(biddingCompanies.id, biddingCompany.id)) + + results.push({ + biddingCompanyId: biddingCompany.id, + vendorCode: biddingCompany.vendors!.vendorCode!, + vendorName: biddingCompany.vendors!.vendorName || '', + success: true, + hasChemicalSubstance: finalHasChemicalSubstance, + message: `조회 완료 (${materialResults.filter(r => r.hasChemicalSubstance).length}/${materialResults.length}개 자재에 화학물질 있음)`, + materialResults + }) + + } catch (error) { + results.push({ + biddingCompanyId: biddingCompany.id, + vendorCode: biddingCompany.vendors!.vendorCode!, + vendorName: biddingCompany.vendors!.vendorName || '', + success: false, + message: error instanceof Error ? error.message : 'Unknown error' + }) + } + } + + const successCount = results.filter(r => r.success).length + const totalCount = results.length + + console.log(`입찰 ${biddingId} 화학물질 일괄 조회 완료: ${successCount}/${totalCount} 성공`) + + return { + success: true, + message: `화학물질 일괄 조회 완료: ${successCount}/${totalCount} 성공`, + results + } + + } catch (error) { + console.error(`입찰 화학물질 일괄 조회 실패 (${biddingId}):`, error) + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error', + results: [] + } + } +} diff --git a/lib/general-contracts/approval-actions.ts b/lib/general-contracts/approval-actions.ts new file mode 100644 index 00000000..e75d6cd6 --- /dev/null +++ b/lib/general-contracts/approval-actions.ts @@ -0,0 +1,136 @@ +/** + * 일반계약 관련 결재 서버 액션 + * + * 사용자가 UI에서 호출하는 함수들 + * ApprovalSubmissionSaga를 사용하여 결재 프로세스를 시작 + */ + +'use server'; + +import { ApprovalSubmissionSaga } from '@/lib/approval'; +import { mapContractToApprovalTemplateVariables } from './approval-template-variables'; +import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils'; +import db from '@/db/db'; +import { eq } from 'drizzle-orm'; +import { generalContracts } from '@/db/schema/generalContract'; +import { users } from '@/db/schema'; + +interface ContractSummary { + basicInfo: Record; + items: Record[]; + subcontractChecklist: Record | null; + storageInfo?: Record[]; + pdfPath?: string; + basicContractPdfs?: Array<{ key: string; buffer: number[]; fileName: string }>; +} + +/** + * 결재를 거쳐 일반계약 승인 요청을 처리하는 서버 액션 + * + * 사용법 (클라이언트 컴포넌트에서): + * ```typescript + * const result = await requestContractApprovalWithApproval({ + * contractId: 123, + * contractSummary: summaryData, + * currentUser: { id: 1, epId: 'EP001', email: 'user@example.com' }, + * approvers: ['EP002', 'EP003'], + * title: '계약 체결 진행 품의 요청서' + * }); + * + * if (result.status === 'pending_approval') { + * console.log('결재 ID:', result.approvalId); + * } + * ``` + */ +export async function requestContractApprovalWithApproval(data: { + contractId: number; + contractSummary: ContractSummary; + currentUser: { id: number; epId: string | null; email?: string }; + approvers?: string[]; // Knox EP ID 배열 (결재선) + title?: string; // 결재 제목 (선택사항, 미지정 시 자동 생성) +}) { + debugLog('[ContractApproval] 일반계약 승인 요청 결재 서버 액션 시작', { + contractId: data.contractId, + contractNumber: data.contractSummary.basicInfo?.contractNumber, + contractName: data.contractSummary.basicInfo?.name, + userId: data.currentUser.id, + hasEpId: !!data.currentUser.epId, + }); + + // 입력 검증 + if (!data.currentUser.epId) { + debugError('[ContractApproval] Knox EP ID 없음'); + throw new Error('Knox EP ID가 필요합니다'); + } + + if (!data.contractId) { + debugError('[ContractApproval] 계약 ID 없음'); + throw new Error('계약 ID가 필요합니다'); + } + + // 1. 유저의 nonsapUserId 조회 (Cronjob 환경을 위해) + debugLog('[ContractApproval] nonsapUserId 조회'); + const userResult = await db.query.users.findFirst({ + where: eq(users.id, data.currentUser.id), + columns: { nonsapUserId: true } + }); + const nonsapUserId = userResult?.nonsapUserId || null; + debugLog('[ContractApproval] nonsapUserId 조회 완료', { nonsapUserId }); + + // 2. 템플릿 변수 매핑 + debugLog('[ContractApproval] 템플릿 변수 매핑 시작'); + const variables = await mapContractToApprovalTemplateVariables(data.contractSummary); + debugLog('[ContractApproval] 템플릿 변수 매핑 완료', { + variableKeys: Object.keys(variables), + }); + + // 3. 결재 워크플로우 시작 (Saga 패턴) + debugLog('[ContractApproval] ApprovalSubmissionSaga 생성'); + const saga = new ApprovalSubmissionSaga( + // actionType: 핸들러를 찾을 때 사용할 키 + 'general_contract_approval', + + // actionPayload: 결재 승인 후 핸들러에 전달될 데이터 + { + contractId: data.contractId, + contractSummary: data.contractSummary, + currentUser: { + id: data.currentUser.id, + email: data.currentUser.email, + nonsapUserId: nonsapUserId, + }, + }, + + // approvalConfig: 결재 상신 정보 (템플릿 포함) + { + title: data.title || `계약 체결 진행 품의 요청서 - ${data.contractSummary.basicInfo?.contractNumber || data.contractId}`, + description: `${data.contractSummary.basicInfo?.name || '일반계약'} 계약 체결 진행 품의 요청`, + templateName: '일반계약 결재', // 한국어 템플릿명 + variables, // 치환할 변수들 + approvers: data.approvers, + currentUser: data.currentUser, + } + ); + + debugLog('[ContractApproval] Saga 실행 시작'); + const result = await saga.execute(); + + // 4. 결재 상신 성공 시 상태를 'approval_in_progress'로 변경 + if (result.status === 'pending_approval') { + debugLog('[ContractApproval] 상태를 approval_in_progress로 변경'); + await db.update(generalContracts) + .set({ + status: 'approval_in_progress', + lastUpdatedAt: new Date() + }) + .where(eq(generalContracts.id, data.contractId)); + } + + debugSuccess('[ContractApproval] 결재 워크플로우 완료', { + approvalId: result.approvalId, + status: result.status, + }); + + return result; +} + diff --git a/lib/general-contracts/approval-template-variables.ts b/lib/general-contracts/approval-template-variables.ts new file mode 100644 index 00000000..6924694e --- /dev/null +++ b/lib/general-contracts/approval-template-variables.ts @@ -0,0 +1,369 @@ +/** + * 일반계약 결재 템플릿 변수 매핑 함수 + * + * 제공된 HTML 템플릿의 변수명에 맞춰 매핑 + */ + +'use server'; + +import { format } from 'date-fns'; + +interface ContractSummary { + basicInfo: Record; + items: Record[]; + subcontractChecklist: Record | null; + storageInfo?: Record[]; +} + +/** + * 일반계약 데이터를 결재 템플릿 변수로 매핑 + * + * @param contractSummary - 계약 요약 정보 + * @returns 템플릿 변수 객체 (Record) + */ +export async function mapContractToApprovalTemplateVariables( + contractSummary: ContractSummary +): Promise> { + const { basicInfo, items, subcontractChecklist } = contractSummary; + + // 날짜 포맷팅 헬퍼 + const formatDate = (date: any) => { + if (!date) return ''; + try { + const d = new Date(date); + if (isNaN(d.getTime())) return String(date); + return format(d, 'yyyy-MM-dd'); + } catch { + return String(date || ''); + } + }; + + // 금액 포맷팅 헬퍼 + const formatCurrency = (amount: any) => { + if (amount === undefined || amount === null || amount === '') return ''; + const num = Number(amount); + if (isNaN(num)) return String(amount); + return num.toLocaleString('ko-KR'); + }; + + // 계약기간 포맷팅 + const contractPeriod = basicInfo.startDate && basicInfo.endDate + ? `${formatDate(basicInfo.startDate)} ~ ${formatDate(basicInfo.endDate)}` + : ''; + + // 계약체결방식 + const contractExecutionMethod = basicInfo.executionMethod || ''; + + // 계약종류 + const contractType = basicInfo.type || ''; + + // 업체선정방식 + const vendorSelectionMethod = basicInfo.contractSourceType || ''; + + // 매입 부가가치세 + const taxType = basicInfo.taxType || ''; + + // SHI 지급조건 + const paymentTerm = basicInfo.paymentTerm || ''; + + // SHI 인도조건 + const deliveryTerm = basicInfo.deliveryTerm || ''; + const deliveryType = basicInfo.deliveryType || ''; + + // 사외업체 야드 투입 여부 + const externalYardEntry = basicInfo.externalYardEntry === 'Y' ? '예' : '아니오'; + + // 직종 + const workType = basicInfo.workType || ''; + + // 재하도 협력사 + const subcontractVendor = basicInfo.subcontractVendorName || ''; + + // 계약 내용 + const contractContent = basicInfo.notes || basicInfo.name || ''; + + // 계약성립조건 + let establishmentConditionsText = ''; + if (basicInfo.contractEstablishmentConditions) { + try { + const cond = typeof basicInfo.contractEstablishmentConditions === 'string' + ? JSON.parse(basicInfo.contractEstablishmentConditions) + : basicInfo.contractEstablishmentConditions; + + const active: string[] = []; + if (cond.regularVendorRegistration) active.push('정규업체 등록(실사 포함) 시'); + if (cond.projectAward) active.push('프로젝트 수주 시'); + if (cond.ownerApproval) active.push('선주 승인 시'); + if (cond.other) active.push('기타'); + establishmentConditionsText = active.join(', '); + } catch (e) { + console.warn('계약성립조건 파싱 실패:', e); + } + } + + // 계약해지조건 + let terminationConditionsText = ''; + if (basicInfo.contractTerminationConditions) { + try { + const cond = typeof basicInfo.contractTerminationConditions === 'string' + ? JSON.parse(basicInfo.contractTerminationConditions) + : basicInfo.contractTerminationConditions; + + const active: string[] = []; + if (cond.standardTermination) active.push('표준 계약해지조건'); + if (cond.projectNotAwarded) active.push('프로젝트 미수주 시'); + if (cond.other) active.push('기타'); + terminationConditionsText = active.join(', '); + } catch (e) { + console.warn('계약해지조건 파싱 실패:', e); + } + } + + // 협력사 정보 + const vendorCode = basicInfo.vendorCode || ''; + const vendorName = basicInfo.vendorName || ''; + const vendorContactPerson = basicInfo.vendorContactPerson || ''; + const vendorPhone = basicInfo.vendorPhone || ''; + const vendorEmail = basicInfo.vendorEmail || ''; + const vendorNote = ''; + + // 자재 정보 (최대 100건) + const materialItems = items.slice(0, 100); + const materialCount = items.length; + + // 보증 정보 + const guarantees: Array<{ + type: string; + order: number; + bondNumber: string; + rate: string; + amount: string; + period: string; + startDate: string; + endDate: string; + issuer: string; + }> = []; + + // 계약보증 + if (basicInfo.contractBond) { + const bond = typeof basicInfo.contractBond === 'string' + ? JSON.parse(basicInfo.contractBond) + : basicInfo.contractBond; + + if (bond && Array.isArray(bond)) { + bond.forEach((b: any, idx: number) => { + guarantees.push({ + type: '계약보증', + order: idx + 1, + bondNumber: b.bondNumber || '', + rate: b.rate ? `${b.rate}%` : '', + amount: formatCurrency(b.amount), + period: b.period || '', + startDate: formatDate(b.startDate), + endDate: formatDate(b.endDate), + issuer: b.issuer || '', + }); + }); + } + } + + // 지급보증 + if (basicInfo.paymentBond) { + const bond = typeof basicInfo.paymentBond === 'string' + ? JSON.parse(basicInfo.paymentBond) + : basicInfo.paymentBond; + + if (bond && Array.isArray(bond)) { + bond.forEach((b: any, idx: number) => { + guarantees.push({ + type: '지급보증', + order: idx + 1, + bondNumber: b.bondNumber || '', + rate: b.rate ? `${b.rate}%` : '', + amount: formatCurrency(b.amount), + period: b.period || '', + startDate: formatDate(b.startDate), + endDate: formatDate(b.endDate), + issuer: b.issuer || '', + }); + }); + } + } + + // 하자보증 + if (basicInfo.defectBond) { + const bond = typeof basicInfo.defectBond === 'string' + ? JSON.parse(basicInfo.defectBond) + : basicInfo.defectBond; + + if (bond && Array.isArray(bond)) { + bond.forEach((b: any, idx: number) => { + guarantees.push({ + type: '하자보증', + order: idx + 1, + bondNumber: b.bondNumber || '', + rate: b.rate ? `${b.rate}%` : '', + amount: formatCurrency(b.amount), + period: b.period || '', + startDate: formatDate(b.startDate), + endDate: formatDate(b.endDate), + issuer: b.issuer || '', + }); + }); + } + } + + // 보증 전체 비고 + const guaranteeNote = basicInfo.guaranteeNote || ''; + + // 하도급 체크리스트 + const checklistItems: Array<{ + category: string; + item1: string; + item2: string; + result: string; + department: string; + cause: string; + measure: string; + }> = []; + + if (subcontractChecklist) { + // 1-1. 작업 시 서면 발급 + checklistItems.push({ + category: '계약 시 [계약 체결 단계]', + item1: '1-1. 작업 시 서면 발급', + item2: '-', + result: subcontractChecklist.workDocumentIssued === '준수' ? '준수' : + subcontractChecklist.workDocumentIssued === '위반' ? '위반' : + subcontractChecklist.workDocumentIssued === '위반의심' ? '위반의심' : '', + department: subcontractChecklist.workDocumentIssuedDepartment || '', + cause: subcontractChecklist.workDocumentIssuedCause || '', + measure: subcontractChecklist.workDocumentIssuedMeasure || '', + }); + + // 1-2. 6대 법정 기재사항 명기 여부 + checklistItems.push({ + category: '계약 시 [계약 체결 단계]', + item1: '1-2. 6대 법정 기재사항 명기 여부', + item2: '-', + result: subcontractChecklist.sixLegalItems === '준수' ? '준수' : + subcontractChecklist.sixLegalItems === '위반' ? '위반' : + subcontractChecklist.sixLegalItems === '위반의심' ? '위반의심' : '', + department: subcontractChecklist.sixLegalItemsDepartment || '', + cause: subcontractChecklist.sixLegalItemsCause || '', + measure: subcontractChecklist.sixLegalItemsMeasure || '', + }); + + // 2. 부당 하도급 대금 결정 행위 + checklistItems.push({ + category: '계약 시 [계약 체결 단계]', + item1: '-', + item2: '2. 부당 하도급 대금 결정 행위 (대금 결정 방법)', + result: subcontractChecklist.unfairSubcontractPrice === '준수' ? '준수' : + subcontractChecklist.unfairSubcontractPrice === '위반' ? '위반' : + subcontractChecklist.unfairSubcontractPrice === '위반의심' ? '위반의심' : '', + department: subcontractChecklist.unfairSubcontractPriceDepartment || '', + cause: subcontractChecklist.unfairSubcontractPriceCause || '', + measure: subcontractChecklist.unfairSubcontractPriceMeasure || '', + }); + } + + // 총 계약 금액 계산 + const totalContractAmount = items.reduce((sum, item) => { + const amount = Number(item.contractAmount || item.totalLineAmount || 0); + return sum + (isNaN(amount) ? 0 : amount); + }, 0); + + // 변수 매핑 + const variables: Record = { + // 계약 기본 정보 + '계약번호': String(basicInfo.contractNumber || ''), + '계약명': String(basicInfo.name || basicInfo.contractName || ''), + '계약체결방식': String(contractExecutionMethod), + '계약종류': String(contractType), + '구매담당자': String(basicInfo.managerName || basicInfo.registeredByName || ''), + '업체선정방식': String(vendorSelectionMethod), + '입찰번호': String(basicInfo.linkedBidNumber || ''), + '입찰명': String(basicInfo.linkedBidName || ''), + '계약기간': contractPeriod, + '계약일자': formatDate(basicInfo.registeredAt || basicInfo.createdAt), + '매입_부가가치세': String(taxType), + '계약_담당자': String(basicInfo.managerName || basicInfo.registeredByName || ''), + '계약부서': String(basicInfo.departmentName || ''), + '계약금액': formatCurrency(basicInfo.contractAmount), + 'SHI_지급조건': String(paymentTerm), + 'SHI_인도조건': String(deliveryTerm), + 'SHI_인도조건_옵션': String(deliveryType), + '선적지': String(basicInfo.shippingLocation || ''), + '하역지': String(basicInfo.dischargeLocation || ''), + '사외업체_야드_투입여부': externalYardEntry, + '프로젝트': String(basicInfo.projectName || basicInfo.projectCode || ''), + '직종': String(workType), + '재하도_협력사': String(subcontractVendor), + '계약내용': String(contractContent), + '계약성립조건': establishmentConditionsText, + '계약해지조건': terminationConditionsText, + + // 협력사 정보 + '협력사코드': String(vendorCode), + '협력사명': String(vendorName), + '협력사_담당자': String(vendorContactPerson), + '전화번호': String(vendorPhone), + '이메일': String(vendorEmail), + '비고': String(vendorNote), + + // 자재 정보 + '대상_자재_수': String(materialCount), + }; + + // 자재 정보 변수 (최대 100건) + materialItems.forEach((item, index) => { + const idx = index + 1; + variables[`플랜트_${idx}`] = String(item.plant || ''); + variables[`프로젝트_${idx}`] = String(item.projectName || item.projectCode || ''); + variables[`자재그룹_${idx}`] = String(item.itemGroup || item.itemCode || ''); + variables[`자재그룹명_${idx}`] = String(item.itemGroupName || ''); + variables[`자재번호_${idx}`] = String(item.itemCode || ''); + variables[`자재상세_${idx}`] = String(item.itemInfo || item.description || ''); + variables[`연간단가여부_${idx}`] = String(item.isAnnualPrice ? '예' : '아니오'); + variables[`수량_${idx}`] = formatCurrency(item.quantity); + variables[`구매단위_${idx}`] = String(item.quantityUnit || ''); + variables[`계약단가_${idx}`] = formatCurrency(item.contractUnitPrice || item.unitPrice); + variables[`수량단위_${idx}`] = String(item.quantityUnit || ''); + variables[`총중량_${idx}`] = formatCurrency(item.totalWeight); + variables[`중량단위_${idx}`] = String(item.weightUnit || ''); + variables[`계약금액_${idx}`] = formatCurrency(item.contractAmount || item.totalLineAmount); + }); + + // 총 계약 금액 + variables['총_계약금액'] = formatCurrency(totalContractAmount); + + // 보증 정보 변수 + guarantees.forEach((guarantee, index) => { + const idx = index + 1; + const typeKey = String(guarantee.type); + variables[`${typeKey}_차수_${idx}`] = String(guarantee.order); + variables[`${typeKey}_증권번호_${idx}`] = String(guarantee.bondNumber || ''); + variables[`${typeKey}_보증금율_${idx}`] = String(guarantee.rate || ''); + variables[`${typeKey}_보증금액_${idx}`] = String(guarantee.amount || ''); + variables[`${typeKey}_보증기간_${idx}`] = String(guarantee.period || ''); + variables[`${typeKey}_시작일_${idx}`] = String(guarantee.startDate || ''); + variables[`${typeKey}_종료일_${idx}`] = String(guarantee.endDate || ''); + variables[`${typeKey}_발행기관_${idx}`] = String(guarantee.issuer || ''); + }); + + // 보증 전체 비고 + variables['보증_전체_비고'] = String(guaranteeNote); + + // 하도급 체크리스트 변수 + checklistItems.forEach((item, index) => { + const idx = index + 1; + variables[`점검결과_${idx === 1 ? '1_1' : idx === 2 ? '1_2' : '2'}`] = String(item.result); + variables[`귀책부서_${idx === 1 ? '1_1' : idx === 2 ? '1_2' : '2'}`] = String(item.department); + variables[`원인_${idx === 1 ? '1_1' : idx === 2 ? '1_2' : '2'}`] = String(item.cause); + variables[`대책_${idx === 1 ? '1_1' : idx === 2 ? '1_2' : '2'}`] = String(item.measure); + }); + + return variables; +} + diff --git a/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx b/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx index 46251c71..d44f4290 100644 --- a/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx +++ b/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx @@ -35,6 +35,9 @@ import { getStorageInfo } from '../service' import { mapContractDataToTemplateVariables } from '../utils' +import { ApprovalPreviewDialog } from '@/lib/approval/client' +import { requestContractApprovalWithApproval } from '../approval-actions' +import { mapContractToApprovalTemplateVariables } from '../approval-template-variables' interface ContractApprovalRequestDialogProps { contract: Record @@ -47,6 +50,8 @@ interface ContractSummary { items: Record[] subcontractChecklist: Record | null storageInfo?: Record[] + pdfPath?: string + basicContractPdfs?: Array<{ key: string; buffer: number[]; fileName: string }> } export function ContractApprovalRequestDialog({ @@ -72,6 +77,12 @@ export function ContractApprovalRequestDialog({ }>>([]) const [isLoadingBasicContracts, setIsLoadingBasicContracts] = useState(false) + // 결재 관련 상태 + const [approvalDialogOpen, setApprovalDialogOpen] = useState(false) + const [approvalVariables, setApprovalVariables] = useState>({}) + const [savedPdfPath, setSavedPdfPath] = useState(null) + const [savedBasicContractPdfs, setSavedBasicContractPdfs] = useState>([]) + const contractId = contract.id as number const userId = session?.user?.id || '' @@ -143,7 +154,7 @@ export function ContractApprovalRequestDialog({ await new Promise(resolve => setTimeout(resolve, 3000)); const fileData = await templateDoc.getFileData(); - const pdfBuffer = await Core.officeToPDFBuffer(fileData, { extension: 'docx' }); + const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' }); const fileName = `${contractType}_${contractSummary?.basicInfo?.vendorCode || vendorId}_${Date.now()}.pdf`; @@ -542,7 +553,42 @@ export function ContractApprovalRequestDialog({ console.log("🔄 PDF 미리보기 닫기 완료") } - // 최종 전송 + // PDF를 서버에 저장하는 함수 (API route 사용) + const savePdfToServer = async (pdfBuffer: Uint8Array, fileName: string): Promise => { + try { + // PDF 버퍼를 Blob으로 변환 + const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' }); + + // FormData 생성 + const formData = new FormData(); + formData.append('file', pdfBlob, fileName); + formData.append('contractId', String(contractId)); + + // API route로 업로드 + const response = await fetch('/api/general-contracts/upload-pdf', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'PDF 파일 저장에 실패했습니다.'); + } + + const result = await response.json(); + + if (!result.success) { + throw new Error(result.error || 'PDF 파일 저장에 실패했습니다.'); + } + + return result.filePath; + } catch (error) { + console.error('PDF 저장 실패:', error); + return null; + } + }; + + // 최종 전송 - 결재 프로세스 시작 const handleFinalSubmit = async () => { if (!generatedPdfUrl || !contractSummary || !generatedPdfBuffer) { toast.error('생성된 PDF가 필요합니다.') @@ -594,34 +640,77 @@ export function ContractApprovalRequestDialog({ } } - // 서버액션을 사용하여 계약승인요청 전송 - const result = await sendContractApprovalRequest( - contractSummary, + // PDF를 서버에 저장 + toast.info('PDF를 서버에 저장하는 중입니다...'); + const pdfPath = await savePdfToServer( generatedPdfBuffer, - 'contractDocument', - userId, - generatedBasicContractPdfs - ) + `contract_${contractId}_${Date.now()}.pdf` + ); - if (result.success) { - toast.success('계약승인요청이 전송되었습니다.') - onOpenChange(false) - } else { - // 서버에서 이미 처리된 에러 메시지 표시 - toast.error(result.error || '계약승인요청 전송 실패') - return + if (!pdfPath) { + toast.error('PDF 저장에 실패했습니다.'); + return; } + + setSavedPdfPath(pdfPath); + setSavedBasicContractPdfs(generatedBasicContractPdfs); + + // 결재 템플릿 변수 매핑 + const approvalVars = await mapContractToApprovalTemplateVariables(contractSummary); + setApprovalVariables(approvalVars); + + // 계약승인요청 dialog close + onOpenChange(false); + + // 결재 템플릿 dialog open + setApprovalDialogOpen(true); } catch (error: any) { - console.error('Error submitting approval request:', error) + console.error('Error preparing approval:', error); + toast.error('결재 준비 중 오류가 발생했습니다.') + } finally { + setIsLoading(false) + } + } - // 데이터베이스 중복 키 오류 처리 - if (error.message && error.message.includes('duplicate key value violates unique constraint')) { - toast.error('이미 존재하는 계약번호입니다. 다른 계약번호를 사용해주세요.') - return - } + // 결재 등록 처리 + const handleApprovalSubmit = async (data: { + approvers: string[]; + title: string; + attachments?: File[]; + }) => { + if (!contractSummary || !savedPdfPath) { + toast.error('계약 정보가 필요합니다.') + return + } - // 다른 오류에 대한 일반적인 처리 - toast.error('계약승인요청 전송 중 오류가 발생했습니다.') + setIsLoading(true) + try { + const result = await requestContractApprovalWithApproval({ + contractId, + contractSummary: { + ...contractSummary, + // PDF 경로를 contractSummary에 추가 + pdfPath: savedPdfPath || undefined, + basicContractPdfs: savedBasicContractPdfs.length > 0 ? savedBasicContractPdfs : undefined, + } as ContractSummary, + currentUser: { + id: Number(userId), + epId: session?.user?.epId || null, + email: session?.user?.email || undefined, + }, + approvers: data.approvers, + title: data.title, + }); + + if (result.status === 'pending_approval') { + toast.success('결재가 등록되었습니다.') + setApprovalDialogOpen(false); + } else { + toast.error('결재 등록에 실패했습니다.') + } + } catch (error: any) { + console.error('Error submitting approval:', error); + toast.error(`결재 등록 중 오류가 발생했습니다: ${error.message || '알 수 없는 오류'}`); } finally { setIsLoading(false) } @@ -1064,5 +1153,31 @@ export function ContractApprovalRequestDialog({ + + {/* 결재 미리보기 Dialog */} + {session?.user && session.user.epId && contractSummary && ( + { + setApprovalDialogOpen(open); + if (!open) { + setApprovalVariables({}); + setSavedPdfPath(null); + setSavedBasicContractPdfs([]); + } + }} + templateName="일반계약 결재" + variables={approvalVariables} + title={`계약 체결 진행 품의 요청서 - ${contractSummary.basicInfo?.contractNumber || contractId}`} + currentUser={{ + id: Number(session.user.id), + epId: session.user.epId, + name: session.user.name || undefined, + email: session.user.email || undefined, + }} + onConfirm={handleApprovalSubmit} + enableAttachments={false} + /> + )} )} \ No newline at end of file diff --git a/lib/general-contracts/handlers.ts b/lib/general-contracts/handlers.ts new file mode 100644 index 00000000..029fb9cd --- /dev/null +++ b/lib/general-contracts/handlers.ts @@ -0,0 +1,157 @@ +/** + * 일반계약 관련 결재 액션 핸들러 + * + * 실제 비즈니스 로직만 포함 (결재 로직은 approval-workflow에서 처리) + */ + +'use server'; + +import { sendContractApprovalRequest } from './service'; +import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils'; +import db from '@/db/db'; +import { eq } from 'drizzle-orm'; +import { generalContracts } from '@/db/schema/generalContract'; + +interface ContractSummary { + basicInfo: Record; + items: Record[]; + subcontractChecklist: Record | null; + storageInfo?: Record[]; +} + +/** + * 일반계약 승인 핸들러 (결재 승인 후 계약승인요청 전송 실행) + * + * 결재 승인 후 자동으로 계약승인요청을 전송함 + * 이 함수는 직접 호출하지 않고, 결재 워크플로우에서 자동으로 호출됨 + * + * @param payload - withApproval()에서 전달한 actionPayload + */ +export async function approveContractInternal(payload: { + contractId: number; + contractSummary: ContractSummary; + currentUser?: { + id: string | number; + name?: string | null; + email?: string | null; + nonsapUserId?: string | null; + }; +}) { + debugLog('[ContractApprovalHandler] 일반계약 승인 핸들러 시작', { + contractId: payload.contractId, + contractNumber: payload.contractSummary.basicInfo?.contractNumber, + contractName: payload.contractSummary.basicInfo?.name, + hasCurrentUser: !!payload.currentUser, + }); + + try { + // 1. 계약 정보 확인 + const [contract] = await db + .select() + .from(generalContracts) + .where(eq(generalContracts.id, payload.contractId)) + .limit(1); + + if (!contract) { + throw new Error('계약을 찾을 수 없습니다.'); + } + + // 2. 계약승인요청 전송 + debugLog('[ContractApprovalHandler] sendContractApprovalRequest 호출'); + + // PDF 경로에서 PDF 버퍼 읽기 + const pdfPath = (payload.contractSummary as any).pdfPath; + if (!pdfPath) { + throw new Error('PDF 경로가 없습니다.'); + } + + // PDF 파일 읽기 + const fs = await import('fs/promises'); + const path = await import('path'); + + const nasPath = process.env.NAS_PATH || "/evcp_nas"; + const isProduction = process.env.NODE_ENV === "production"; + const baseDir = isProduction ? nasPath : path.join(process.cwd(), "public"); + + // publicPath에서 실제 파일 경로로 변환 + const actualPath = pdfPath.startsWith('/') + ? path.join(baseDir, pdfPath) + : path.join(baseDir, 'generalContracts', pdfPath); + + let pdfBuffer: Uint8Array; + try { + const fileBuffer = await fs.readFile(actualPath); + pdfBuffer = new Uint8Array(fileBuffer); + } catch (error) { + debugError('[ContractApprovalHandler] PDF 파일 읽기 실패', error); + throw new Error('PDF 파일을 읽을 수 없습니다.'); + } + + // 기본계약서는 클라이언트에서 이미 생성되었을 것으로 가정 + const generatedBasicContracts: Array<{ key: string; buffer: number[]; fileName: string }> = + (payload.contractSummary as any).basicContractPdfs || []; + + const userId = payload.currentUser?.id + ? String(payload.currentUser.id) + : String(contract.registeredById); + + const result = await sendContractApprovalRequest( + payload.contractSummary, + pdfBuffer, + 'contractDocument', + userId, + generatedBasicContracts + ); + + if (!result.success) { + debugError('[ContractApprovalHandler] 계약승인요청 전송 실패', result.error); + + // 전송 실패 시 상태를 원래대로 되돌림 + await db.update(generalContracts) + .set({ + status: 'Draft', + lastUpdatedAt: new Date() + }) + .where(eq(generalContracts.id, payload.contractId)); + + throw new Error(result.error || '계약승인요청 전송에 실패했습니다.'); + } + + // 3. 전송 성공 시 상태를 'Contract Accept Request'로 변경 + debugLog('[ContractApprovalHandler] 계약승인요청 전송 성공, 상태를 Contract Accept Request로 변경'); + await db.update(generalContracts) + .set({ + status: 'Contract Accept Request', + lastUpdatedAt: new Date() + }) + .where(eq(generalContracts.id, payload.contractId)); + + debugSuccess('[ContractApprovalHandler] 일반계약 승인 완료', { + contractId: payload.contractId, + result: result + }); + + return { + success: true, + message: '계약승인요청이 전송되었습니다.', + result: result + }; + } catch (error) { + debugError('[ContractApprovalHandler] 일반계약 승인 중 에러', error); + + // 에러 발생 시 상태를 원래대로 되돌림 + try { + await db.update(generalContracts) + .set({ + status: 'Draft', + lastUpdatedAt: new Date() + }) + .where(eq(generalContracts.id, payload.contractId)); + } catch (updateError) { + debugError('[ContractApprovalHandler] 상태 업데이트 실패', updateError); + } + + throw error; + } +} + diff --git a/lib/general-contracts/service.ts b/lib/general-contracts/service.ts index 72b6449f..b803d2d4 100644 --- a/lib/general-contracts/service.ts +++ b/lib/general-contracts/service.ts @@ -1386,7 +1386,7 @@ export async function sendContractApprovalRequest( signerStatus: 'PENDING', }) - // 사외업체 야드투입이 'Y'인 경우 안전담당자 자동 지정 + // 사외업체 야드투입이 'Y'인 경우 안전담당자 자동 지정 - 수정필요 12/05 if (contractSummary.basicInfo?.externalYardEntry === 'Y') { try { // 안전담당자 역할을 가진 사용자 조회 (역할명에 '안전' 또는 'safety' 포함) diff --git a/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts b/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts index 00873d83..9bf61452 100644 --- a/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts +++ b/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts @@ -256,11 +256,14 @@ export async function mapECCBiddingHeaderToBidding( // prNumber: 대표 PR의 BANFN 또는 타겟 PR의 ZREQ_FN 값 prNumber = representativeItem?.BANFN || targetItem.ZREQ_FN || null; } + + // 원입찰번호(originalBiddingNumber)에 생성된 biddingNumber에서 '-'로 split해서 앞부분을 사용 + const originalBiddingNumber = biddingNumber ? biddingNumber.split('-')[0] : (eccHeader.ANFNR || null); // 매핑 const mappedData: BiddingData = { biddingNumber, // 생성된 Bidding 코드 - originalBiddingNumber: eccHeader.ANFNR || null, // 원입찰번호 + originalBiddingNumber, // 원입찰번호에 생성된 biddingnumber split 결과 사용 revision: 0, // 기본 리비전 0 (I/F 해서 가져온 건 보낸 적 없으므로 0 고정) projectName, // 타겟 PR Item의 PSPID로 찾은 프로젝트 이름 itemName, // 타겟 PR Item 정보로 조회한 자재명/내역 @@ -279,7 +282,6 @@ export async function mapECCBiddingHeaderToBidding( biddingRegistrationDate: new Date().toISOString(), // 입찰등록일 I/F 시점 등록(1120 이시원 프로 요청) submissionStartDate: null, submissionEndDate: null, - evaluationDate: null, // 사양설명회 hasSpecificationMeeting: false, // 기본값 처리하고, 입찰관리상세에서 사용자가 관리 diff --git a/lib/soap/ecc/send/chemical-substance-check.ts b/lib/soap/ecc/send/chemical-substance-check.ts new file mode 100644 index 00000000..b5c4cc25 --- /dev/null +++ b/lib/soap/ecc/send/chemical-substance-check.ts @@ -0,0 +1,449 @@ +'use server' + +import { sendSoapXml } from "@/lib/soap/sender"; +import type { SoapSendConfig, SoapLogInfo, SoapSendResult } from "@/lib/soap/types"; + +// ECC 화학물질 조회 엔드포인트 (WSDL에 명시된 인터페이스 사용) +const ECC_CHEMICAL_SUBSTANCE_ENDPOINT = "http://shii8dvddb01.hec.serp.shi.samsung.net:50000/sap/xi/engine?type=entry&version=3.0&Sender.Service=P2038_Q&Interface=http%3A%2F%2Fshi.samsung.co.kr%2FP2_MM%2FMMM%5E[P2MM_INTERFACE_NAME]"; + +// 화학물질 조회 요청 데이터 타입 +export interface ChemicalSubstanceCheckRequest { + T_LIST: Array<{ + BUKRS: string; // Company Code (M, CHAR 4) + WERKS: string; // Plant (M, CHAR 4) + LIFNR: string; // Vendor's account number (M, CHAR 10) + MATNR: string; // Material Number (M, CHAR 18) + }>; +} + +// 화학물질 조회 응답 데이터 타입 +export interface ChemicalSubstanceCheckResponse { + T_LIST: Array<{ + QINSPST: string; // Y/N (화학물질 여부) + SGTXT: string; // Text (상세 메시지) + }>; +} + +// 화학물질 조회 결과 타입 (DB 저장용) +export interface ChemicalSubstanceResult { + bukrs: string; + werks: string; + lifnr: string; + matnr: string; + hasChemicalSubstance: boolean; + message: string; + checkedAt: Date; +} + +// SOAP Body Content 생성 함수 +function createChemicalSubstanceCheckSoapBodyContent(data: ChemicalSubstanceCheckRequest): Record { + return { + 'p1:MT_[INTERFACE_NAME]_S': { // 실제 인터페이스명으로 변경 필요 + 'T_LIST': data.T_LIST + } + }; +} + +// 화학물질 조회 데이터 검증 함수 +function validateChemicalSubstanceCheckData(data: ChemicalSubstanceCheckRequest): { isValid: boolean; errors: string[] } { + const errors: string[] = []; + + // T_LIST 배열 검증 + if (!data.T_LIST || !Array.isArray(data.T_LIST) || data.T_LIST.length === 0) { + errors.push('T_LIST는 필수이며 최소 1개 이상의 데이터가 있어야 합니다.'); + } else { + data.T_LIST.forEach((item, index) => { + // 필수 필드 검증 + if (!item.BUKRS || typeof item.BUKRS !== 'string' || item.BUKRS.trim() === '') { + errors.push(`T_LIST[${index}].BUKRS은 필수입니다.`); + } else if (item.BUKRS.length > 4) { + errors.push(`T_LIST[${index}].BUKRS은 4자를 초과할 수 없습니다.`); + } + + if (!item.WERKS || typeof item.WERKS !== 'string' || item.WERKS.trim() === '') { + errors.push(`T_LIST[${index}].WERKS는 필수입니다.`); + } else if (item.WERKS.length > 4) { + errors.push(`T_LIST[${index}].WERKS는 4자를 초과할 수 없습니다.`); + } + + if (!item.LIFNR || typeof item.LIFNR !== 'string' || item.LIFNR.trim() === '') { + errors.push(`T_LIST[${index}].LIFNR은 필수입니다.`); + } else if (item.LIFNR.length > 10) { + errors.push(`T_LIST[${index}].LIFNR은 10자를 초과할 수 없습니다.`); + } + + if (!item.MATNR || typeof item.MATNR !== 'string' || item.MATNR.trim() === '') { + errors.push(`T_LIST[${index}].MATNR은 필수입니다.`); + } else if (item.MATNR.length > 18) { + errors.push(`T_LIST[${index}].MATNR은 18자를 초과할 수 없습니다.`); + } + }); + } + + return { + isValid: errors.length === 0, + errors + }; +} + +// ECC로 화학물질 조회 SOAP XML 전송하는 함수 +async function sendChemicalSubstanceCheckToECC(data: ChemicalSubstanceCheckRequest): Promise { + try { + // 데이터 검증 + const validation = validateChemicalSubstanceCheckData(data); + if (!validation.isValid) { + return { + success: false, + message: `데이터 검증 실패: ${validation.errors.join(', ')}` + }; + } + + // SOAP Body Content 생성 + const soapBodyContent = createChemicalSubstanceCheckSoapBodyContent(data); + + // SOAP 전송 설정 + const config: SoapSendConfig = { + endpoint: ECC_CHEMICAL_SUBSTANCE_ENDPOINT, + envelope: soapBodyContent, + soapAction: 'http://sap.com/xi/WebService/soap1.1', + timeout: 30000, // 화학물질 조회는 30초 타임아웃 + retryCount: 3, + retryDelay: 1000, + namespace: 'http://shi.samsung.co.kr/P2_MM/MMM', // ECC MM 모듈 네임스페이스 + prefix: 'p1' // WSDL에서 사용하는 p1 접두사 + }; + + // 로그 정보 + const logInfo: SoapLogInfo = { + direction: 'OUTBOUND', + system: 'S-ERP ECC', + interface: 'IF_ECC_EVCP_CHEMICAL_SUBSTANCE_CHECK' + }; + + const materials = data.T_LIST.map(item => `${item.BUKRS}/${item.WERKS}/${item.LIFNR}/${item.MATNR}`).join(', '); + console.log(`📤 화학물질 조회 요청 전송 시작 - Materials: ${materials}`); + console.log(`🔍 조회 대상 물질 ${data.T_LIST.length}개`); + + // SOAP XML 전송 + const result = await sendSoapXml(config, logInfo); + + if (result.success) { + console.log(`✅ 화학물질 조회 요청 전송 성공 - Materials: ${materials}`); + } else { + console.error(`❌ 화학물질 조회 요청 전송 실패 - Materials: ${materials}, 오류: ${result.message}`); + } + + return result; + + } catch (error) { + console.error('❌ 화학물질 조회 전송 중 오류 발생:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error' + }; + } +} + +// ======================================== +// 메인 화학물질 조회 서버 액션 함수들 +// ======================================== + +// 단일 화학물질 조회 요청 처리 +export async function checkChemicalSubstance(params: { + bukrs: string; + werks: string; + lifnr: string; + matnr: string; +}): Promise<{ + success: boolean; + message: string; + hasChemicalSubstance?: boolean; + responseData?: string; + statusCode?: number; + headers?: Record; + endpoint?: string; + requestXml?: string; + material?: string; +}> { + try { + console.log(`🚀 화학물질 조회 요청 시작 - Material: ${params.bukrs}/${params.werks}/${params.lifnr}/${params.matnr}`); + + const requestData: ChemicalSubstanceCheckRequest = { + T_LIST: [{ + BUKRS: params.bukrs, + WERKS: params.werks, + LIFNR: params.lifnr, + MATNR: params.matnr + }] + }; + + const result = await sendChemicalSubstanceCheckToECC(requestData); + + let hasChemicalSubstance: boolean | undefined; + let message = result.message; + + if (result.success && result.responseText) { + try { + // 응답 파싱 로직 (실제 응답 구조에 따라 조정 필요) + // QINSPST = 'Y' 이면 화학물질 있음, 'N'이면 없음 + const responseData = JSON.parse(result.responseText); + if (responseData?.T_LIST?.[0]) { + const item = responseData.T_LIST[0]; + hasChemicalSubstance = item.QINSPST === 'Y'; + message = item.SGTXT || result.message; + } + } catch (parseError) { + console.warn('응답 데이터 파싱 실패:', parseError); + } + } + + return { + success: result.success, + message, + hasChemicalSubstance, + responseData: result.responseText, + statusCode: result.statusCode, + headers: result.headers, + endpoint: result.endpoint, + requestXml: result.requestXml, + material: `${params.bukrs}/${params.werks}/${params.lifnr}/${params.matnr}` + }; + + } catch (error) { + console.error('❌ 화학물질 조회 요청 처리 실패:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error' + }; + } +} + +// 여러 물질 배치 화학물질 조회 요청 처리 +export async function checkMultipleChemicalSubstances(items: Array<{ + bukrs: string; + werks: string; + lifnr: string; + matnr: string; +}>): Promise<{ + success: boolean; + message: string; + results?: Array<{ + material: string; + hasChemicalSubstance?: boolean; + success: boolean; + error?: string; + message?: string; + }>; +}> { + try { + console.log(`🚀 배치 화학물질 조회 요청 시작: ${items.length}개`); + + const requestData: ChemicalSubstanceCheckRequest = { + T_LIST: items.map(item => ({ + BUKRS: item.bukrs, + WERKS: item.werks, + LIFNR: item.lifnr, + MATNR: item.matnr + })) + }; + + const result = await sendChemicalSubstanceCheckToECC(requestData); + + let results: Array<{ + material: string; + hasChemicalSubstance?: boolean; + success: boolean; + error?: string; + message?: string; + }> | undefined; + + if (result.success && result.responseText) { + try { + const responseData = JSON.parse(result.responseText); + if (responseData?.T_LIST && Array.isArray(responseData.T_LIST)) { + results = responseData.T_LIST.map((item: any, index: number) => { + const originalItem = items[index]; + const material = `${originalItem.bukrs}/${originalItem.werks}/${originalItem.lifnr}/${originalItem.matnr}`; + + return { + material, + hasChemicalSubstance: item.QINSPST === 'Y', + success: true, + message: item.SGTXT + }; + }); + } + } catch (parseError) { + console.warn('배치 응답 데이터 파싱 실패:', parseError); + // 파싱 실패시 전체 실패로 처리 + results = items.map(item => ({ + material: `${item.bukrs}/${item.werks}/${item.lifnr}/${item.matnr}`, + success: false, + error: '응답 데이터 파싱 실패' + })); + } + } else { + // 전송 실패시 모든 항목 실패로 처리 + results = items.map(item => ({ + material: `${item.bukrs}/${item.werks}/${item.lifnr}/${item.matnr}`, + success: false, + error: result.message + })); + } + + const successCount = results?.filter(r => r.success).length || 0; + const failCount = (results?.length || 0) - successCount; + + console.log(`🎉 배치 화학물질 조회 완료: 성공 ${successCount}개, 실패 ${failCount}개`); + + return { + success: result.success, + message: result.success + ? `배치 화학물질 조회 성공: ${successCount}개` + : `배치 화학물질 조회 실패: ${result.message}`, + results + }; + + } catch (error) { + console.error('❌ 배치 화학물질 조회 중 전체 오류 발생:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error' + }; + } +} + +// 개별 처리 방식의 배치 화학물질 조회 (각각 따로 전송) +export async function checkMultipleChemicalSubstancesIndividually(items: Array<{ + bukrs: string; + werks: string; + lifnr: string; + matnr: string; +}>): Promise<{ + success: boolean; + message: string; + results?: Array<{ + material: string; + hasChemicalSubstance?: boolean; + success: boolean; + error?: string; + message?: string; + }>; +}> { + try { + console.log(`🚀 개별 화학물질 조회 요청 시작: ${items.length}개`); + + const results: Array<{ + material: string; + hasChemicalSubstance?: boolean; + success: boolean; + error?: string; + message?: string; + }> = []; + + for (const item of items) { + try { + const material = `${item.bukrs}/${item.werks}/${item.lifnr}/${item.matnr}`; + console.log(`📤 화학물질 조회 처리 중: ${material}`); + + const checkResult = await checkChemicalSubstance(item); + + results.push({ + material, + hasChemicalSubstance: checkResult.hasChemicalSubstance, + success: checkResult.success, + error: checkResult.success ? undefined : checkResult.message, + message: checkResult.message + }); + + // 개별 처리간 지연 (시스템 부하 방지) + if (items.length > 1) { + await new Promise(resolve => setTimeout(resolve, 500)); + } + + } catch (error) { + const material = `${item.bukrs}/${item.werks}/${item.lifnr}/${item.matnr}`; + console.error(`❌ 화학물질 조회 처리 실패: ${material}`, error); + results.push({ + material, + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + } + + const successCount = results.filter(r => r.success).length; + const failCount = results.length - successCount; + + console.log(`🎉 개별 화학물질 조회 완료: 성공 ${successCount}개, 실패 ${failCount}개`); + + return { + success: failCount === 0, + message: `개별 화학물질 조회 완료: 성공 ${successCount}개, 실패 ${failCount}개`, + results + }; + + } catch (error) { + console.error('❌ 개별 화학물질 조회 중 전체 오류 발생:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error' + }; + } +} + +// 테스트용 화학물질 조회 함수 (샘플 데이터 포함) +export async function checkTestChemicalSubstance(): Promise<{ + success: boolean; + message: string; + hasChemicalSubstance?: boolean; + responseData?: string; + testData?: ChemicalSubstanceCheckRequest; +}> { + try { + console.log('🧪 테스트용 화학물질 조회 시작'); + + // 테스트용 샘플 데이터 생성 + const testData: ChemicalSubstanceCheckRequest = { + T_LIST: [{ + BUKRS: '1000', + WERKS: '1000', + LIFNR: 'TEST_VENDOR', + MATNR: 'TEST_MATERIAL' + }] + }; + + const result = await sendChemicalSubstanceCheckToECC(testData); + + let hasChemicalSubstance: boolean | undefined; + let message = result.message; + + if (result.success && result.responseText) { + try { + const responseData = JSON.parse(result.responseText); + if (responseData?.T_LIST?.[0]) { + const item = responseData.T_LIST[0]; + hasChemicalSubstance = item.QINSPST === 'Y'; + message = item.SGTXT || result.message; + } + } catch (parseError) { + console.warn('테스트 응답 데이터 파싱 실패:', parseError); + } + } + + return { + success: result.success, + message, + hasChemicalSubstance, + responseData: result.responseText, + testData + }; + + } catch (error) { + console.error('❌ 테스트 화학물질 조회 실패:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error' + }; + } +} -- cgit v1.2.3 From c0e1cc06e0c67cb4a941889a3d63d312d1fb8fce Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 8 Dec 2025 03:02:42 +0000 Subject: (최겸) 구매 입찰계약 수정, 입찰기간수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bidding/manage/bidding-schedule-editor.tsx | 15 +- db/schema/bidding.ts | 1 + lib/bidding/detail/service.ts | 45 +- .../bidding-detail-vendor-toolbar-actions.tsx | 8 +- lib/bidding/list/biddings-table-columns.tsx | 31 +- lib/bidding/list/export-biddings-to-excel.ts | 9 +- lib/bidding/receive/biddings-receive-columns.tsx | 6 +- .../selection/biddings-selection-columns.tsx | 7 +- lib/bidding/service.ts | 39 +- .../vendor/export-partners-biddings-to-excel.ts | 9 +- lib/bidding/vendor/partners-bidding-detail.tsx | 11 +- .../vendor/partners-bidding-list-columns.tsx | 8 +- .../approval-template-variables.ts | 270 +-- .../general-contract-approval-request-dialog.tsx | 2381 ++++++++++---------- .../detail/general-contract-items-table.tsx | 2 +- 15 files changed, 1449 insertions(+), 1393 deletions(-) (limited to 'lib/general-contracts') diff --git a/components/bidding/manage/bidding-schedule-editor.tsx b/components/bidding/manage/bidding-schedule-editor.tsx index 32ce6940..72961c3d 100644 --- a/components/bidding/manage/bidding-schedule-editor.tsx +++ b/components/bidding/manage/bidding-schedule-editor.tsx @@ -151,15 +151,18 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc return new Date(kstTime).toISOString().slice(0, 16) } - // timestamp에서 시간(HH:MM) 추출 (KST 기준) + // timestamp에서 시간(HH:MM) 추출 + // 수정: Backend에서 setUTCHours로 저장했으므로, 읽을 때도 getUTCHours로 읽어야 + // 브라우저 타임존(KST)의 간섭 없이 원래 저장한 숫자를 가져올 수 있습니다. const extractTimeFromTimestamp = (date: string | Date | undefined | null): string => { if (!date) return '' const d = new Date(date) - // UTC 시간에 9시간을 더함 - const kstTime = d.getTime() + (9 * 60 * 60 * 1000) - const kstDate = new Date(kstTime) - const hours = kstDate.getUTCHours().toString().padStart(2, '0') - const minutes = kstDate.getUTCMinutes().toString().padStart(2, '0') + + // 중요: Backend에서 setUTCHours로 저장했으므로, 읽을 때도 getUTCHours로 읽어야 + // 브라우저 타임존(KST)의 간섭 없이 원래 저장한 숫자(09:00)를 가져올 수 있습니다. + const hours = d.getUTCHours().toString().padStart(2, '0') + const minutes = d.getUTCMinutes().toString().padStart(2, '0') + return `${hours}:${minutes}` } diff --git a/db/schema/bidding.ts b/db/schema/bidding.ts index c5370174..8e5fe823 100644 --- a/db/schema/bidding.ts +++ b/db/schema/bidding.ts @@ -196,6 +196,7 @@ export const biddings = pgTable('biddings', { // PR 정보 prNumber: varchar('pr_number', { length: 50 }), // PR No. hasPrDocument: boolean('has_pr_document').default(false), // PR 문서 여부 + plant: varchar('plant', { length: 10 }), // 플랜트 코드(WERKS), ECC 연동 시 설정 // 상태 및 설정 status: biddingStatusEnum('status').default('bidding_generated').notNull(), diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index 99591e3b..eec3f253 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -854,15 +854,14 @@ export async function registerBidding(biddingId: number, userId: string) { await db.transaction(async (tx) => { debugLog('registerBidding: Transaction started') - // 0. 입찰서 제출기간 계산 (오프셋 기반) + // 0. 입찰서 제출기간 계산 (입력값 절대 기준) const { submissionStartOffset, submissionDurationDays, submissionStartDate, submissionEndDate } = bidding let calculatedStartDate = bidding.submissionStartDate let calculatedEndDate = bidding.submissionEndDate - // 오프셋 값이 있으면 날짜 계산 if (submissionStartOffset !== null && submissionDurationDays !== null) { - // 시간 추출 (기본값: 시작 09:00, 마감 18:00) + // DB에 저장된 시간을 숫자 그대로 가져옴 (예: 10:00 저장 → 10 반환) const startTime = submissionStartDate ? { hours: submissionStartDate.getUTCHours(), minutes: submissionStartDate.getUTCMinutes() } : { hours: 9, minutes: 0 } @@ -870,22 +869,30 @@ export async function registerBidding(biddingId: number, userId: string) { ? { hours: submissionEndDate.getUTCHours(), minutes: submissionEndDate.getUTCMinutes() } : { hours: 18, minutes: 0 } - // baseDate = 현재일 날짜만 (00:00:00) - const baseDate = new Date() - baseDate.setHours(0, 0, 0, 0) - - // 시작일 = baseDate + offset일 + 시작시간 - calculatedStartDate = new Date(baseDate) - calculatedStartDate.setDate(calculatedStartDate.getDate() + submissionStartOffset) - calculatedStartDate.setHours(startTime.hours, startTime.minutes, 0, 0) - - // 마감일 = 시작일(날짜만) + duration일 + 마감시간 - calculatedEndDate = new Date(calculatedStartDate) - calculatedEndDate.setHours(0, 0, 0, 0) - calculatedEndDate.setDate(calculatedEndDate.getDate() + submissionDurationDays) - calculatedEndDate.setHours(endTime.hours, endTime.minutes, 0, 0) - - debugLog('registerBidding: Submission dates calculated', { + // 서버의 오늘 날짜(년/월/일)를 그대로 사용해 00:00 UTC 시점 생성 + const now = new Date() + const baseDate = new Date(Date.UTC( + now.getFullYear(), + now.getMonth(), + now.getDate(), + 0, 0, 0 + )) + + // 시작일 = baseDate + offset일 + 입력 시간(숫자 그대로) + const tempStartDate = new Date(baseDate) + tempStartDate.setUTCDate(tempStartDate.getUTCDate() + submissionStartOffset) + tempStartDate.setUTCHours(startTime.hours, startTime.minutes, 0, 0) + + // 마감일 = 시작일 날짜만 기준 + duration일 + 입력 마감 시간 + const tempEndDate = new Date(tempStartDate) + tempEndDate.setUTCHours(0, 0, 0, 0) + tempEndDate.setUTCDate(tempEndDate.getUTCDate() + submissionDurationDays) + tempEndDate.setUTCHours(endTime.hours, endTime.minutes, 0, 0) + + calculatedStartDate = tempStartDate + calculatedEndDate = tempEndDate + + debugLog('registerBidding: Submission dates calculated (Input Value Based)', { baseDate: baseDate.toISOString(), calculatedStartDate: calculatedStartDate.toISOString(), calculatedEndDate: calculatedEndDate.toISOString(), diff --git a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx index d3df141a..e934a5fe 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx @@ -154,8 +154,12 @@ export function BiddingDetailVendorToolbarActions({ title: "성공", description: '차수증가가 완료되었습니다.', }) - router.push(`/evcp/bid`) - onSuccess() + if (result.biddingId) { + router.push(`/evcp/bid/${result.biddingId}/info`) + } else { + router.push(`/evcp/bid`) + } + // onSuccess() } else { toast({ title: "오류", diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx index 62d4dbe7..602bcbb9 100644 --- a/lib/bidding/list/biddings-table-columns.tsx +++ b/lib/bidding/list/biddings-table-columns.tsx @@ -257,21 +257,40 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef id: "submissionPeriod", header: ({ column }) => , cell: ({ row }) => { + const status = row.original.status + + // 입찰생성 또는 결재진행중 상태일 때는 특별 메시지 표시 + if (status === 'bidding_generated') { + return ( +
+ 입찰 등록중입니다 +
+ ) + } + + if (status === 'approval_pending') { + return ( +
+ 결재 진행중입니다 +
+ ) + } + const startDate = row.original.submissionStartDate const endDate = row.original.submissionEndDate - + if (!startDate || !endDate) return - - + const startObj = new Date(startDate) const endObj = new Date(endDate) - // UI 표시용 KST 변환 - const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ') - + // 입력값 기반: 저장된 UTC 값 그대로 표시 (타임존 가감 없음) + const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ') + return (
- {formatKst(startObj)} ~ {formatKst(endObj)} + {formatValue(startObj)} ~ {formatValue(endObj)}
) diff --git a/lib/bidding/list/export-biddings-to-excel.ts b/lib/bidding/list/export-biddings-to-excel.ts index 8b13e38d..64d98399 100644 --- a/lib/bidding/list/export-biddings-to-excel.ts +++ b/lib/bidding/list/export-biddings-to-excel.ts @@ -83,13 +83,10 @@ export async function exportBiddingsToExcel( const startObj = new Date(startDate) const endObj = new Date(endDate) - // KST 변환 (UTC+9) - const formatKst = (d: Date) => { - const kstDate = new Date(d.getTime() + 9 * 60 * 60 * 1000) - return kstDate.toISOString().slice(0, 16).replace('T', ' ') - } + // 입력값 기반: 저장된 UTC 값을 그대로 표시 (타임존 가감 없음) + const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ') - value = `${formatKst(startObj)} ~ ${formatKst(endObj)}` + value = `${formatValue(startObj)} ~ ${formatValue(endObj)}` } break diff --git a/lib/bidding/receive/biddings-receive-columns.tsx b/lib/bidding/receive/biddings-receive-columns.tsx index f2e2df17..6847d9d5 100644 --- a/lib/bidding/receive/biddings-receive-columns.tsx +++ b/lib/bidding/receive/biddings-receive-columns.tsx @@ -199,13 +199,13 @@ export function getBiddingsReceiveColumns({ setRowAction, onParticipantClick }: const startObj = new Date(startDate) const endObj = new Date(endDate) - // UI 표시용 KST 변환 - const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ') + // 입력값 기반: 저장된 UTC 값 그대로 표시 (타임존 가감 없음) + const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ') return (
- {formatKst(startObj)} ~ {formatKst(endObj)} + {formatValue(startObj)} ~ {formatValue(endObj)}
) diff --git a/lib/bidding/selection/biddings-selection-columns.tsx b/lib/bidding/selection/biddings-selection-columns.tsx index 87c489e3..030fc05b 100644 --- a/lib/bidding/selection/biddings-selection-columns.tsx +++ b/lib/bidding/selection/biddings-selection-columns.tsx @@ -177,14 +177,13 @@ export function getBiddingsSelectionColumns({ setRowAction }: GetColumnsProps): const startObj = new Date(startDate) const endObj = new Date(endDate) - // 비교로직만 유지, 색상표기/마감뱃지 제거 - // UI 표시용 KST 변환 - const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ') + // 입력값 기반: 저장된 UTC 값 그대로 표시 (타임존 가감 없음) + const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ') return (
- {formatKst(startObj)} ~ {formatKst(endObj)} + {formatValue(startObj)} ~ {formatValue(endObj)}
) diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts index 71ee01ab..ed20ad0c 100644 --- a/lib/bidding/service.ts +++ b/lib/bidding/service.ts @@ -3006,6 +3006,7 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u // 구매조직 purchasingOrganization: existingBidding.purchasingOrganization, + plant: existingBidding.plant, // 담당자 정보 복제 bidPicId: existingBidding.bidPicId, @@ -3323,7 +3324,6 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u revalidatePath('/evcp/bid-receive') revalidatePath('/evcp/bid') revalidatePath(`/bid-receive/${biddingId}`) // 기존 입찰 페이지도 갱신 - revalidatePath(`/bid-receive/${newBidding.id}`) return { success: true, @@ -4139,6 +4139,41 @@ export async function getBiddingSelectionItemsAndPrices(biddingId: number) { */ export async function checkAndSaveChemicalSubstancesForBidding(biddingId: number) { try { + const [biddingInfo] = await db + .select({ + id: biddings.id, + ANFNR: biddings.ANFNR, + plant: biddings.plant, + }) + .from(biddings) + .where(eq(biddings.id, biddingId)) + .limit(1) + + if (!biddingInfo) { + return { + success: false, + message: '입찰 정보를 찾을 수 없습니다.', + results: [] + } + } + + if (!biddingInfo.ANFNR) { + return { + success: true, + message: 'SAP PR 연동 입찰이 아니므로 화학물질 검사를 건너뜁니다.', + results: [] + } + } + + const biddingWerks = biddingInfo.plant?.trim() + if (!biddingWerks) { + return { + success: false, + message: '입찰의 플랜트(WERKS) 정보가 없어 화학물질 검사를 진행할 수 없습니다.', + results: [] + } + } + // 입찰의 모든 참여업체 조회 (벤더 코드 있는 것만) const biddingCompaniesList = await db .select({ @@ -4222,7 +4257,7 @@ export async function checkAndSaveChemicalSubstancesForBidding(biddingId: number try { const checkResult = await checkChemicalSubstance({ bukrs: 'H100', // 회사코드는 H100 고정 - werks: 'PM11', // WERKS는 PM11 고정 + werks: biddingWerks, lifnr: biddingCompany.vendors!.vendorCode!, matnr: materialNumber }) diff --git a/lib/bidding/vendor/export-partners-biddings-to-excel.ts b/lib/bidding/vendor/export-partners-biddings-to-excel.ts index 9e99eeec..e1d985fe 100644 --- a/lib/bidding/vendor/export-partners-biddings-to-excel.ts +++ b/lib/bidding/vendor/export-partners-biddings-to-excel.ts @@ -124,13 +124,10 @@ export async function exportPartnersBiddingsToExcel( const startObj = new Date(startDate) const endObj = new Date(endDate) - // KST 변환 (UTC+9) - const formatKst = (d: Date) => { - const kstDate = new Date(d.getTime() + 9 * 60 * 60 * 1000) - return kstDate.toISOString().slice(0, 16).replace('T', ' ') - } + // 입력값 기반: 저장된 UTC 값을 그대로 표시 (타임존 가감 없음) + const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ') - value = `${formatKst(startObj)} ~ ${formatKst(endObj)}` + value = `${formatValue(startObj)} ~ ${formatValue(endObj)}` } break diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx index 087648ab..bf33cef5 100644 --- a/lib/bidding/vendor/partners-bidding-detail.tsx +++ b/lib/bidding/vendor/partners-bidding-detail.tsx @@ -868,7 +868,8 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD const timeLeft = deadline.getTime() - now.getTime() const daysLeft = Math.floor(timeLeft / (1000 * 60 * 60 * 24)) const hoursLeft = Math.floor((timeLeft % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) - const kstDeadline = new Date(deadline.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ') + // 입력값 기반: 저장된 UTC 값을 그대로 표시 + const displayDeadline = deadline.toISOString().slice(0, 16).replace('T', ' ') return (
제출 마감일: - {kstDeadline} + {displayDeadline}
{isExpired ? ( @@ -920,9 +921,9 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD 입찰서 제출기간: {(() => { const start = new Date(biddingDetail.submissionStartDate!) const end = new Date(biddingDetail.submissionEndDate!) - const kstStart = new Date(start.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ') - const kstEnd = new Date(end.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ') - return `${kstStart} ~ ${kstEnd}` + const displayStart = start.toISOString().slice(0, 16).replace('T', ' ') + const displayEnd = end.toISOString().slice(0, 16).replace('T', ' ') + return `${displayStart} ~ ${displayEnd}` })()}
)} diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx index 6276d1b7..09c3caad 100644 --- a/lib/bidding/vendor/partners-bidding-list-columns.tsx +++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx @@ -352,14 +352,14 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL const startObj = new Date(startDate) const endObj = new Date(endDate) - // UI 표시용 KST 변환 - const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ') + // 입력값 기반: 저장된 UTC 값 그대로 표시 (타임존 가감 없음) + const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ') return (
-
{formatKst(startObj)}
+
{formatValue(startObj)}
~
-
{formatKst(endObj)}
+
{formatValue(endObj)}
) }, diff --git a/lib/general-contracts/approval-template-variables.ts b/lib/general-contracts/approval-template-variables.ts index 6924694e..710e6101 100644 --- a/lib/general-contracts/approval-template-variables.ts +++ b/lib/general-contracts/approval-template-variables.ts @@ -144,129 +144,75 @@ export async function mapContractToApprovalTemplateVariables( issuer: string; }> = []; - // 계약보증 - if (basicInfo.contractBond) { - const bond = typeof basicInfo.contractBond === 'string' - ? JSON.parse(basicInfo.contractBond) - : basicInfo.contractBond; - - if (bond && Array.isArray(bond)) { - bond.forEach((b: any, idx: number) => { - guarantees.push({ - type: '계약보증', - order: idx + 1, - bondNumber: b.bondNumber || '', - rate: b.rate ? `${b.rate}%` : '', - amount: formatCurrency(b.amount), - period: b.period || '', - startDate: formatDate(b.startDate), - endDate: formatDate(b.endDate), - issuer: b.issuer || '', - }); - }); - } - } - - // 지급보증 - if (basicInfo.paymentBond) { - const bond = typeof basicInfo.paymentBond === 'string' - ? JSON.parse(basicInfo.paymentBond) - : basicInfo.paymentBond; - - if (bond && Array.isArray(bond)) { - bond.forEach((b: any, idx: number) => { - guarantees.push({ - type: '지급보증', - order: idx + 1, - bondNumber: b.bondNumber || '', - rate: b.rate ? `${b.rate}%` : '', - amount: formatCurrency(b.amount), - period: b.period || '', - startDate: formatDate(b.startDate), - endDate: formatDate(b.endDate), - issuer: b.issuer || '', - }); - }); - } - } - - // 하자보증 - if (basicInfo.defectBond) { - const bond = typeof basicInfo.defectBond === 'string' - ? JSON.parse(basicInfo.defectBond) - : basicInfo.defectBond; - - if (bond && Array.isArray(bond)) { - bond.forEach((b: any, idx: number) => { - guarantees.push({ - type: '하자보증', - order: idx + 1, - bondNumber: b.bondNumber || '', - rate: b.rate ? `${b.rate}%` : '', - amount: formatCurrency(b.amount), - period: b.period || '', - startDate: formatDate(b.startDate), - endDate: formatDate(b.endDate), - issuer: b.issuer || '', - }); - }); - } - } - - // 보증 전체 비고 - const guaranteeNote = basicInfo.guaranteeNote || ''; - - // 하도급 체크리스트 - const checklistItems: Array<{ - category: string; - item1: string; - item2: string; - result: string; - department: string; - cause: string; - measure: string; - }> = []; + // // 계약보증 (첫 번째 항목만 사용) + // if (basicInfo.contractBond) { + // const bond = typeof basicInfo.contractBond === 'string' + // ? JSON.parse(basicInfo.contractBond) + // : basicInfo.contractBond; + + // if (bond && Array.isArray(bond) && bond.length > 0) { + // const b = bond[0]; + // guarantees.push({ + // type: '계약보증', + // order: 1, + // bondNumber: b.bondNumber || '', + // rate: b.rate ? `${b.rate}%` : '', + // amount: formatCurrency(b.amount), + // period: b.period || '', + // startDate: formatDate(b.startDate), + // endDate: formatDate(b.endDate), + // issuer: b.issuer || '', + // }); + // } + // } + + // // 지급보증 (첫 번째 항목만 사용) + // if (basicInfo.paymentBond) { + // const bond = typeof basicInfo.paymentBond === 'string' + // ? JSON.parse(basicInfo.paymentBond) + // : basicInfo.paymentBond; + + // if (bond && Array.isArray(bond) && bond.length > 0) { + // const b = bond[0]; + // guarantees.push({ + // type: '지급보증', + // order: 1, + // bondNumber: b.bondNumber || '', + // rate: b.rate ? `${b.rate}%` : '', + // amount: formatCurrency(b.amount), + // period: b.period || '', + // startDate: formatDate(b.startDate), + // endDate: formatDate(b.endDate), + // issuer: b.issuer || '', + // }); + // } + // } + + // // 하자보증 (첫 번째 항목만 사용) + // if (basicInfo.defectBond) { + // const bond = typeof basicInfo.defectBond === 'string' + // ? JSON.parse(basicInfo.defectBond) + // : basicInfo.defectBond; + + // if (bond && Array.isArray(bond) && bond.length > 0) { + // const b = bond[0]; + // guarantees.push({ + // type: '하자보증', + // order: 1, + // bondNumber: b.bondNumber || '', + // rate: b.rate ? `${b.rate}%` : '', + // amount: formatCurrency(b.amount), + // period: b.period || '', + // startDate: formatDate(b.startDate), + // endDate: formatDate(b.endDate), + // issuer: b.issuer || '', + // }); + // } + // } + + // // 보증 전체 비고 + // const guaranteeNote = basicInfo.guaranteeNote || ''; - if (subcontractChecklist) { - // 1-1. 작업 시 서면 발급 - checklistItems.push({ - category: '계약 시 [계약 체결 단계]', - item1: '1-1. 작업 시 서면 발급', - item2: '-', - result: subcontractChecklist.workDocumentIssued === '준수' ? '준수' : - subcontractChecklist.workDocumentIssued === '위반' ? '위반' : - subcontractChecklist.workDocumentIssued === '위반의심' ? '위반의심' : '', - department: subcontractChecklist.workDocumentIssuedDepartment || '', - cause: subcontractChecklist.workDocumentIssuedCause || '', - measure: subcontractChecklist.workDocumentIssuedMeasure || '', - }); - - // 1-2. 6대 법정 기재사항 명기 여부 - checklistItems.push({ - category: '계약 시 [계약 체결 단계]', - item1: '1-2. 6대 법정 기재사항 명기 여부', - item2: '-', - result: subcontractChecklist.sixLegalItems === '준수' ? '준수' : - subcontractChecklist.sixLegalItems === '위반' ? '위반' : - subcontractChecklist.sixLegalItems === '위반의심' ? '위반의심' : '', - department: subcontractChecklist.sixLegalItemsDepartment || '', - cause: subcontractChecklist.sixLegalItemsCause || '', - measure: subcontractChecklist.sixLegalItemsMeasure || '', - }); - - // 2. 부당 하도급 대금 결정 행위 - checklistItems.push({ - category: '계약 시 [계약 체결 단계]', - item1: '-', - item2: '2. 부당 하도급 대금 결정 행위 (대금 결정 방법)', - result: subcontractChecklist.unfairSubcontractPrice === '준수' ? '준수' : - subcontractChecklist.unfairSubcontractPrice === '위반' ? '위반' : - subcontractChecklist.unfairSubcontractPrice === '위반의심' ? '위반의심' : '', - department: subcontractChecklist.unfairSubcontractPriceDepartment || '', - cause: subcontractChecklist.unfairSubcontractPriceCause || '', - measure: subcontractChecklist.unfairSubcontractPriceMeasure || '', - }); - } // 총 계약 금액 계산 const totalContractAmount = items.reduce((sum, item) => { @@ -338,31 +284,61 @@ export async function mapContractToApprovalTemplateVariables( // 총 계약 금액 variables['총_계약금액'] = formatCurrency(totalContractAmount); - // 보증 정보 변수 - guarantees.forEach((guarantee, index) => { - const idx = index + 1; - const typeKey = String(guarantee.type); - variables[`${typeKey}_차수_${idx}`] = String(guarantee.order); - variables[`${typeKey}_증권번호_${idx}`] = String(guarantee.bondNumber || ''); - variables[`${typeKey}_보증금율_${idx}`] = String(guarantee.rate || ''); - variables[`${typeKey}_보증금액_${idx}`] = String(guarantee.amount || ''); - variables[`${typeKey}_보증기간_${idx}`] = String(guarantee.period || ''); - variables[`${typeKey}_시작일_${idx}`] = String(guarantee.startDate || ''); - variables[`${typeKey}_종료일_${idx}`] = String(guarantee.endDate || ''); - variables[`${typeKey}_발행기관_${idx}`] = String(guarantee.issuer || ''); - }); - - // 보증 전체 비고 - variables['보증_전체_비고'] = String(guaranteeNote); - - // 하도급 체크리스트 변수 - checklistItems.forEach((item, index) => { - const idx = index + 1; - variables[`점검결과_${idx === 1 ? '1_1' : idx === 2 ? '1_2' : '2'}`] = String(item.result); - variables[`귀책부서_${idx === 1 ? '1_1' : idx === 2 ? '1_2' : '2'}`] = String(item.department); - variables[`원인_${idx === 1 ? '1_1' : idx === 2 ? '1_2' : '2'}`] = String(item.cause); - variables[`대책_${idx === 1 ? '1_1' : idx === 2 ? '1_2' : '2'}`] = String(item.measure); - }); + // // 보증 정보 변수 (첫 번째 항목만 사용) + // const contractGuarantee = guarantees.find(g => g.type === '계약보증'); + // if (contractGuarantee) { + // variables['계약보증_차수_1'] = String(contractGuarantee.order); + // variables['계약보증_증권번호_1'] = String(contractGuarantee.bondNumber || ''); + // variables['계약보증_보증금율_1'] = String(contractGuarantee.rate || ''); + // variables['계약보증_보증금액_1'] = String(contractGuarantee.amount || ''); + // variables['계약보증_보증기간_1'] = String(contractGuarantee.period || ''); + // variables['계약보증_시작일_1'] = String(contractGuarantee.startDate || ''); + // variables['계약보증_종료일_1'] = String(contractGuarantee.endDate || ''); + // variables['계약보증_발행기관_1'] = String(contractGuarantee.issuer || ''); + // variables['계약보증_비고_1'] = String(guaranteeNote); // 기존 보증 전체 비고를 계약보증 비고로 사용 + // } + + // const paymentGuarantee = guarantees.find(g => g.type === '지급보증'); + // if (paymentGuarantee) { + // variables['지급보증_차수_1'] = String(paymentGuarantee.order); + // variables['지급보증_증권번호_1'] = String(paymentGuarantee.bondNumber || ''); + // variables['지급보증_보증금율_1'] = String(paymentGuarantee.rate || ''); + // variables['지급보증_보증금액_1'] = String(paymentGuarantee.amount || ''); + // variables['지급보증_보증기간_1'] = String(paymentGuarantee.period || ''); + // variables['지급보증_시작일_1'] = String(paymentGuarantee.startDate || ''); + // variables['지급보증_종료일_1'] = String(paymentGuarantee.endDate || ''); + // variables['지급보증_발행기관_1'] = String(paymentGuarantee.issuer || ''); + // variables['지급보증_비고_1'] = String(guaranteeNote); // 기존 보증 전체 비고를 지급보증 비고로 사용 + // } + + // const defectGuarantee = guarantees.find(g => g.type === '하자보증'); + // if (defectGuarantee) { + // variables['하자보증_차수_1'] = String(defectGuarantee.order); + // variables['하자보증_증권번호_1'] = String(defectGuarantee.bondNumber || ''); + // variables['하자보증_보증금율_1'] = String(defectGuarantee.rate || ''); + // variables['하자보증_보증금액_1'] = String(defectGuarantee.amount || ''); + // variables['하자보증_보증기간_1'] = String(defectGuarantee.period || ''); + // variables['하자보증_시작일_1'] = String(defectGuarantee.startDate || ''); + // variables['하자보증_종료일_1'] = String(defectGuarantee.endDate || ''); + // variables['하자보증_발행기관_1'] = String(defectGuarantee.issuer || ''); + // variables['하자보증_비고_1'] = String(guaranteeNote); // 기존 보증 전체 비고를 하자보증 비고로 사용 + // } + + // 하도급 체크리스트 변수 (새로운 템플릿 구조에 맞춤) + if (subcontractChecklist) { + variables['작업전_서면발급_체크'] = String(subcontractChecklist.workDocumentIssuedCheck || subcontractChecklist.workDocumentIssued || ''); + variables['기재사항_1'] = String(subcontractChecklist.legalItem1 || subcontractChecklist.sixLegalItems1 || ''); + variables['기재사항_2'] = String(subcontractChecklist.legalItem2 || subcontractChecklist.sixLegalItems2 || ''); + variables['기재사항_3'] = String(subcontractChecklist.legalItem3 || subcontractChecklist.sixLegalItems3 || ''); + variables['기재사항_4'] = String(subcontractChecklist.legalItem4 || subcontractChecklist.sixLegalItems4 || ''); + variables['기재사항_5'] = String(subcontractChecklist.legalItem5 || subcontractChecklist.sixLegalItems5 || ''); + variables['기재사항_6'] = String(subcontractChecklist.legalItem6 || subcontractChecklist.sixLegalItems6 || ''); + variables['부당대금_결정'] = String(subcontractChecklist.unfairPriceDecision || subcontractChecklist.unfairSubcontractPrice || ''); + variables['점검결과'] = String(subcontractChecklist.inspectionResult || subcontractChecklist.overallResult || ''); + variables['귀책부서'] = String(subcontractChecklist.responsibleDepartment || subcontractChecklist.overallDepartment || ''); + variables['원인'] = String(subcontractChecklist.cause || subcontractChecklist.overallCause || ''); + variables['대책'] = String(subcontractChecklist.countermeasure || subcontractChecklist.overallMeasure || ''); + } return variables; } diff --git a/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx b/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx index d44f4290..db0901cb 100644 --- a/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx +++ b/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx @@ -1,1183 +1,1200 @@ -'use client' - -import React, { useState, useEffect } from 'react' -import { useSession } from 'next-auth/react' -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Badge } from '@/components/ui/badge' -import { Checkbox } from '@/components/ui/checkbox' -import { Label } from '@/components/ui/label' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { Input } from '@/components/ui/input' -import { toast } from 'sonner' -import { - FileText, - Upload, - Eye, - Send, - CheckCircle, - Download, - AlertCircle -} from 'lucide-react' -import { ContractDocuments } from './general-contract-documents' -import { getActiveContractTemplates } from '@/lib/bidding/service' -import { type BasicContractTemplate } from '@/db/schema' -import { - getBasicInfo, - getContractItems, - getSubcontractChecklist, - uploadContractApprovalFile, - sendContractApprovalRequest, - getContractById, - getContractTemplateByContractType, - getStorageInfo -} from '../service' -import { mapContractDataToTemplateVariables } from '../utils' -import { ApprovalPreviewDialog } from '@/lib/approval/client' -import { requestContractApprovalWithApproval } from '../approval-actions' -import { mapContractToApprovalTemplateVariables } from '../approval-template-variables' - -interface ContractApprovalRequestDialogProps { - contract: Record - open: boolean - onOpenChange: (open: boolean) => void -} - -interface ContractSummary { - basicInfo: Record - items: Record[] - subcontractChecklist: Record | null - storageInfo?: Record[] - pdfPath?: string - basicContractPdfs?: Array<{ key: string; buffer: number[]; fileName: string }> -} - -export function ContractApprovalRequestDialog({ - contract, - open, - onOpenChange -}: ContractApprovalRequestDialogProps) { - const { data: session } = useSession() - const [currentStep, setCurrentStep] = useState(1) - const [contractSummary, setContractSummary] = useState(null) - const [uploadedFile, setUploadedFile] = useState(null) - const [generatedPdfUrl, setGeneratedPdfUrl] = useState(null) - const [generatedPdfBuffer, setGeneratedPdfBuffer] = useState(null) - const [isLoading, setIsLoading] = useState(false) - const [pdfViewerInstance, setPdfViewerInstance] = useState(null) - const [isPdfPreviewVisible, setIsPdfPreviewVisible] = useState(false) - - // 기본계약 관련 상태 - const [selectedBasicContracts, setSelectedBasicContracts] = useState>([]) - const [isLoadingBasicContracts, setIsLoadingBasicContracts] = useState(false) - - // 결재 관련 상태 - const [approvalDialogOpen, setApprovalDialogOpen] = useState(false) - const [approvalVariables, setApprovalVariables] = useState>({}) - const [savedPdfPath, setSavedPdfPath] = useState(null) - const [savedBasicContractPdfs, setSavedBasicContractPdfs] = useState>([]) - - const contractId = contract.id as number - const userId = session?.user?.id || '' - - - // 기본계약 생성 함수 (최종 전송 시점에 호출) - const generateBasicContractPdf = async ( - vendorId: number, - contractType: string, - templateName: string - ): Promise<{ buffer: number[], fileName: string }> => { - try { - // 1. 템플릿 데이터 준비 (서버 액션 호출) - const prepareResponse = await fetch("/api/contracts/prepare-template", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - templateName, - vendorId, - }), - }); - - if (!prepareResponse.ok) { - const errorText = await prepareResponse.text(); - throw new Error(`템플릿 준비 실패 (${prepareResponse.status}): ${errorText}`); - } - - const { template, templateData } = await prepareResponse.json(); - - // 2. 템플릿 파일 다운로드 - const templateResponse = await fetch("/api/contracts/get-template", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ templatePath: template.filePath }), - }); - - const templateBlob = await templateResponse.blob(); - const templateFile = new window.File([templateBlob], "template.docx", { - type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - }); - - // 3. PDFTron WebViewer로 PDF 변환 - const { default: WebViewer } = await import("@pdftron/webviewer"); - - const tempDiv = document.createElement('div'); - tempDiv.style.display = 'none'; - document.body.appendChild(tempDiv); - - try { - const instance = await WebViewer( - { - path: "/pdftronWeb", - licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, - fullAPI: true, - enableOfficeEditing: true, - }, - tempDiv - ); - - const { Core } = instance; - const { createDocument } = Core; - - const templateDoc = await createDocument(templateFile, { - filename: templateFile.name, - extension: 'docx', - }); - - // 변수 치환 적용 - await templateDoc.applyTemplateValues(templateData); - await new Promise(resolve => setTimeout(resolve, 3000)); - - const fileData = await templateDoc.getFileData(); - const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' }); - - const fileName = `${contractType}_${contractSummary?.basicInfo?.vendorCode || vendorId}_${Date.now()}.pdf`; - - instance.UI.dispose(); - return { - buffer: Array.from(pdfBuffer), - fileName - }; - - } finally { - if (tempDiv.parentNode) { - document.body.removeChild(tempDiv); - } - } - - } catch (error) { - console.error(`기본계약 PDF 생성 실패 (${contractType}):`, error); - throw error; - } - }; - - // 기본계약 생성 및 선택 초기화 - const initializeBasicContracts = React.useCallback(async () => { - if (!contractSummary?.basicInfo) return; - - setIsLoadingBasicContracts(true); - try { - // 기본적으로 사용할 수 있는 계약서 타입들 - const availableContracts: Array<{ - type: string; - templateName: string; - checked: boolean; - }> = [ - { type: "NDA", templateName: "비밀", checked: false }, - { type: "General_GTC", templateName: "General GTC", checked: false }, - { type: "기술자료", templateName: "기술", checked: false } - ]; - - // 프로젝트 코드가 있으면 Project GTC도 추가 - if (contractSummary.basicInfo.projectCode) { - availableContracts.push({ - type: "Project_GTC", - templateName: contractSummary.basicInfo.projectCode as string, - checked: false - }); - } - - setSelectedBasicContracts(availableContracts); - } catch (error) { - console.error('기본계약 초기화 실패:', error); - toast.error('기본계약 초기화에 실패했습니다.'); - } finally { - setIsLoadingBasicContracts(false); - } - }, [contractSummary]); - - // 기본계약 선택 토글 - const toggleBasicContract = (type: string) => { - setSelectedBasicContracts(prev => - prev.map(contract => - contract.type === type - ? { ...contract, checked: !contract.checked } - : contract - ) - ); - }; - - - // 1단계: 계약 현황 수집 - const collectContractSummary = React.useCallback(async () => { - setIsLoading(true) - try { - // 각 컴포넌트에서 활성화된 데이터만 수집 - const summary: ContractSummary = { - basicInfo: {}, - items: [], - subcontractChecklist: null - } - - // Basic Info 확인 (항상 활성화) - try { - const basicInfoData = await getBasicInfo(contractId) - if (basicInfoData && basicInfoData.success) { - summary.basicInfo = basicInfoData.data || {} - } - // externalYardEntry 정보도 추가로 가져오기 - const contractData = await getContractById(contractId) - if (contractData) { - summary.basicInfo = { - ...summary.basicInfo, - externalYardEntry: contractData.externalYardEntry || 'N' - } - } - } catch { - console.log('Basic Info 데이터 없음') - } - - // 품목 정보 확인 - try { - const itemsData = await getContractItems(contractId) - if (itemsData && itemsData.length > 0) { - summary.items = itemsData - } - } catch { - console.log('품목 정보 데이터 없음') - } - - try { - // Subcontract Checklist 확인 - const subcontractData = await getSubcontractChecklist(contractId) - if (subcontractData && subcontractData.success && subcontractData.enabled) { - summary.subcontractChecklist = subcontractData.data - } - } catch { - console.log('Subcontract Checklist 데이터 없음') - } - - // 임치(물품보관) 계약 정보 확인 (SG) - try { - if (summary.basicInfo?.contractType === 'SG') { - const storageData = await getStorageInfo(contractId) - if (storageData && storageData.length > 0) { - summary.storageInfo = storageData - } - } - } catch { - console.log('임치계약 정보 없음') - } - - console.log('contractSummary 구조:', summary) - console.log('basicInfo 내용:', summary.basicInfo) - setContractSummary(summary) - } catch (error) { - console.error('Error collecting contract summary:', error) - toast.error('계약 정보를 수집하는 중 오류가 발생했습니다.') - } finally { - setIsLoading(false) - } - }, [contractId]) - - // 3단계: PDF 생성 및 미리보기 (PDFTron 사용) - 템플릿 자동 로드 - const generatePdf = async () => { - if (!contractSummary) { - toast.error('계약 정보가 필요합니다.') - return - } - - setIsLoading(true) - try { - // 1. 계약 유형에 맞는 템플릿 조회 - const contractType = contractSummary.basicInfo.contractType as string - const templateResult = await getContractTemplateByContractType(contractType) - - if (!templateResult.success || !templateResult.template) { - throw new Error(templateResult.error || '템플릿을 찾을 수 없습니다.') - } - - const template = templateResult.template - - // 2. 템플릿 파일 다운로드 - const templateResponse = await fetch("/api/contracts/get-template", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ templatePath: template.filePath }), - }) - - if (!templateResponse.ok) { - throw new Error("템플릿 파일을 다운로드할 수 없습니다.") - } - - const templateBlob = await templateResponse.blob() - const templateFile = new File([templateBlob], template.fileName || "template.docx", { - type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - }) - - // 3. PDFTron을 사용해서 변수 치환 및 PDF 변환 - // @ts-ignore - const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer) - - // 임시 WebViewer 인스턴스 생성 (DOM에 추가하지 않음) - const tempDiv = document.createElement('div') - tempDiv.style.display = 'none' - document.body.appendChild(tempDiv) - - const instance = await WebViewer( - { - path: "/pdftronWeb", - licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, - fullAPI: true, - }, - tempDiv - ) - - try { - const { Core } = instance - const { createDocument } = Core - - // 템플릿 문서 생성 및 변수 치환 - const templateDoc = await createDocument(templateFile, { - filename: templateFile.name, - extension: 'docx', - }) - - // 템플릿 변수 매핑 - const mappedTemplateData = mapContractDataToTemplateVariables(contractSummary) - - console.log("🔄 변수 치환 시작:", mappedTemplateData) - await templateDoc.applyTemplateValues(mappedTemplateData as any) - console.log("✅ 변수 치환 완료") - - // PDF 변환 - const fileData = await templateDoc.getFileData() - const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' }) - - console.log(`✅ PDF 변환 완료: ${templateFile.name}`, `크기: ${pdfBuffer.byteLength} bytes`) - - // PDF 버퍼를 Blob URL로 변환하여 미리보기 - const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' }) - const pdfUrl = URL.createObjectURL(pdfBlob) - setGeneratedPdfUrl(pdfUrl) - - // PDF 버퍼를 상태에 저장 (최종 전송 시 사용) - setGeneratedPdfBuffer(new Uint8Array(pdfBuffer)) - - toast.success('PDF가 생성되었습니다.') - - } finally { - // 임시 WebViewer 정리 - instance.UI.dispose() - document.body.removeChild(tempDiv) - } - - } catch (error: any) { - console.error('❌ PDF 생성 실패:', error) - const errorMessage = error instanceof Error ? error.message : (error?.message || '알 수 없는 오류') - toast.error(`PDF 생성 중 오류가 발생했습니다: ${errorMessage}`) - } finally { - setIsLoading(false) - } - } - - // PDF 미리보기 기능 - const openPdfPreview = async () => { - if (!generatedPdfBuffer) { - toast.error('생성된 PDF가 없습니다.') - return - } - - setIsLoading(true) - try { - // @ts-ignore - const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer) - - // 기존 인스턴스가 있다면 정리 - if (pdfViewerInstance) { - console.log("🔄 기존 WebViewer 인스턴스 정리") - try { - pdfViewerInstance.UI.dispose() - } catch (error) { - console.warn('기존 WebViewer 정리 중 오류:', error) - } - setPdfViewerInstance(null) - } - - // 미리보기용 컨테이너 확인 - let previewDiv = document.getElementById('pdf-preview-container') - if (!previewDiv) { - console.log("🔄 컨테이너 생성") - previewDiv = document.createElement('div') - previewDiv.id = 'pdf-preview-container' - previewDiv.className = 'w-full h-full' - previewDiv.style.width = '100%' - previewDiv.style.height = '100%' - - // 실제 컨테이너에 추가 - const actualContainer = document.querySelector('[data-pdf-container]') - if (actualContainer) { - actualContainer.appendChild(previewDiv) - } - } - - console.log("🔄 WebViewer 인스턴스 생성 시작") - - // WebViewer 인스턴스 생성 (문서 없이) - const instance = await Promise.race([ - WebViewer( - { - path: "/pdftronWeb", - licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, - fullAPI: true, - }, - previewDiv - ), - new Promise((_, reject) => - setTimeout(() => reject(new Error('WebViewer 초기화 타임아웃')), 30000) - ) - ]) - - console.log("🔄 WebViewer 인스턴스 생성 완료") - setPdfViewerInstance(instance) - - // PDF 버퍼를 Blob으로 변환 - const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' }) - const pdfUrl = URL.createObjectURL(pdfBlob) - console.log("🔄 PDF Blob URL 생성:", pdfUrl) - - // 문서 로드 - console.log("🔄 문서 로드 시작") - const { documentViewer } = (instance as any).Core - - // 문서 로드 이벤트 대기 - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('문서 로드 타임아웃')) - }, 20000) - - const onDocumentLoaded = () => { - clearTimeout(timeout) - documentViewer.removeEventListener('documentLoaded', onDocumentLoaded) - documentViewer.removeEventListener('documentError', onDocumentError) - console.log("🔄 문서 로드 완료") - resolve(true) - } - - const onDocumentError = (error: any) => { - clearTimeout(timeout) - documentViewer.removeEventListener('documentLoaded', onDocumentLoaded) - documentViewer.removeEventListener('documentError', onDocumentError) - console.error('문서 로드 오류:', error) - reject(error) - } - - documentViewer.addEventListener('documentLoaded', onDocumentLoaded) - documentViewer.addEventListener('documentError', onDocumentError) - - // 문서 로드 시작 - documentViewer.loadDocument(pdfUrl) - }) - - setIsPdfPreviewVisible(true) - toast.success('PDF 미리보기가 준비되었습니다.') - - } catch (error) { - console.error('PDF 미리보기 실패:', error) - toast.error(`PDF 미리보기 중 오류가 발생했습니다: ${error.message}`) - } finally { - setIsLoading(false) - } - } - - // PDF 다운로드 기능 - const downloadPdf = () => { - if (!generatedPdfBuffer) { - toast.error('다운로드할 PDF가 없습니다.') - return - } - - const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' }) - const pdfUrl = URL.createObjectURL(pdfBlob) - - const link = document.createElement('a') - link.href = pdfUrl - link.download = `contract_${contractId}_${Date.now()}.pdf` - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - - URL.revokeObjectURL(pdfUrl) - toast.success('PDF가 다운로드되었습니다.') - } - - // PDF 미리보기 닫기 - const closePdfPreview = () => { - console.log("🔄 PDF 미리보기 닫기 시작") - if (pdfViewerInstance) { - try { - console.log("🔄 WebViewer 인스턴스 정리") - pdfViewerInstance.UI.dispose() - } catch (error) { - console.warn('WebViewer 정리 중 오류:', error) - } - setPdfViewerInstance(null) - } - - // 컨테이너 정리 - const previewDiv = document.getElementById('pdf-preview-container') - if (previewDiv) { - try { - previewDiv.innerHTML = '' - } catch (error) { - console.warn('컨테이너 정리 중 오류:', error) - } - } - - setIsPdfPreviewVisible(false) - console.log("🔄 PDF 미리보기 닫기 완료") - } - - // PDF를 서버에 저장하는 함수 (API route 사용) - const savePdfToServer = async (pdfBuffer: Uint8Array, fileName: string): Promise => { - try { - // PDF 버퍼를 Blob으로 변환 - const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' }); - - // FormData 생성 - const formData = new FormData(); - formData.append('file', pdfBlob, fileName); - formData.append('contractId', String(contractId)); - - // API route로 업로드 - const response = await fetch('/api/general-contracts/upload-pdf', { - method: 'POST', - body: formData, - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'PDF 파일 저장에 실패했습니다.'); - } - - const result = await response.json(); - - if (!result.success) { - throw new Error(result.error || 'PDF 파일 저장에 실패했습니다.'); - } - - return result.filePath; - } catch (error) { - console.error('PDF 저장 실패:', error); - return null; - } - }; - - // 최종 전송 - 결재 프로세스 시작 - const handleFinalSubmit = async () => { - if (!generatedPdfUrl || !contractSummary || !generatedPdfBuffer) { - toast.error('생성된 PDF가 필요합니다.') - return - } - - if (!userId) { - toast.error('로그인이 필요합니다.') - return - } - - setIsLoading(true) - try { - // 기본계약서 생성 (최종 전송 시점에) - let generatedBasicContractPdfs: Array<{ key: string; buffer: number[]; fileName: string }> = []; - - const contractsToGenerate = selectedBasicContracts.filter(c => c.checked); - if (contractsToGenerate.length > 0) { - // vendorId 조회 - let vendorId: number | undefined; - try { - const basicInfoData = await getBasicInfo(contractId); - if (basicInfoData && basicInfoData.success && basicInfoData.data) { - vendorId = basicInfoData.data.vendorId; - } - } catch (error) { - console.error('vendorId 조회 실패:', error); - } - - if (vendorId) { - toast.info('기본계약서를 생성하는 중입니다...'); - - for (const contract of contractsToGenerate) { - try { - const pdf = await generateBasicContractPdf(vendorId, contract.type, contract.templateName); - generatedBasicContractPdfs.push({ - key: `${vendorId}_${contract.type}_${contract.templateName}`, - ...pdf - }); - } catch (error) { - console.error(`${contract.type} 계약서 생성 실패:`, error); - // 개별 실패는 전체를 중단하지 않음 - } - } - - if (generatedBasicContractPdfs.length > 0) { - toast.success(`${generatedBasicContractPdfs.length}개의 기본계약서가 생성되었습니다.`); - } - } - } - - // PDF를 서버에 저장 - toast.info('PDF를 서버에 저장하는 중입니다...'); - const pdfPath = await savePdfToServer( - generatedPdfBuffer, - `contract_${contractId}_${Date.now()}.pdf` - ); - - if (!pdfPath) { - toast.error('PDF 저장에 실패했습니다.'); - return; - } - - setSavedPdfPath(pdfPath); - setSavedBasicContractPdfs(generatedBasicContractPdfs); - - // 결재 템플릿 변수 매핑 - const approvalVars = await mapContractToApprovalTemplateVariables(contractSummary); - setApprovalVariables(approvalVars); - - // 계약승인요청 dialog close - onOpenChange(false); - - // 결재 템플릿 dialog open - setApprovalDialogOpen(true); - } catch (error: any) { - console.error('Error preparing approval:', error); - toast.error('결재 준비 중 오류가 발생했습니다.') - } finally { - setIsLoading(false) - } - } - - // 결재 등록 처리 - const handleApprovalSubmit = async (data: { - approvers: string[]; - title: string; - attachments?: File[]; - }) => { - if (!contractSummary || !savedPdfPath) { - toast.error('계약 정보가 필요합니다.') - return - } - - setIsLoading(true) - try { - const result = await requestContractApprovalWithApproval({ - contractId, - contractSummary: { - ...contractSummary, - // PDF 경로를 contractSummary에 추가 - pdfPath: savedPdfPath || undefined, - basicContractPdfs: savedBasicContractPdfs.length > 0 ? savedBasicContractPdfs : undefined, - } as ContractSummary, - currentUser: { - id: Number(userId), - epId: session?.user?.epId || null, - email: session?.user?.email || undefined, - }, - approvers: data.approvers, - title: data.title, - }); - - if (result.status === 'pending_approval') { - toast.success('결재가 등록되었습니다.') - setApprovalDialogOpen(false); - } else { - toast.error('결재 등록에 실패했습니다.') - } - } catch (error: any) { - console.error('Error submitting approval:', error); - toast.error(`결재 등록 중 오류가 발생했습니다: ${error.message || '알 수 없는 오류'}`); - } finally { - setIsLoading(false) - } - } - - // 다이얼로그가 열릴 때 1단계 데이터 수집 - useEffect(() => { - if (open && currentStep === 1) { - collectContractSummary() - } - }, [open, currentStep, collectContractSummary]) - - // 계약 요약이 준비되면 기본계약 초기화 - useEffect(() => { - if (contractSummary && currentStep === 2) { - const loadBasicContracts = async () => { - await initializeBasicContracts() - } - loadBasicContracts() - } - }, [contractSummary, currentStep, initializeBasicContracts]) - - // 다이얼로그가 닫힐 때 PDF 뷰어 정리 - useEffect(() => { - if (!open) { - closePdfPreview() - } - }, [open]) - - - return ( - - - - - - 계약승인요청 - - - - - - - 1. 계약 현황 정리 - - - 2. 기본계약 체크 - - - 3. PDF 미리보기 - - - - {/* 1단계: 계약 현황 정리 */} - - - - - - 작성된 계약 현황 - - - - {isLoading ? ( -
-
-

계약 정보를 수집하는 중...

-
- ) : ( -
- {/* 기본 정보 (필수) */} -
-
- - - 필수 -
-
-
- 계약번호: {String(contractSummary?.basicInfo?.contractNumber || '')} -
-
- 계약명: {String(contractSummary?.basicInfo?.contractName || '')} -
-
- 벤더: {String(contractSummary?.basicInfo?.vendorName || '')} -
-
- 프로젝트: {String(contractSummary?.basicInfo?.projectName || '')} -
-
- 계약유형: {String(contractSummary?.basicInfo?.contractType || '')} -
-
- 계약상태: {String(contractSummary?.basicInfo?.contractStatus || '')} -
-
- 계약금액: {String(contractSummary?.basicInfo?.contractAmount || '')} {String(contractSummary?.basicInfo?.currency || '')} -
-
- 계약기간: {String(contractSummary?.basicInfo?.startDate || '')} ~ {String(contractSummary?.basicInfo?.endDate || '')} -
-
- 사양서 유형: {String(contractSummary?.basicInfo?.specificationType || '')} -
-
- 단가 유형: {String(contractSummary?.basicInfo?.unitPriceType || '')} -
-
- 연결 PO번호: {String(contractSummary?.basicInfo?.linkedPoNumber || '')} -
-
- 연결 입찰번호: {String(contractSummary?.basicInfo?.linkedBidNumber || '')} -
-
-
- - {/* 지급/인도 조건 */} -
-
- - - 필수 -
-
-
- 지급조건: {String(contractSummary?.basicInfo?.paymentTerm || '')} -
-
- 세금 유형: {String(contractSummary?.basicInfo?.taxType || '')} -
-
- 인도조건: {String(contractSummary?.basicInfo?.deliveryTerm || '')} -
-
- 인도유형: {String(contractSummary?.basicInfo?.deliveryType || '')} -
-
- 선적지: {String(contractSummary?.basicInfo?.shippingLocation || '')} -
-
- 하역지: {String(contractSummary?.basicInfo?.dischargeLocation || '')} -
-
- 계약납기: {String(contractSummary?.basicInfo?.contractDeliveryDate || '')} -
-
- 위약금: {contractSummary?.basicInfo?.liquidatedDamages ? '적용' : '미적용'} -
-
-
- - {/* 추가 조건 */} -
-
- - - 필수 -
-
-
- 연동제 정보: {String(contractSummary?.basicInfo?.interlockingSystem || '')} -
-
- 계약성립조건: - {contractSummary?.basicInfo?.contractEstablishmentConditions && - Object.entries(contractSummary.basicInfo.contractEstablishmentConditions as Record) - .filter(([, value]) => value === true) - .map(([key]) => key) - .join(', ') || '없음'} -
-
- 계약해지조건: - {contractSummary?.basicInfo?.contractTerminationConditions && - Object.entries(contractSummary.basicInfo.contractTerminationConditions as Record) - .filter(([, value]) => value === true) - .map(([key]) => key) - .join(', ') || '없음'} -
-
-
- - {/* 품목 정보 */} -
-
- 0} - disabled - /> - - 선택 -
- {contractSummary?.items && contractSummary.items.length > 0 ? ( -
-

- 총 {contractSummary.items.length}개 품목이 입력되어 있습니다. -

-
- {contractSummary.items.slice(0, 3).map((item: Record, index: number) => ( -
-
{String(item.itemInfo || item.description || `품목 ${index + 1}`)}
-
- 수량: {String(item.quantity || 0)} | 단가: {String(item.contractUnitPrice || item.unitPrice || 0)} -
-
- ))} - {contractSummary.items.length > 3 && ( -
- ... 외 {contractSummary.items.length - 3}개 품목 -
- )} -
-
- ) : ( -

- 품목 정보가 입력되지 않았습니다. -

- )} -
- - {/* 하도급 체크리스트 */} -
-
- - - 선택 -
-

- {contractSummary?.subcontractChecklist - ? '정보가 입력되어 있습니다.' - : '정보가 입력되지 않았습니다.'} -

-
-
- )} -
-
- -
- -
-
- - {/* 2단계: 기본계약 체크 */} - - - - - - 기본계약서 선택 - -

- 벤더에게 발송할 기본계약서를 선택해주세요. (템플릿이 있는 계약서만 선택 가능합니다.) -

-
- - {isLoadingBasicContracts ? ( -
-
-

기본계약 템플릿을 불러오는 중...

-
- ) : ( -
- {selectedBasicContracts.length > 0 ? ( -
-
-

필요한 기본계약서

- - {selectedBasicContracts.filter(c => c.checked).length}개 선택됨 - -
- -
- {selectedBasicContracts.map((contract) => ( -
-
- toggleBasicContract(contract.type)} - /> -
- -

- 템플릿: {contract.templateName} -

-
-
- - {contract.checked ? "선택됨" : "미선택"} - -
- ))} -
- -
- ) : ( -
- -

기본계약서 목록을 불러올 수 없습니다.

-

잠시 후 다시 시도해주세요.

-
- )} - -
- )} -
-
- -
- - -
-
- - {/* 3단계: PDF 미리보기 */} - - - - - - PDF 미리보기 - - - - {!generatedPdfUrl ? ( -
- -
- ) : ( -
-
-
- - PDF 생성 완료 -
-
- -
-
-

생성된 PDF

-
- - -
-
- - {/* PDF 미리보기 영역 */} -
- {isPdfPreviewVisible ? ( - <> -
- -
-
- - ) : ( -
-
- -

미리보기 버튼을 클릭하여 PDF를 확인하세요

-
-
- )} -
-
-
- )} - - - -
- - -
- - - - - {/* 결재 미리보기 Dialog */} - {session?.user && session.user.epId && contractSummary && ( - { - setApprovalDialogOpen(open); - if (!open) { - setApprovalVariables({}); - setSavedPdfPath(null); - setSavedBasicContractPdfs([]); - } - }} - templateName="일반계약 결재" - variables={approvalVariables} - title={`계약 체결 진행 품의 요청서 - ${contractSummary.basicInfo?.contractNumber || contractId}`} - currentUser={{ - id: Number(session.user.id), - epId: session.user.epId, - name: session.user.name || undefined, - email: session.user.email || undefined, - }} - onConfirm={handleApprovalSubmit} - enableAttachments={false} - /> - )} -
+'use client' + +import React, { useState, useEffect } from 'react' +import { useSession } from 'next-auth/react' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Checkbox } from '@/components/ui/checkbox' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Input } from '@/components/ui/input' +import { toast } from 'sonner' +import { + FileText, + Upload, + Eye, + Send, + CheckCircle, + Download, + AlertCircle +} from 'lucide-react' +import { ContractDocuments } from './general-contract-documents' +import { getActiveContractTemplates } from '@/lib/bidding/service' +import { type BasicContractTemplate } from '@/db/schema' +import { + getBasicInfo, + getContractItems, + getSubcontractChecklist, + uploadContractApprovalFile, + sendContractApprovalRequest, + getContractById, + getContractTemplateByContractType, + getStorageInfo +} from '../service' +import { mapContractDataToTemplateVariables } from '../utils' +import { ApprovalPreviewDialog } from '@/lib/approval/client' +import { requestContractApprovalWithApproval } from '../approval-actions' +import { mapContractToApprovalTemplateVariables } from '../approval-template-variables' + +interface ContractApprovalRequestDialogProps { + contract: Record + open: boolean + onOpenChange: (open: boolean) => void +} + +interface ContractSummary { + basicInfo: Record + items: Record[] + subcontractChecklist: Record | null + storageInfo?: Record[] + pdfPath?: string + basicContractPdfs?: Array<{ key: string; buffer: number[]; fileName: string }> +} + +export function ContractApprovalRequestDialog({ + contract, + open, + onOpenChange +}: ContractApprovalRequestDialogProps) { + const { data: session } = useSession() + const [currentStep, setCurrentStep] = useState(1) + const [contractSummary, setContractSummary] = useState(null) + const [uploadedFile, setUploadedFile] = useState(null) + const [generatedPdfUrl, setGeneratedPdfUrl] = useState(null) + const [generatedPdfBuffer, setGeneratedPdfBuffer] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [pdfViewerInstance, setPdfViewerInstance] = useState(null) + const [isPdfPreviewVisible, setIsPdfPreviewVisible] = useState(false) + + // 기본계약 관련 상태 + const [selectedBasicContracts, setSelectedBasicContracts] = useState>([]) + const [isLoadingBasicContracts, setIsLoadingBasicContracts] = useState(false) + + // 결재 관련 상태 + const [approvalDialogOpen, setApprovalDialogOpen] = useState(false) + const [approvalVariables, setApprovalVariables] = useState>({}) + const [savedPdfPath, setSavedPdfPath] = useState(null) + const [savedBasicContractPdfs, setSavedBasicContractPdfs] = useState>([]) + + const contractId = contract.id as number + const userId = session?.user?.id || '' + + + // 기본계약 생성 함수 (최종 전송 시점에 호출) + const generateBasicContractPdf = async ( + vendorId: number, + contractType: string, + templateName: string + ): Promise<{ buffer: number[], fileName: string }> => { + try { + // 1. 템플릿 데이터 준비 (서버 액션 호출) + const prepareResponse = await fetch("/api/contracts/prepare-template", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + templateName, + vendorId, + }), + }); + + if (!prepareResponse.ok) { + const errorText = await prepareResponse.text(); + throw new Error(`템플릿 준비 실패 (${prepareResponse.status}): ${errorText}`); + } + + const { template, templateData } = await prepareResponse.json(); + + // 2. 템플릿 파일 다운로드 + const templateResponse = await fetch("/api/contracts/get-template", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ templatePath: template.filePath }), + }); + + const templateBlob = await templateResponse.blob(); + const templateFile = new window.File([templateBlob], "template.docx", { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + }); + + // 3. PDFTron WebViewer로 PDF 변환 + const { default: WebViewer } = await import("@pdftron/webviewer"); + + const tempDiv = document.createElement('div'); + tempDiv.style.display = 'none'; + document.body.appendChild(tempDiv); + + try { + const instance = await WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + fullAPI: true, + enableOfficeEditing: true, + }, + tempDiv + ); + + const { Core } = instance; + const { createDocument } = Core; + + const templateDoc = await createDocument(templateFile, { + filename: templateFile.name, + extension: 'docx', + }); + + // 변수 치환 적용 + await templateDoc.applyTemplateValues(templateData); + await new Promise(resolve => setTimeout(resolve, 3000)); + + const fileData = await templateDoc.getFileData(); + const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' }); + + const fileName = `${contractType}_${contractSummary?.basicInfo?.vendorCode || vendorId}_${Date.now()}.pdf`; + + instance.UI.dispose(); + return { + buffer: Array.from(pdfBuffer), + fileName + }; + + } finally { + if (tempDiv.parentNode) { + document.body.removeChild(tempDiv); + } + } + + } catch (error) { + console.error(`기본계약 PDF 생성 실패 (${contractType}):`, error); + throw error; + } + }; + + // 기본계약 생성 및 선택 초기화 + const initializeBasicContracts = React.useCallback(async () => { + if (!contractSummary?.basicInfo) return; + + setIsLoadingBasicContracts(true); + try { + // 기본적으로 사용할 수 있는 계약서 타입들 + const availableContracts: Array<{ + type: string; + templateName: string; + checked: boolean; + }> = [ + { type: "NDA", templateName: "비밀", checked: false }, + { type: "General_GTC", templateName: "General GTC", checked: false }, + { type: "기술자료", templateName: "기술", checked: false } + ]; + + // 프로젝트 코드가 있으면 Project GTC도 추가 + if (contractSummary.basicInfo.projectCode) { + availableContracts.push({ + type: "Project_GTC", + templateName: contractSummary.basicInfo.projectCode as string, + checked: false + }); + } + + setSelectedBasicContracts(availableContracts); + } catch (error) { + console.error('기본계약 초기화 실패:', error); + toast.error('기본계약 초기화에 실패했습니다.'); + } finally { + setIsLoadingBasicContracts(false); + } + }, [contractSummary]); + + // 기본계약 선택 토글 + const toggleBasicContract = (type: string) => { + setSelectedBasicContracts(prev => + prev.map(contract => + contract.type === type + ? { ...contract, checked: !contract.checked } + : contract + ) + ); + }; + + + // 1단계: 계약 현황 수집 + const collectContractSummary = React.useCallback(async () => { + setIsLoading(true) + try { + // 각 컴포넌트에서 활성화된 데이터만 수집 + const summary: ContractSummary = { + basicInfo: {}, + items: [], + subcontractChecklist: null + } + + // Basic Info 확인 (항상 활성화) + try { + const basicInfoData = await getBasicInfo(contractId) + if (basicInfoData && basicInfoData.success) { + summary.basicInfo = basicInfoData.data || {} + } + // externalYardEntry 정보도 추가로 가져오기 + const contractData = await getContractById(contractId) + if (contractData) { + summary.basicInfo = { + ...summary.basicInfo, + externalYardEntry: contractData.externalYardEntry || 'N' + } + } + } catch { + console.log('Basic Info 데이터 없음') + } + + // 품목 정보 확인 + try { + const itemsData = await getContractItems(contractId) + if (itemsData && itemsData.length > 0) { + summary.items = itemsData + } + } catch { + console.log('품목 정보 데이터 없음') + } + + try { + // Subcontract Checklist 확인 + const subcontractData = await getSubcontractChecklist(contractId) + if (subcontractData && subcontractData.success && subcontractData.enabled) { + summary.subcontractChecklist = subcontractData.data + } + } catch { + console.log('Subcontract Checklist 데이터 없음') + } + + // 임치(물품보관) 계약 정보 확인 (SG) + try { + if (summary.basicInfo?.contractType === 'SG') { + const storageData = await getStorageInfo(contractId) + if (storageData && storageData.length > 0) { + summary.storageInfo = storageData + } + } + } catch { + console.log('임치계약 정보 없음') + } + + console.log('contractSummary 구조:', summary) + console.log('basicInfo 내용:', summary.basicInfo) + setContractSummary(summary) + } catch (error) { + console.error('Error collecting contract summary:', error) + toast.error('계약 정보를 수집하는 중 오류가 발생했습니다.') + } finally { + setIsLoading(false) + } + }, [contractId]) + + // 3단계: PDF 생성 및 미리보기 (PDFTron 사용) - 템플릿 자동 로드 + const generatePdf = async () => { + if (!contractSummary) { + toast.error('계약 정보가 필요합니다.') + return + } + + setIsLoading(true) + try { + // 1. 계약 유형에 맞는 템플릿 조회 + const contractType = contractSummary.basicInfo.contractType as string + const templateResult = await getContractTemplateByContractType(contractType) + + if (!templateResult.success || !templateResult.template) { + throw new Error(templateResult.error || '템플릿을 찾을 수 없습니다.') + } + + const template = templateResult.template + + // 2. 템플릿 파일 다운로드 + const templateResponse = await fetch("/api/contracts/get-template", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ templatePath: template.filePath }), + }) + + if (!templateResponse.ok) { + throw new Error("템플릿 파일을 다운로드할 수 없습니다.") + } + + const templateBlob = await templateResponse.blob() + const templateFile = new File([templateBlob], template.fileName || "template.docx", { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + }) + + // 3. PDFTron을 사용해서 변수 치환 및 PDF 변환 + // @ts-ignore + const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer) + + // 임시 WebViewer 인스턴스 생성 (DOM에 추가하지 않음) + const tempDiv = document.createElement('div') + tempDiv.style.display = 'none' + document.body.appendChild(tempDiv) + + const instance = await WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + fullAPI: true, + }, + tempDiv + ) + + try { + const { Core } = instance + const { createDocument } = Core + + // 템플릿 문서 생성 및 변수 치환 + const templateDoc = await createDocument(templateFile, { + filename: templateFile.name, + extension: 'docx', + }) + + // 템플릿 변수 매핑 + const mappedTemplateData = mapContractDataToTemplateVariables(contractSummary) + + console.log("🔄 변수 치환 시작:", mappedTemplateData) + await templateDoc.applyTemplateValues(mappedTemplateData as any) + console.log("✅ 변수 치환 완료") + + // PDF 변환 + const fileData = await templateDoc.getFileData() + const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' }) + + console.log(`✅ PDF 변환 완료: ${templateFile.name}`, `크기: ${pdfBuffer.byteLength} bytes`) + + // PDF 버퍼를 Blob URL로 변환하여 미리보기 + const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' }) + const pdfUrl = URL.createObjectURL(pdfBlob) + setGeneratedPdfUrl(pdfUrl) + + // PDF 버퍼를 상태에 저장 (최종 전송 시 사용) + setGeneratedPdfBuffer(new Uint8Array(pdfBuffer)) + + toast.success('PDF가 생성되었습니다.') + + } finally { + // 임시 WebViewer 정리 + instance.UI.dispose() + document.body.removeChild(tempDiv) + } + + } catch (error: any) { + console.error('❌ PDF 생성 실패:', error) + const errorMessage = error instanceof Error ? error.message : (error?.message || '알 수 없는 오류') + toast.error(`PDF 생성 중 오류가 발생했습니다: ${errorMessage}`) + } finally { + setIsLoading(false) + } + } + + // PDF 미리보기 기능 + const openPdfPreview = async () => { + if (!generatedPdfBuffer) { + toast.error('생성된 PDF가 없습니다.') + return + } + + setIsLoading(true) + try { + // @ts-ignore + const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer) + + // 기존 인스턴스가 있다면 정리 + if (pdfViewerInstance) { + console.log("🔄 기존 WebViewer 인스턴스 정리") + try { + pdfViewerInstance.UI.dispose() + } catch (error) { + console.warn('기존 WebViewer 정리 중 오류:', error) + } + setPdfViewerInstance(null) + } + + // 미리보기용 컨테이너 확인 + let previewDiv = document.getElementById('pdf-preview-container') + if (!previewDiv) { + console.log("🔄 컨테이너 생성") + previewDiv = document.createElement('div') + previewDiv.id = 'pdf-preview-container' + previewDiv.className = 'w-full h-full' + previewDiv.style.width = '100%' + previewDiv.style.height = '100%' + + // 실제 컨테이너에 추가 + const actualContainer = document.querySelector('[data-pdf-container]') + if (actualContainer) { + actualContainer.appendChild(previewDiv) + } + } + + console.log("🔄 WebViewer 인스턴스 생성 시작") + + // WebViewer 인스턴스 생성 (문서 없이) + const instance = await Promise.race([ + WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + fullAPI: true, + }, + previewDiv + ), + new Promise((_, reject) => + setTimeout(() => reject(new Error('WebViewer 초기화 타임아웃')), 30000) + ) + ]) + + console.log("🔄 WebViewer 인스턴스 생성 완료") + setPdfViewerInstance(instance) + + // PDF 버퍼를 Blob으로 변환 + const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' }) + const pdfUrl = URL.createObjectURL(pdfBlob) + console.log("🔄 PDF Blob URL 생성:", pdfUrl) + + // 문서 로드 + console.log("🔄 문서 로드 시작") + const { documentViewer } = (instance as any).Core + + // 문서 로드 이벤트 대기 + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('문서 로드 타임아웃')) + }, 20000) + + const onDocumentLoaded = () => { + clearTimeout(timeout) + documentViewer.removeEventListener('documentLoaded', onDocumentLoaded) + documentViewer.removeEventListener('documentError', onDocumentError) + console.log("🔄 문서 로드 완료") + resolve(true) + } + + const onDocumentError = (error: any) => { + clearTimeout(timeout) + documentViewer.removeEventListener('documentLoaded', onDocumentLoaded) + documentViewer.removeEventListener('documentError', onDocumentError) + console.error('문서 로드 오류:', error) + reject(error) + } + + documentViewer.addEventListener('documentLoaded', onDocumentLoaded) + documentViewer.addEventListener('documentError', onDocumentError) + + // 문서 로드 시작 + documentViewer.loadDocument(pdfUrl) + }) + + setIsPdfPreviewVisible(true) + toast.success('PDF 미리보기가 준비되었습니다.') + + } catch (error) { + console.error('PDF 미리보기 실패:', error) + toast.error(`PDF 미리보기 중 오류가 발생했습니다: ${error.message}`) + } finally { + setIsLoading(false) + } + } + + // PDF 다운로드 기능 + const downloadPdf = () => { + if (!generatedPdfBuffer) { + toast.error('다운로드할 PDF가 없습니다.') + return + } + + const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' }) + const pdfUrl = URL.createObjectURL(pdfBlob) + + const link = document.createElement('a') + link.href = pdfUrl + link.download = `contract_${contractId}_${Date.now()}.pdf` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + + URL.revokeObjectURL(pdfUrl) + toast.success('PDF가 다운로드되었습니다.') + } + + // PDF 미리보기 닫기 + const closePdfPreview = () => { + console.log("🔄 PDF 미리보기 닫기 시작") + if (pdfViewerInstance) { + try { + console.log("🔄 WebViewer 인스턴스 정리") + pdfViewerInstance.UI.dispose() + } catch (error) { + console.warn('WebViewer 정리 중 오류:', error) + } + setPdfViewerInstance(null) + } + + // 컨테이너 정리 + const previewDiv = document.getElementById('pdf-preview-container') + if (previewDiv) { + try { + previewDiv.innerHTML = '' + } catch (error) { + console.warn('컨테이너 정리 중 오류:', error) + } + } + + setIsPdfPreviewVisible(false) + console.log("🔄 PDF 미리보기 닫기 완료") + } + + // PDF를 서버에 저장하는 함수 (API route 사용) + const savePdfToServer = async (pdfBuffer: Uint8Array, fileName: string): Promise => { + try { + // PDF 버퍼를 Blob으로 변환 + const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' }); + + // FormData 생성 + const formData = new FormData(); + formData.append('file', pdfBlob, fileName); + formData.append('contractId', String(contractId)); + + // API route로 업로드 + const response = await fetch('/api/general-contracts/upload-pdf', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'PDF 파일 저장에 실패했습니다.'); + } + + const result = await response.json(); + + if (!result.success) { + throw new Error(result.error || 'PDF 파일 저장에 실패했습니다.'); + } + + return result.filePath; + } catch (error) { + console.error('PDF 저장 실패:', error); + return null; + } + }; + + // 최종 전송 - 결재 프로세스 시작 + const handleFinalSubmit = async () => { + if (!generatedPdfUrl || !contractSummary || !generatedPdfBuffer) { + toast.error('생성된 PDF가 필요합니다.') + return + } + + if (!userId) { + toast.error('로그인이 필요합니다.') + return + } + + setIsLoading(true) + try { + // 기본계약서 생성 (최종 전송 시점에) + let generatedBasicContractPdfs: Array<{ key: string; buffer: number[]; fileName: string }> = []; + + const contractsToGenerate = selectedBasicContracts.filter(c => c.checked); + if (contractsToGenerate.length > 0) { + // vendorId 조회 + let vendorId: number | undefined; + try { + const basicInfoData = await getBasicInfo(contractId); + if (basicInfoData && basicInfoData.success && basicInfoData.data) { + vendorId = basicInfoData.data.vendorId; + } + } catch (error) { + console.error('vendorId 조회 실패:', error); + } + + if (vendorId) { + toast.info('기본계약서를 생성하는 중입니다...'); + + for (const contract of contractsToGenerate) { + try { + const pdf = await generateBasicContractPdf(vendorId, contract.type, contract.templateName); + generatedBasicContractPdfs.push({ + key: `${vendorId}_${contract.type}_${contract.templateName}`, + ...pdf + }); + } catch (error) { + console.error(`${contract.type} 계약서 생성 실패:`, error); + // 개별 실패는 전체를 중단하지 않음 + } + } + + if (generatedBasicContractPdfs.length > 0) { + toast.success(`${generatedBasicContractPdfs.length}개의 기본계약서가 생성되었습니다.`); + } + } + } + + // PDF를 서버에 저장 + toast.info('PDF를 서버에 저장하는 중입니다...'); + const pdfPath = await savePdfToServer( + generatedPdfBuffer, + `contract_${contractId}_${Date.now()}.pdf` + ); + + if (!pdfPath) { + toast.error('PDF 저장에 실패했습니다.'); + return; + } + + setSavedPdfPath(pdfPath); + setSavedBasicContractPdfs(generatedBasicContractPdfs); + + // 결재 템플릿 변수 매핑 + const approvalVars = await mapContractToApprovalTemplateVariables(contractSummary); + setApprovalVariables(approvalVars); + + // 계약승인요청 dialog close + onOpenChange(false); + + // 결재 템플릿 dialog open + setApprovalDialogOpen(true); + } catch (error: any) { + console.error('Error preparing approval:', error); + toast.error('결재 준비 중 오류가 발생했습니다.') + } finally { + setIsLoading(false) + } + } + + // 결재 등록 처리 + const handleApprovalSubmit = async (data: { + approvers: string[]; + title: string; + attachments?: File[]; + }) => { + if (!contractSummary || !savedPdfPath) { + toast.error('계약 정보가 필요합니다.') + return + } + + setIsLoading(true) + try { + const result = await requestContractApprovalWithApproval({ + contractId, + contractSummary: { + ...contractSummary, + // PDF 경로를 contractSummary에 추가 + pdfPath: savedPdfPath || undefined, + basicContractPdfs: savedBasicContractPdfs.length > 0 ? savedBasicContractPdfs : undefined, + } as ContractSummary, + currentUser: { + id: Number(userId), + epId: session?.user?.epId || null, + email: session?.user?.email || undefined, + }, + approvers: data.approvers, + title: data.title, + }); + + if (result.status === 'pending_approval') { + toast.success('결재가 등록되었습니다.') + setApprovalDialogOpen(false); + } else { + toast.error('결재 등록에 실패했습니다.') + } + } catch (error: any) { + console.error('Error submitting approval:', error); + toast.error(`결재 등록 중 오류가 발생했습니다: ${error.message || '알 수 없는 오류'}`); + } finally { + setIsLoading(false) + } + } + + // 다이얼로그가 열릴 때 1단계 데이터 수집 + useEffect(() => { + if (open && currentStep === 1) { + collectContractSummary() + } + }, [open, currentStep, collectContractSummary]) + + // 계약 요약이 준비되면 기본계약 초기화 + useEffect(() => { + if (contractSummary && currentStep === 2) { + const loadBasicContracts = async () => { + await initializeBasicContracts() + } + loadBasicContracts() + } + }, [contractSummary, currentStep, initializeBasicContracts]) + + // 다이얼로그가 닫힐 때 PDF 뷰어 정리 + useEffect(() => { + if (!open) { + closePdfPreview() + } + }, [open]) + + + return ( + + + + + + 계약승인요청 + + + + + + + 1. 계약 현황 정리 + + + 2. 기본계약 체크 + + + 3. PDF 미리보기 + + + + {/* 1단계: 계약 현황 정리 */} + + + + + + 작성된 계약 현황 + + + + {isLoading ? ( +
+
+

계약 정보를 수집하는 중...

+
+ ) : ( +
+ {/* 기본 정보 (필수) */} +
+
+ + + 필수 +
+
+
+ 계약번호: {String(contractSummary?.basicInfo?.contractNumber || '')} +
+
+ 계약명: {String(contractSummary?.basicInfo?.contractName || '')} +
+
+ 벤더: {String(contractSummary?.basicInfo?.vendorName || '')} +
+
+ 프로젝트: {String(contractSummary?.basicInfo?.projectName || '')} +
+
+ 계약유형: {String(contractSummary?.basicInfo?.contractType || '')} +
+
+ 계약상태: {String(contractSummary?.basicInfo?.contractStatus || '')} +
+
+ 계약금액: {String(contractSummary?.basicInfo?.contractAmount || '')} {String(contractSummary?.basicInfo?.currency || '')} +
+
+ 계약기간: {String(contractSummary?.basicInfo?.startDate || '')} ~ {String(contractSummary?.basicInfo?.endDate || '')} +
+
+ 사양서 유형: {String(contractSummary?.basicInfo?.specificationType || '')} +
+
+ 단가 유형: {String(contractSummary?.basicInfo?.unitPriceType || '')} +
+
+ 연결 PO번호: {String(contractSummary?.basicInfo?.linkedPoNumber || '')} +
+
+ 연결 입찰번호: {String(contractSummary?.basicInfo?.linkedBidNumber || '')} +
+
+
+ + {/* 지급/인도 조건 */} +
+
+ + + 필수 +
+
+
+ 지급조건: {String(contractSummary?.basicInfo?.paymentTerm || '')} +
+
+ 세금 유형: {String(contractSummary?.basicInfo?.taxType || '')} +
+
+ 인도조건: {String(contractSummary?.basicInfo?.deliveryTerm || '')} +
+
+ 인도유형: {String(contractSummary?.basicInfo?.deliveryType || '')} +
+
+ 선적지: {String(contractSummary?.basicInfo?.shippingLocation || '')} +
+
+ 하역지: {String(contractSummary?.basicInfo?.dischargeLocation || '')} +
+
+ 계약납기: {String(contractSummary?.basicInfo?.contractDeliveryDate || '')} +
+
+ 위약금: {contractSummary?.basicInfo?.liquidatedDamages ? '적용' : '미적용'} +
+
+
+ + {/* 추가 조건 */} +
+
+ + + 필수 +
+
+
+ 연동제 정보: {String(contractSummary?.basicInfo?.interlockingSystem || '')} +
+
+ 계약성립조건: + {contractSummary?.basicInfo?.contractEstablishmentConditions ? (() => { + const conditions = Object.entries(contractSummary.basicInfo.contractEstablishmentConditions as Record) + .filter(([, value]) => value === true) + .map(([key]) => { + const conditionMap: Record = { + 'ownerApproval': '정규업체 등록(실사 포함) 시', + 'regularVendorRegistration': '프로젝트 수주 시', + 'shipOwnerApproval': '선주 승인 시', + 'other': '기타' + }; + return conditionMap[key] || key; + }); + return conditions.length > 0 ? conditions.join(', ') : '없음'; + })() : '없음'} +
+
+ 계약해지조건: + {contractSummary?.basicInfo?.contractTerminationConditions ? (() => { + const conditions = Object.entries(contractSummary.basicInfo.contractTerminationConditions as Record) + .filter(([, value]) => value === true) + .map(([key]) => { + const conditionMap: Record = { + 'standardTermination': '표준 계약해지조건', + 'projectNotAwarded': '프로젝트 미수주 시', + 'other': '기타' + }; + return conditionMap[key] || key; + }); + return conditions.length > 0 ? conditions.join(', ') : '없음'; + })() : '없음'} +
+
+
+ + {/* 품목 정보 */} +
+
+ 0} + disabled + /> + + 선택 +
+ {contractSummary?.items && contractSummary.items.length > 0 ? ( +
+

+ 총 {contractSummary.items.length}개 품목이 입력되어 있습니다. +

+
+ {contractSummary.items.slice(0, 3).map((item: Record, index: number) => ( +
+
{String(item.itemInfo || item.description || `품목 ${index + 1}`)}
+
+ 수량: {String(item.quantity || 0)} | 단가: {String(item.contractUnitPrice || item.unitPrice || 0)} +
+
+ ))} + {contractSummary.items.length > 3 && ( +
+ ... 외 {contractSummary.items.length - 3}개 품목 +
+ )} +
+
+ ) : ( +

+ 품목 정보가 입력되지 않았습니다. +

+ )} +
+ + {/* 하도급 체크리스트 */} +
+
+ + + 선택 +
+

+ {contractSummary?.subcontractChecklist + ? '정보가 입력되어 있습니다.' + : '정보가 입력되지 않았습니다.'} +

+
+
+ )} +
+
+ +
+ +
+
+ + {/* 2단계: 기본계약 체크 */} + + + + + + 기본계약서 선택 + +

+ 벤더에게 발송할 기본계약서를 선택해주세요. (템플릿이 있는 계약서만 선택 가능합니다.) +

+
+ + {isLoadingBasicContracts ? ( +
+
+

기본계약 템플릿을 불러오는 중...

+
+ ) : ( +
+ {selectedBasicContracts.length > 0 ? ( +
+
+

필요한 기본계약서

+ + {selectedBasicContracts.filter(c => c.checked).length}개 선택됨 + +
+ +
+ {selectedBasicContracts.map((contract) => ( +
+
+ toggleBasicContract(contract.type)} + /> +
+ +

+ 템플릿: {contract.templateName} +

+
+
+ + {contract.checked ? "선택됨" : "미선택"} + +
+ ))} +
+ +
+ ) : ( +
+ +

기본계약서 목록을 불러올 수 없습니다.

+

잠시 후 다시 시도해주세요.

+
+ )} + +
+ )} +
+
+ +
+ + +
+
+ + {/* 3단계: PDF 미리보기 */} + + + + + + PDF 미리보기 + + + + {!generatedPdfUrl ? ( +
+ +
+ ) : ( +
+
+
+ + PDF 생성 완료 +
+
+ +
+
+

생성된 PDF

+
+ + +
+
+ + {/* PDF 미리보기 영역 */} +
+ {isPdfPreviewVisible ? ( + <> +
+ +
+
+ + ) : ( +
+
+ +

미리보기 버튼을 클릭하여 PDF를 확인하세요

+
+
+ )} +
+
+
+ )} + + + +
+ + +
+ + + + + {/* 결재 미리보기 Dialog */} + {session?.user && session.user.epId && contractSummary && ( + { + setApprovalDialogOpen(open); + if (!open) { + setApprovalVariables({}); + setSavedPdfPath(null); + setSavedBasicContractPdfs([]); + } + }} + templateName="일반계약 결재" + variables={approvalVariables} + title={`계약 체결 진행 품의 요청서 - ${contractSummary.basicInfo?.contractNumber || contractId}`} + currentUser={{ + id: Number(session.user.id), + epId: session.user.epId, + name: session.user.name || undefined, + email: session.user.email || undefined, + }} + onConfirm={handleApprovalSubmit} + enableAttachments={false} + /> + )} +
)} \ No newline at end of file diff --git a/lib/general-contracts/detail/general-contract-items-table.tsx b/lib/general-contracts/detail/general-contract-items-table.tsx index 4f74cfbb..be174417 100644 --- a/lib/general-contracts/detail/general-contract-items-table.tsx +++ b/lib/general-contracts/detail/general-contract-items-table.tsx @@ -218,7 +218,7 @@ export function ContractItemsTable({ const item = itemsToSave[index] // if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`) if (!item.itemInfo) errors.push(`${index + 1}번째 품목의 Item 정보`) - if (!item.quantity || item.quantity <= 0) errors.push(`${index + 1}번째 품목의 수량`) + // if (!item.quantity || item.quantity <= 0) errors.push(`${index + 1}번째 품목의 수량`) if (!item.contractUnitPrice || item.contractUnitPrice <= 0) errors.push(`${index + 1}번째 품목의 단가`) if (!item.contractDeliveryDate) errors.push(`${index + 1}번째 품목의 납기일`) } -- cgit v1.2.3