diff options
| author | joonhoekim <26rote@gmail.com> | 2025-12-04 21:02:10 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-12-04 21:02:10 +0900 |
| commit | 240f4f31b3b6ff6a46436978fb988588a1972721 (patch) | |
| tree | dbf81b022d861cb077e84a10b913c26fd064db8b | |
| parent | 5699e866201566366981ae8399a835fc7fa9fa47 (diff) | |
| parent | ae211e5b9d9bf8e1566b78a85ec4522360833ea9 (diff) | |
(김준회) Merge branch 'dujinkim' of https://github.com/DTS-Development/SHI_EVCP into dujinkim
42 files changed, 3928 insertions, 561 deletions
diff --git a/components/bidding/manage/bidding-companies-editor.tsx b/components/bidding/manage/bidding-companies-editor.tsx index 6634f528..4c3e6bbc 100644 --- a/components/bidding/manage/bidding-companies-editor.tsx +++ b/components/bidding/manage/bidding-companies-editor.tsx @@ -1,7 +1,7 @@ 'use client' import * as React from 'react' -import { Building, User, Plus, Trash2 } from 'lucide-react' +import { Building, User, Plus, Trash2, Users } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' @@ -11,7 +11,9 @@ import { createBiddingCompanyContact, deleteBiddingCompanyContact, getVendorContactsByVendorId, - updateBiddingCompanyPriceAdjustmentQuestion + updateBiddingCompanyPriceAdjustmentQuestion, + getBiddingCompaniesByBidPicId, + addBiddingCompanyFromOtherBidding } from '@/lib/bidding/service' import { deleteBiddingCompany } from '@/lib/bidding/pre-quote/service' import { BiddingDetailVendorCreateDialog } from './bidding-detail-vendor-create-dialog' @@ -36,6 +38,7 @@ import { } from '@/components/ui/table' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { PurchaseGroupCodeSelector, PurchaseGroupCodeWithUser } from '@/components/common/selectors/purchase-group-code/purchase-group-code-selector' interface QuotationVendor { id: number // biddingCompanies.id @@ -102,6 +105,26 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC const [isLoadingVendorContacts, setIsLoadingVendorContacts] = React.useState(false) const [selectedContactFromVendor, setSelectedContactFromVendor] = React.useState<VendorContact | null>(null) + // 협력사 멀티 선택 다이얼로그 + const [multiSelectDialogOpen, setMultiSelectDialogOpen] = React.useState(false) + const [selectedBidPic, setSelectedBidPic] = React.useState<PurchaseGroupCodeWithUser | undefined>(undefined) + const [biddingCompaniesList, setBiddingCompaniesList] = React.useState<Array<{ + biddingId: number + biddingNumber: string + biddingTitle: string + companyId: number + vendorCode: string + vendorName: string + updatedAt: Date + }>>([]) + const [isLoadingBiddingCompanies, setIsLoadingBiddingCompanies] = React.useState(false) + const [selectedBiddingCompany, setSelectedBiddingCompany] = React.useState<{ + biddingId: number + companyId: number + } | null>(null) + const [selectedBiddingCompanyContacts, setSelectedBiddingCompanyContacts] = React.useState<BiddingCompanyContact[]>([]) + const [isLoadingCompanyContacts, setIsLoadingCompanyContacts] = React.useState(false) + // 업체 목록 다시 로딩 함수 const reloadVendors = React.useCallback(async () => { try { @@ -494,10 +517,16 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC </p> </div> {!readonly && ( - <Button onClick={() => setAddVendorDialogOpen(true)} className="flex items-center gap-2" disabled={readonly}> - <Plus className="h-4 w-4" /> - 업체 추가 - </Button> + <div className="flex gap-2"> + <Button onClick={() => setMultiSelectDialogOpen(true)} className="flex items-center gap-2" disabled={readonly} variant="outline"> + <Users className="h-4 w-4" /> + 협력사 멀티 선택 + </Button> + <Button onClick={() => setAddVendorDialogOpen(true)} className="flex items-center gap-2" disabled={readonly}> + <Plus className="h-4 w-4" /> + 업체 추가 + </Button> + </div> )} </CardHeader> <CardContent> @@ -740,6 +769,227 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC </DialogContent> </Dialog> + {/* 협력사 멀티 선택 다이얼로그 */} + <Dialog open={multiSelectDialogOpen} onOpenChange={setMultiSelectDialogOpen}> + <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>참여협력사 선택</DialogTitle> + <DialogDescription> + 입찰담당자를 선택하여 해당 담당자의 입찰 업체를 조회하고 선택할 수 있습니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4 py-4"> + {/* 입찰담당자 선택 */} + <div className="space-y-2"> + <Label>입찰담당자 선택</Label> + <PurchaseGroupCodeSelector + selectedCode={selectedBidPic} + onCodeSelect={async (code) => { + setSelectedBidPic(code) + if (code.user?.id) { + setIsLoadingBiddingCompanies(true) + try { + const result = await getBiddingCompaniesByBidPicId(code.user.id) + if (result.success && result.data) { + setBiddingCompaniesList(result.data) + } else { + toast.error(result.error || '입찰 업체 조회에 실패했습니다.') + setBiddingCompaniesList([]) + } + } catch (error) { + console.error('Failed to load bidding companies:', error) + toast.error('입찰 업체 조회에 실패했습니다.') + setBiddingCompaniesList([]) + } finally { + setIsLoadingBiddingCompanies(false) + } + } + }} + placeholder="입찰담당자 선택" + disabled={readonly} + /> + </div> + + {/* 입찰 업체 목록 */} + {isLoadingBiddingCompanies ? ( + <div className="flex items-center justify-center py-8"> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + <span className="text-sm text-muted-foreground">입찰 업체를 불러오는 중...</span> + </div> + ) : biddingCompaniesList.length === 0 && selectedBidPic ? ( + <div className="text-center py-8 text-muted-foreground"> + 해당 입찰담당자의 입찰 업체가 없습니다. + </div> + ) : biddingCompaniesList.length > 0 ? ( + <div className="space-y-2"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[50px]">선택</TableHead> + <TableHead>입찰번호</TableHead> + <TableHead>입찰명</TableHead> + <TableHead>협력사코드</TableHead> + <TableHead>협력사명</TableHead> + <TableHead>입찰 업데이트일</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {biddingCompaniesList.map((company) => { + const isSelected = selectedBiddingCompany?.biddingId === company.biddingId && + selectedBiddingCompany?.companyId === company.companyId + return ( + <TableRow + key={`${company.biddingId}-${company.companyId}`} + className={`cursor-pointer hover:bg-muted/50 ${ + isSelected ? 'bg-muted/50' : '' + }`} + onClick={async () => { + if (isSelected) { + setSelectedBiddingCompany(null) + setSelectedBiddingCompanyContacts([]) + return + } + setSelectedBiddingCompany({ + biddingId: company.biddingId, + companyId: company.companyId + }) + setIsLoadingCompanyContacts(true) + try { + const contactsResult = await getBiddingCompanyContacts(company.biddingId, company.companyId) + if (contactsResult.success && contactsResult.data) { + setSelectedBiddingCompanyContacts(contactsResult.data) + } else { + setSelectedBiddingCompanyContacts([]) + } + } catch (error) { + console.error('Failed to load company contacts:', error) + setSelectedBiddingCompanyContacts([]) + } finally { + setIsLoadingCompanyContacts(false) + } + }} + > + <TableCell onClick={(e) => e.stopPropagation()}> + <Checkbox + checked={isSelected} + onCheckedChange={() => { + // 클릭 이벤트는 TableRow의 onClick에서 처리 + }} + disabled={readonly} + /> + </TableCell> + <TableCell className="font-medium">{company.biddingNumber}</TableCell> + <TableCell>{company.biddingTitle}</TableCell> + <TableCell>{company.vendorCode}</TableCell> + <TableCell>{company.vendorName}</TableCell> + <TableCell> + {company.updatedAt ? new Date(company.updatedAt).toLocaleDateString('ko-KR') : '-'} + </TableCell> + </TableRow> + ) + })} + </TableBody> + </Table> + + {/* 선택한 입찰 업체의 담당자 정보 */} + {selectedBiddingCompany !== null && ( + <div className="mt-4 p-4 border rounded-lg"> + <h4 className="font-medium mb-2">담당자 정보</h4> + {isLoadingCompanyContacts ? ( + <div className="flex items-center justify-center py-4"> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + <span className="text-sm text-muted-foreground">담당자 정보를 불러오는 중...</span> + </div> + ) : selectedBiddingCompanyContacts.length === 0 ? ( + <div className="text-sm text-muted-foreground">등록된 담당자가 없습니다.</div> + ) : ( + <div className="space-y-2"> + {selectedBiddingCompanyContacts.map((contact) => ( + <div key={contact.id} className="text-sm"> + <span className="font-medium">{contact.contactName}</span> + <span className="text-muted-foreground ml-2">{contact.contactEmail}</span> + {contact.contactNumber && ( + <span className="text-muted-foreground ml-2">{contact.contactNumber}</span> + )} + </div> + ))} + </div> + )} + </div> + )} + </div> + ) : null} + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => { + setMultiSelectDialogOpen(false) + setSelectedBidPic(undefined) + setBiddingCompaniesList([]) + setSelectedBiddingCompany(null) + setSelectedBiddingCompanyContacts([]) + }} + > + 취소 + </Button> + <Button + onClick={async () => { + if (!selectedBiddingCompany) { + toast.error('입찰 업체를 선택해주세요.') + return + } + + const selectedCompany = biddingCompaniesList.find( + c => c.biddingId === selectedBiddingCompany.biddingId && + c.companyId === selectedBiddingCompany.companyId + ) + + if (!selectedCompany) { + toast.error('선택한 입찰 업체 정보를 찾을 수 없습니다.') + return + } + + try { + const contacts = selectedBiddingCompanyContacts.map(c => ({ + contactName: c.contactName, + contactEmail: c.contactEmail, + contactNumber: c.contactNumber || undefined, + })) + + const result = await addBiddingCompanyFromOtherBidding( + biddingId, + selectedCompany.biddingId, + selectedCompany.companyId, + contacts.length > 0 ? contacts : undefined + ) + + if (result.success) { + toast.success('업체가 성공적으로 추가되었습니다.') + setMultiSelectDialogOpen(false) + setSelectedBidPic(undefined) + setBiddingCompaniesList([]) + setSelectedBiddingCompany(null) + setSelectedBiddingCompanyContacts([]) + await reloadVendors() + } else { + toast.error(result.error || '업체 추가에 실패했습니다.') + } + } catch (error) { + console.error('Failed to add bidding company:', error) + toast.error('업체 추가에 실패했습니다.') + } + }} + disabled={!selectedBiddingCompany || readonly} + > + 추가 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + {/* 벤더 담당자에서 추가 다이얼로그 */} <Dialog open={addContactFromVendorDialogOpen} onOpenChange={setAddContactFromVendorDialogOpen}> <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> diff --git a/components/bidding/manage/bidding-items-editor.tsx b/components/bidding/manage/bidding-items-editor.tsx index 90e512d2..452cdc3c 100644 --- a/components/bidding/manage/bidding-items-editor.tsx +++ b/components/bidding/manage/bidding-items-editor.tsx @@ -1,7 +1,7 @@ 'use client' import * as React from 'react' -import { Package, Plus, Trash2, Save, RefreshCw, FileText } from 'lucide-react' +import { Package, Plus, Trash2, Save, RefreshCw, FileText, FileSpreadsheet, Upload } from 'lucide-react' import { getPRItemsForBidding } from '@/lib/bidding/detail/service' import { updatePrItem } from '@/lib/bidding/detail/service' import { toast } from 'sonner' @@ -26,7 +26,7 @@ import { CostCenterSingleSelector } from '@/components/common/selectors/cost-cen import { GlAccountSingleSelector } from '@/components/common/selectors/gl-account/gl-account-single-selector' // PR 아이템 정보 타입 (create-bidding-dialog와 동일) -interface PRItemInfo { +export interface PRItemInfo { id: number // 실제 DB ID prNumber?: string | null projectId?: number | null @@ -84,6 +84,16 @@ import { CreatePreQuoteRfqDialog } from './create-pre-quote-rfq-dialog' import { ProcurementItemSelectorDialogSingle } from '@/components/common/selectors/procurement-item/procurement-item-selector-dialog-single' import { Textarea } from '@/components/ui/textarea' import { Label } from '@/components/ui/label' +import { exportBiddingItemsToExcel } from '@/lib/bidding/manage/export-bidding-items-to-excel' +import { importBiddingItemsFromExcel } from '@/lib/bidding/manage/import-bidding-items-from-excel' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItemsEditorProps) { const { data: session } = useSession() @@ -114,6 +124,11 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems isPriceAdjustmentApplicable?: boolean | null sparePartOptions?: string | null } | null>(null) + const [importDialogOpen, setImportDialogOpen] = React.useState(false) + const [importFile, setImportFile] = React.useState<File | null>(null) + const [importErrors, setImportErrors] = React.useState<string[]>([]) + const [isImporting, setIsImporting] = React.useState(false) + const [isExporting, setIsExporting] = React.useState(false) // 초기 데이터 로딩 - 기존 품목이 있으면 자동으로 로드 React.useEffect(() => { @@ -492,7 +507,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems materialGroupInfo: null, materialNumber: null, materialInfo: null, - priceUnit: 1, + priceUnit: '1', purchaseUnit: 'EA', materialWeight: null, wbsCode: null, @@ -644,6 +659,76 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems const totals = calculateTotals() + // Excel 내보내기 핸들러 + const handleExport = React.useCallback(async () => { + if (items.length === 0) { + toast.error('내보낼 품목이 없습니다.') + return + } + + try { + setIsExporting(true) + await exportBiddingItemsToExcel(items, { + filename: `입찰품목목록_${biddingId}`, + }) + toast.success('Excel 파일이 다운로드되었습니다.') + } catch (error) { + console.error('Excel export error:', error) + toast.error('Excel 내보내기 중 오류가 발생했습니다.') + } finally { + setIsExporting(false) + } + }, [items, biddingId]) + + // Excel 가져오기 핸들러 + const handleImportFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0] + if (file) { + if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.xls')) { + toast.error('Excel 파일(.xlsx, .xls)만 업로드 가능합니다.') + return + } + setImportFile(file) + setImportErrors([]) + } + } + + const handleImport = async () => { + if (!importFile) return + + setIsImporting(true) + setImportErrors([]) + + try { + const result = await importBiddingItemsFromExcel(importFile) + + if (result.errors.length > 0) { + setImportErrors(result.errors) + toast.warning( + `${result.items.length}개의 품목을 파싱했지만 ${result.errors.length}개의 오류가 있습니다.` + ) + return + } + + if (result.items.length === 0) { + toast.error('가져올 품목이 없습니다.') + return + } + + // 기존 아이템에 추가 + setItems((prev) => [...prev, ...result.items]) + setImportDialogOpen(false) + setImportFile(null) + setImportErrors([]) + toast.success(`${result.items.length}개의 품목이 추가되었습니다.`) + } catch (error) { + console.error('Excel import error:', error) + toast.error('Excel 가져오기 중 오류가 발생했습니다.') + } finally { + setIsImporting(false) + } + } + if (isLoading) { return ( <div className="flex items-center justify-center p-8"> @@ -1372,6 +1457,14 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems <FileText className="h-4 w-4" /> 사전견적 </Button> + <Button onClick={handleExport} variant="outline" className="flex items-center gap-2" disabled={readonly || isExporting || items.length === 0}> + <FileSpreadsheet className="h-4 w-4" /> + {isExporting ? "내보내는 중..." : "Excel 내보내기"} + </Button> + <Button onClick={() => setImportDialogOpen(true)} variant="outline" className="flex items-center gap-2" disabled={readonly}> + <Upload className="h-4 w-4" /> + Excel 가져오기 + </Button> <Button onClick={handleAddItem} className="flex items-center gap-2" disabled={readonly}> <Plus className="h-4 w-4" /> 품목 추가 @@ -1492,6 +1585,88 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems toast.success('사전견적용 일반견적이 생성되었습니다') }} /> + + {/* Excel 가져오기 다이얼로그 */} + <Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}> + <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>Excel 가져오기</DialogTitle> + <DialogDescription> + Excel 파일을 업로드하여 품목을 일괄 추가합니다. + </DialogDescription> + </DialogHeader> + <div className="space-y-4"> + <div> + <Label htmlFor="import-file">Excel 파일 선택</Label> + <Input + id="import-file" + type="file" + accept=".xlsx,.xls" + onChange={handleImportFileSelect} + className="mt-2" + disabled={isImporting} + /> + {importFile && ( + <p className="text-sm text-muted-foreground mt-2"> + 선택된 파일: {importFile.name} + </p> + )} + </div> + + {importErrors.length > 0 && ( + <div className="space-y-2"> + <Label className="text-destructive">오류 목록</Label> + <div className="max-h-60 overflow-y-auto border rounded-md p-3 bg-destructive/5"> + <ul className="list-disc list-inside space-y-1"> + {importErrors.map((error, index) => ( + <li key={index} className="text-sm text-destructive"> + {error} + </li> + ))} + </ul> + </div> + </div> + )} + + <div className="text-sm text-muted-foreground space-y-1"> + <p className="font-semibold">필수 컬럼:</p> + <ul className="list-disc list-inside ml-2"> + <li>자재그룹코드, 자재그룹명</li> + <li>수량 또는 중량 (둘 중 하나 필수)</li> + <li>수량단위 또는 중량단위</li> + <li>납품요청일 (YYYY-MM-DD 형식)</li> + <li>내정단가</li> + </ul> + </div> + </div> + <DialogFooter> + <Button + variant="outline" + onClick={() => { + setImportDialogOpen(false) + setImportFile(null) + setImportErrors([]) + }} + disabled={isImporting} + > + 취소 + </Button> + <Button + onClick={handleImport} + disabled={!importFile || isImporting} + > + {isImporting ? ( + <> + <RefreshCw className="w-4 h-4 mr-2 animate-spin" /> + 가져오는 중... + </> + ) : ( + "가져오기" + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> </div> ) diff --git a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx index de3c19ff..1ab7a40f 100644 --- a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx +++ b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx @@ -465,7 +465,7 @@ export function CreatePreQuoteRfqDialog({ )}
>
{field.value ? (
- format(field.value, "yyyy-MM-dd")
+ format(field.value, "yyyy-MM-dd HH:mm")
) : (
<span>제출마감일을 선택하세요 (선택)</span>
)}
@@ -477,12 +477,40 @@ export function CreatePreQuoteRfqDialog({ <Calendar
mode="single"
selected={field.value}
- onSelect={field.onChange}
- disabled={(date) =>
- date < new Date() || date < new Date("1900-01-01")
- }
+ onSelect={(date) => {
+ if (!date) {
+ field.onChange(undefined)
+ return
+ }
+ const newDate = new Date(date)
+ if (field.value) {
+ newDate.setHours(field.value.getHours(), field.value.getMinutes())
+ } else {
+ newDate.setHours(0, 0, 0, 0)
+ }
+ field.onChange(newDate)
+ }}
+ disabled={(date) => {
+ const today = new Date()
+ today.setHours(0, 0, 0, 0)
+ return date < today || date < new Date("1900-01-01")
+ }}
initialFocus
/>
+ <div className="p-3 border-t border-border">
+ <Input
+ type="time"
+ value={field.value ? format(field.value, "HH:mm") : ""}
+ onChange={(e) => {
+ if (field.value) {
+ const [hours, minutes] = e.target.value.split(':').map(Number)
+ const newDate = new Date(field.value)
+ newDate.setHours(hours, minutes)
+ field.onChange(newDate)
+ }
+ }}
+ />
+ </div>
</PopoverContent>
</Popover>
<FormMessage />
diff --git a/components/common/legal/cpvw-wab-qust-list-view-dialog.tsx b/components/common/legal/cpvw-wab-qust-list-view-dialog.tsx new file mode 100644 index 00000000..aeefbb84 --- /dev/null +++ b/components/common/legal/cpvw-wab-qust-list-view-dialog.tsx @@ -0,0 +1,364 @@ +"use client" + +import * as React from "react" +import { Loader, Database, Check } from "lucide-react" +import { toast } from "sonner" +import { + useReactTable, + getCoreRowModel, + getPaginationRowModel, + getFilteredRowModel, + ColumnDef, + flexRender, +} from "@tanstack/react-table" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Checkbox } from "@/components/ui/checkbox" +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area" + +import { getCPVWWabQustListViewData, CPVWWabQustListView } from "@/lib/basic-contract/cpvw-service" + +interface CPVWWabQustListViewDialogProps { + onConfirm?: (selectedRows: CPVWWabQustListView[]) => void + requireSingleSelection?: boolean + triggerDisabled?: boolean + triggerTitle?: string +} + +export function CPVWWabQustListViewDialog({ + onConfirm, + requireSingleSelection = false, + triggerDisabled = false, + triggerTitle, +}: CPVWWabQustListViewDialogProps) { + const [open, setOpen] = React.useState(false) + const [isLoading, setIsLoading] = React.useState(false) + const [data, setData] = React.useState<CPVWWabQustListView[]>([]) + const [error, setError] = React.useState<string | null>(null) + const [rowSelection, setRowSelection] = React.useState<Record<string, boolean>>({}) + + const loadData = async () => { + setIsLoading(true) + setError(null) + try { + const result = await getCPVWWabQustListViewData() + if (result.success) { + setData(result.data) + if (result.isUsingFallback) { + toast.info("테스트 데이터를 표시합니다.") + } + } else { + setError(result.error || "데이터 로딩 실패") + toast.error(result.error || "데이터 로딩 실패") + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류" + setError(errorMessage) + toast.error(errorMessage) + } finally { + setIsLoading(false) + } + } + + React.useEffect(() => { + if (open) { + loadData() + } else { + // 다이얼로그 닫힐 때 데이터 초기화 + setData([]) + setError(null) + setRowSelection({}) + } + }, [open]) + + // 테이블 컬럼 정의 (동적 생성) + const columns = React.useMemo<ColumnDef<CPVWWabQustListView>[]>(() => { + if (data.length === 0) return [] + + const dataKeys = Object.keys(data[0]) + + return [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="모든 행 선택" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="행 선택" + /> + ), + enableSorting: false, + enableHiding: false, + }, + ...dataKeys.map((key) => ({ + accessorKey: key, + header: key, + cell: ({ getValue }: any) => { + const value = getValue() + return value !== null && value !== undefined ? String(value) : "" + }, + })), + ] + }, [data]) + + // 테이블 인스턴스 생성 + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onRowSelectionChange: setRowSelection, + state: { + rowSelection, + }, + }) + + // 선택된 행들 가져오기 + const selectedRows = table.getFilteredSelectedRowModel().rows.map(row => row.original) + + // 확인 버튼 핸들러 + const handleConfirm = () => { + if (selectedRows.length === 0) { + toast.error("행을 선택해주세요.") + return + } + + if (requireSingleSelection && selectedRows.length !== 1) { + toast.error("하나의 행만 선택해주세요.") + return + } + + if (onConfirm) { + onConfirm(selectedRows) + toast.success( + requireSingleSelection + ? "선택한 행으로 준법문의 상태를 동기화합니다." + : `${selectedRows.length}개의 행을 선택했습니다.` + ) + } else { + // 임시로 선택된 데이터 콘솔 출력 + console.log("선택된 행들:", selectedRows) + toast.success(`${selectedRows.length}개의 행이 선택되었습니다. (콘솔 확인)`) + } + + setOpen(false) + } + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button + variant="outline" + size="sm" + disabled={triggerDisabled} + title={triggerTitle} + > + <Database className="mr-2 size-4" aria-hidden="true" /> + 준법문의 요청 데이터 조회 + </Button> + </DialogTrigger> + <DialogContent className="max-w-7xl h-[90vh] flex flex-col overflow-hidden"> + <DialogHeader> + <DialogTitle>준법문의 요청 데이터</DialogTitle> + <DialogDescription> + 준법문의 요청 데이터를 조회합니다. + {data.length > 0 && ` (${data.length}건, ${selectedRows.length}개 선택됨)`} + </DialogDescription> + </DialogHeader> + + <div className="flex flex-col flex-1 min-h-0"> + {isLoading ? ( + <div className="flex items-center justify-center flex-1 min-h-[200px]"> + <Loader className="mr-2 size-6 animate-spin" /> + <span>데이터 로딩 중...</span> + </div> + ) : error ? ( + <div className="flex items-center justify-center flex-1 min-h-[200px] text-red-500"> + <span>오류: {error}</span> + </div> + ) : data.length === 0 ? ( + <div className="flex items-center justify-center flex-1 min-h-[200px] text-muted-foreground"> + <span>데이터가 없습니다.</span> + </div> + ) : ( + <div className="flex flex-col flex-1 min-h-0"> + {/* 테이블 영역 - 스크롤 가능 */} + <ScrollArea className="flex-1 overflow-auto border rounded-md"> + <Table className="min-w-full"> + <TableHeader className="sticky top-0 bg-background z-10"> + {table.getHeaderGroups().map((headerGroup) => ( + <TableRow key={headerGroup.id}> + {headerGroup.headers.map((header) => ( + <TableHead key={header.id} className="font-medium bg-background"> + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + </TableHead> + ))} + </TableRow> + ))} + </TableHeader> + <TableBody> + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + <TableRow + key={row.id} + data-state={row.getIsSelected() && "selected"} + > + {row.getVisibleCells().map((cell) => ( + <TableCell key={cell.id} className="text-sm"> + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + </TableCell> + ))} + </TableRow> + )) + ) : ( + <TableRow> + <TableCell + colSpan={columns.length} + className="h-24 text-center" + > + 데이터가 없습니다. + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + <ScrollBar orientation="horizontal" /> + </ScrollArea> + + {/* 페이지네이션 컨트롤 - 고정 영역 */} + <div className="flex items-center justify-between px-2 py-4 border-t bg-background flex-shrink-0"> + <div className="flex-1 text-sm text-muted-foreground"> + {table.getFilteredSelectedRowModel().rows.length}개 행 선택됨 + </div> + <div className="flex items-center space-x-6 lg:space-x-8"> + <div className="flex items-center space-x-2"> + <p className="text-sm font-medium">페이지당 행 수</p> + <select + value={table.getState().pagination.pageSize} + onChange={(e) => { + table.setPageSize(Number(e.target.value)) + }} + className="h-8 w-[70px] rounded border border-input bg-transparent px-3 py-1 text-sm ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2" + > + {[10, 20, 30, 40, 50].map((pageSize) => ( + <option key={pageSize} value={pageSize}> + {pageSize} + </option> + ))} + </select> + </div> + <div className="flex w-[100px] items-center justify-center text-sm font-medium"> + {table.getState().pagination.pageIndex + 1} /{" "} + {table.getPageCount()} + </div> + <div className="flex items-center space-x-2"> + <Button + variant="outline" + className="h-8 w-8 p-0" + onClick={() => table.setPageIndex(0)} + disabled={!table.getCanPreviousPage()} + > + <span className="sr-only">첫 페이지로</span> + {"<<"} + </Button> + <Button + variant="outline" + className="h-8 w-8 p-0" + onClick={() => table.previousPage()} + disabled={!table.getCanPreviousPage()} + > + <span className="sr-only">이전 페이지</span> + {"<"} + </Button> + <Button + variant="outline" + className="h-8 w-8 p-0" + onClick={() => table.nextPage()} + disabled={!table.getCanNextPage()} + > + <span className="sr-only">다음 페이지</span> + {">"} + </Button> + <Button + variant="outline" + className="h-8 w-8 p-0" + onClick={() => table.setPageIndex(table.getPageCount() - 1)} + disabled={!table.getCanNextPage()} + > + <span className="sr-only">마지막 페이지로</span> + {">>"} + </Button> + </div> + </div> + </div> + </div> + )} + </div> + + <DialogFooter className="gap-2 flex-shrink-0"> + <Button variant="outline" onClick={() => setOpen(false)}> + 닫기 + </Button> + <Button onClick={loadData} disabled={isLoading} variant="outline"> + {isLoading ? ( + <> + <Loader className="mr-2 size-4 animate-spin" /> + 로딩 중... + </> + ) : ( + "새로고침" + )} + </Button> + <Button + onClick={handleConfirm} + disabled={ + requireSingleSelection + ? selectedRows.length !== 1 + : selectedRows.length === 0 + } + > + <Check className="mr-2 size-4" /> + 확인 ({selectedRows.length}) + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} + diff --git a/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx index 84fd85ff..a1b98468 100644 --- a/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx +++ b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx @@ -23,6 +23,7 @@ export interface ProcurementItemSelectorDialogSingleProps { title?: string; description?: string; showConfirmButtons?: boolean; + disabled?: boolean; } /** @@ -78,6 +79,7 @@ export function ProcurementItemSelectorDialogSingle({ title = "1회성 품목 선택", description = "1회성 품목을 검색하고 선택해주세요.", showConfirmButtons = false, + disabled = false, }: ProcurementItemSelectorDialogSingleProps) { const [open, setOpen] = useState(false); const [tempSelectedProcurementItem, setTempSelectedProcurementItem] = @@ -128,7 +130,7 @@ export function ProcurementItemSelectorDialogSingle({ return ( <Dialog open={open} onOpenChange={handleOpenChange}> <DialogTrigger asChild> - <Button variant={triggerVariant} size={triggerSize}> + <Button variant={triggerVariant} size={triggerSize} disabled={disabled}> {selectedProcurementItem ? ( <span className="truncate"> {`${selectedProcurementItem.itemCode}`} diff --git a/db/schema/basicContractDocumnet.ts b/db/schema/basicContractDocumnet.ts index 944c4b2c..e571c7e0 100644 --- a/db/schema/basicContractDocumnet.ts +++ b/db/schema/basicContractDocumnet.ts @@ -67,6 +67,12 @@ export const basicContract = pgTable('basic_contract', { legalReviewRegNo: varchar('legal_review_reg_no', { length: 100 }), // 법무 시스템 REG_NO legalReviewProgressStatus: varchar('legal_review_progress_status', { length: 255 }), // PRGS_STAT_DSC 값 + // 준법문의 관련 필드 + complianceReviewRequestedAt: timestamp('compliance_review_requested_at'), // 준법문의 요청일 + complianceReviewCompletedAt: timestamp('compliance_review_completed_at'), // 준법문의 완료일 + complianceReviewRegNo: varchar('compliance_review_reg_no', { length: 100 }), // 준법문의 시스템 REG_NO + complianceReviewProgressStatus: varchar('compliance_review_progress_status', { length: 255 }), // 준법문의 PRGS_STAT_DSC 값 + createdAt: timestamp('created_at').defaultNow(), updatedAt: timestamp('updated_at').defaultNow(), completedAt: timestamp('completed_at'), // 계약 체결 완료 날짜 @@ -99,6 +105,12 @@ export const basicContractView = pgView('basic_contract_view').as((qb) => { legalReviewRegNo: sql<string | null>`${basicContract.legalReviewRegNo}`.as('legal_review_reg_no'), legalReviewProgressStatus: sql<string | null>`${basicContract.legalReviewProgressStatus}`.as('legal_review_progress_status'), + // 준법문의 관련 필드 + complianceReviewRequestedAt: sql<Date | null>`${basicContract.complianceReviewRequestedAt}`.as('compliance_review_requested_at'), + complianceReviewCompletedAt: sql<Date | null>`${basicContract.complianceReviewCompletedAt}`.as('compliance_review_completed_at'), + complianceReviewRegNo: sql<string | null>`${basicContract.complianceReviewRegNo}`.as('compliance_review_reg_no'), + complianceReviewProgressStatus: sql<string | null>`${basicContract.complianceReviewProgressStatus}`.as('compliance_review_progress_status'), + createdAt: sql<Date>`${basicContract.createdAt}`.as('created_at'), updatedAt: sql<Date>`${basicContract.updatedAt}`.as('updated_at'), completedAt: sql<Date | null>`${basicContract.completedAt}`.as('completed_at'), @@ -121,6 +133,9 @@ export const basicContractView = pgView('basic_contract_view').as((qb) => { // 법무검토 상태 (PRGS_STAT_DSC 동기화 값) legalReviewStatus: sql<string | null>`${basicContract.legalReviewProgressStatus}`.as('legal_review_status'), + + // 준법문의 상태 (PRGS_STAT_DSC 동기화 값) + complianceReviewStatus: sql<string | null>`${basicContract.complianceReviewProgressStatus}`.as('compliance_review_status'), // 템플릿 파일 정보 templateFilePath: sql<string | null>`${basicContractTemplates.filePath}`.as('template_file_path'), diff --git a/db/schema/bidding.ts b/db/schema/bidding.ts index c08ea921..fa3f1df5 100644 --- a/db/schema/bidding.ts +++ b/db/schema/bidding.ts @@ -297,7 +297,7 @@ export const prItemsForBidding = pgTable('pr_items_for_bidding', { // 수량 및 중량 quantity: decimal('quantity', { precision: 10, scale: 3 }), // 수량 - quantityUnit: varchar('quantity_unit', { length: 50 }), // 수량단위 (구매단위) + quantityUnit: varchar('quantity_unit', { length: 50 }), // 수량단위 totalWeight: decimal('total_weight', { precision: 10, scale: 3 }), // 총 중량 weightUnit: varchar('weight_unit', { length: 50 }), // 중량단위 (자재순중량) diff --git a/lib/basic-contract/cpvw-service.ts b/lib/basic-contract/cpvw-service.ts new file mode 100644 index 00000000..6d249002 --- /dev/null +++ b/lib/basic-contract/cpvw-service.ts @@ -0,0 +1,236 @@ +"use server" + +import { oracleKnex } from '@/lib/oracle-db/db' + +// CPVW_WAB_QUST_LIST_VIEW 테이블 데이터 타입 (실제 테이블 구조에 맞게 조정 필요) +export interface CPVWWabQustListView { + [key: string]: string | number | Date | null | undefined +} + +// 테스트 환경용 폴백 데이터 (실제 CPVW_WAB_QUST_LIST_VIEW 테이블 구조에 맞춤) +const FALLBACK_TEST_DATA: CPVWWabQustListView[] = [ + { + REG_NO: '1030', + INQ_TP: 'OC', + INQ_TP_DSC: '해외계약', + TIT: 'Contrack of Sale', + REQ_DGR: '2', + REQR_NM: '김원식', + REQ_DT: '20130829', + REVIEW_TERM_DT: '20130902', + RVWR_NM: '김미정', + CNFMR_NM: '안한진', + APPR_NM: '염정훈', + PRGS_STAT: 'E', + PRGS_STAT_DSC: '검토중', + REGR_DPTCD: 'D602058000', + REGR_DEPTNM: '구매1팀(사외계약)' + }, + { + REG_NO: '1076', + INQ_TP: 'IC', + INQ_TP_DSC: '국내계약', + TIT: 'CAISSON PIPE 복관 계약서 검토 요청件', + REQ_DGR: '1', + REQR_NM: '서권환', + REQ_DT: '20130821', + REVIEW_TERM_DT: '20130826', + RVWR_NM: '이택준', + CNFMR_NM: '이택준', + APPR_NM: '전상용', + PRGS_STAT: 'E', + PRGS_STAT_DSC: '완료', + REGR_DPTCD: 'D602058000', + REGR_DEPTNM: '구매1팀(사외계약)' + }, + { + REG_NO: '1100', + INQ_TP: 'IC', + INQ_TP_DSC: '국내계약', + TIT: '(7102) HVAC 작업계약', + REQ_DGR: '1', + REQR_NM: '신동동', + REQ_DT: '20130826', + REVIEW_TERM_DT: '20130829', + RVWR_NM: '이두리', + CNFMR_NM: '이두리', + APPR_NM: '전상용', + PRGS_STAT: 'E', + PRGS_STAT_DSC: '완료', + REGR_DPTCD: 'D602058000', + REGR_DEPTNM: '구매1팀(사외계약)' + }, + { + REG_NO: '1105', + INQ_TP: 'IC', + INQ_TP_DSC: '국내계약', + TIT: 'Plate 가공계약서 검토 요청건', + REQ_DGR: '1', + REQR_NM: '서권환', + REQ_DT: '20130826', + REVIEW_TERM_DT: '20130829', + RVWR_NM: '백영국', + CNFMR_NM: '백영국', + APPR_NM: '전상용', + PRGS_STAT: 'E', + PRGS_STAT_DSC: '완료', + REGR_DPTCD: 'D602058000', + REGR_DEPTNM: '구매1팀(사외계약)' + }, + { + REG_NO: '1106', + INQ_TP: 'IC', + INQ_TP_DSC: '국내계약', + TIT: 'SHELL FLNG, V-BRACE 제작 계약서 검토件', + REQ_DGR: '1', + REQR_NM: '성기승', + REQ_DT: '20130826', + REVIEW_TERM_DT: '20130830', + RVWR_NM: '이두리', + CNFMR_NM: '이두리', + APPR_NM: '전상용', + PRGS_STAT: 'E', + PRGS_STAT_DSC: '완료', + REGR_DPTCD: 'D602058000', + REGR_DEPTNM: '구매1팀(사외계약)' + } +] + +const normalizeOracleRows = (rows: Array<Record<string, unknown>>): CPVWWabQustListView[] => { + return rows.map((item) => { + const convertedItem: CPVWWabQustListView = {} + for (const [key, value] of Object.entries(item)) { + if (value instanceof Date) { + convertedItem[key] = value + } else if (value === null) { + convertedItem[key] = null + } else { + convertedItem[key] = String(value) + } + } + return convertedItem + }) +} + +/** + * CPVW_WAB_QUST_LIST_VIEW 테이블 전체 조회 + * @returns 테이블 데이터 배열 + */ +export async function getCPVWWabQustListViewData(): Promise<{ + success: boolean + data: CPVWWabQustListView[] + error?: string + isUsingFallback?: boolean +}> { + try { + console.log('📋 [getCPVWWabQustListViewData] CPVW_WAB_QUST_LIST_VIEW 테이블 조회 시작...') + + const result = await oracleKnex.raw(` + SELECT * + FROM CPVW_WAB_QUST_LIST_VIEW + WHERE ROWNUM < 100 + ORDER BY 1 + `) + + // Oracle raw query의 결과는 rows 배열에 들어있음 + const rows = (result.rows || result) as Array<Record<string, unknown>> + + console.log(`✅ [getCPVWWabQustListViewData] 조회 성공 - ${rows.length}건`) + + // 데이터 타입 변환 (필요에 따라 조정) + const cleanedResult = normalizeOracleRows(rows) + + return { + success: true, + data: cleanedResult, + isUsingFallback: false + } + } catch (error) { + console.error('❌ [getCPVWWabQustListViewData] 오류:', error) + console.log('🔄 [getCPVWWabQustListViewData] 폴백 테스트 데이터 사용') + return { + success: true, + data: FALLBACK_TEST_DATA, + isUsingFallback: true + } + } +} + +export async function getCPVWWabQustListViewByRegNo(regNo: string): Promise<{ + success: boolean + data?: CPVWWabQustListView + error?: string + isUsingFallback?: boolean +}> { + if (!regNo) { + return { + success: false, + error: 'REG_NO는 필수입니다.' + } + } + + try { + console.log(`[getCPVWWabQustListViewByRegNo] REG_NO=${regNo} 조회`) + const result = await oracleKnex.raw( + ` + SELECT * + FROM CPVW_WAB_QUST_LIST_VIEW + WHERE REG_NO = :regNo + `, + { regNo } + ) + + const rows = (result.rows || result) as Array<Record<string, unknown>> + const cleanedResult = normalizeOracleRows(rows) + + if (cleanedResult.length === 0) { + // 데이터가 없을 때 폴백 테스트 데이터에서 찾기 + console.log(`[getCPVWWabQustListViewByRegNo] 데이터 없음, 폴백 테스트 데이터에서 검색: REG_NO=${regNo}`) + const fallbackData = FALLBACK_TEST_DATA.find(item => + String(item.REG_NO) === String(regNo) + ) + + if (fallbackData) { + console.log(`[getCPVWWabQustListViewByRegNo] 폴백 테스트 데이터에서 찾음: REG_NO=${regNo}`) + return { + success: true, + data: fallbackData, + isUsingFallback: true + } + } + + return { + success: false, + error: '해당 REG_NO에 대한 데이터가 없습니다.' + } + } + + return { + success: true, + data: cleanedResult[0], + isUsingFallback: false + } + } catch (error) { + console.error('[getCPVWWabQustListViewByRegNo] 오류:', error) + console.log(`[getCPVWWabQustListViewByRegNo] 폴백 테스트 데이터에서 검색: REG_NO=${regNo}`) + + // 오류 발생 시 폴백 테스트 데이터에서 찾기 + const fallbackData = FALLBACK_TEST_DATA.find(item => + String(item.REG_NO) === String(regNo) + ) + + if (fallbackData) { + console.log(`[getCPVWWabQustListViewByRegNo] 폴백 테스트 데이터에서 찾음: REG_NO=${regNo}`) + return { + success: true, + data: fallbackData, + isUsingFallback: true + } + } + + return { + success: false, + error: error instanceof Error ? error.message : 'REG_NO 조회 중 오류가 발생했습니다.' + } + } +} diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts index 6f4e5d53..12278c54 100644 --- a/lib/basic-contract/service.ts +++ b/lib/basic-contract/service.ts @@ -2862,6 +2862,10 @@ export async function requestLegalReviewAction( } } +// ⚠️ SSLVW(법무관리시스템) PRGS_STAT_DSC 문자열을 그대로 저장하는 함수입니다. +// - 상태 텍스트 및 완료 여부는 외부 시스템에 의존하므로 신뢰도가 100%는 아니고, +// - 여기에서 관리하는 값들은 UI 표시/참고용으로만 사용해야 합니다. +// - 최종 승인 차단 등 핵심 비즈니스 로직에서는 SSLVW 쪽 완료 시간을 직접 신뢰하지 않습니다. const persistLegalReviewStatus = async ({ contractId, regNo, @@ -2904,6 +2908,121 @@ const persistLegalReviewStatus = async ({ } /** + * 준법문의 요청 서버 액션 + */ +export async function requestComplianceInquiryAction( + contractIds: number[] +): Promise<{ success: boolean; message: string }> { + const session = await getServerSession(authOptions) + + if (!session?.user) { + return { + success: false, + message: "로그인이 필요합니다." + } + } + + // 계약서 정보 조회 + const contracts = await db + .select({ + id: basicContractView.id, + complianceReviewRequestedAt: basicContractView.complianceReviewRequestedAt, + }) + .from(basicContractView) + .where(inArray(basicContractView.id, contractIds)) + + if (contracts.length === 0) { + return { + success: false, + message: "선택된 계약서를 찾을 수 없습니다." + } + } + + // 준법문의 요청 가능한 계약서 필터링 (이미 요청되지 않은 것만) + const eligibleContracts = contracts.filter(contract => + !contract.complianceReviewRequestedAt + ) + + if (eligibleContracts.length === 0) { + return { + success: false, + message: "준법문의 요청 가능한 계약서가 없습니다." + } + } + + const currentDate = new Date() + + // 트랜잭션으로 처리 + await db.transaction(async (tx) => { + for (const contract of eligibleContracts) { + await tx + .update(basicContract) + .set({ + complianceReviewRequestedAt: currentDate, + updatedAt: currentDate, + }) + .where(eq(basicContract.id, contract.id)) + } + }) + + revalidateTag("basic-contracts") + + return { + success: true, + message: `${eligibleContracts.length}건의 준법문의 요청이 완료되었습니다.` + } +} + +/** + * 준법문의 상태 저장 (준법문의 전용 필드 사용) + */ +const persistComplianceReviewStatus = async ({ + contractId, + regNo, + progressStatus, +}: { + contractId: number + regNo: string + progressStatus: string +}) => { + const now = new Date() + + // 완료 상태 확인 (법무검토와 동일한 패턴) + // ⚠️ CPVW PRGS_STAT_DSC 문자열을 기반으로 한 best-effort 휴리스틱입니다. + // - 외부 시스템의 상태 텍스트에 의존하므로 신뢰도가 100%는 아니고, + // - 여기에서 설정하는 완료 시간(complianceReviewCompletedAt)은 UI 표시용으로만 사용해야 합니다. + // - 버튼 활성화, 서버 액션 차단, 필터 조건 등 핵심 비즈니스 로직에서는 + // 이 값을 신뢰하지 않도록 합니다. + // 완료 상태 확인 (법무검토와 동일한 패턴) + const isCompleted = progressStatus && ( + progressStatus.includes('완료') || + progressStatus.includes('승인') || + progressStatus.includes('종료') + ) + + await db.transaction(async (tx) => { + // 준법문의 상태 업데이트 (준법문의 전용 필드 사용) + const updateData: any = { + complianceReviewRegNo: regNo, + complianceReviewProgressStatus: progressStatus, + updatedAt: now, + } + + // 완료 상태인 경우 완료일 설정 + if (isCompleted) { + updateData.complianceReviewCompletedAt = now + } + + await tx + .update(basicContract) + .set(updateData) + .where(eq(basicContract.id, contractId)) + }) + + revalidateTag("basic-contracts") +} + +/** * SSLVW 데이터로부터 법무검토 상태 업데이트 * @param sslvwData 선택된 SSLVW 데이터 배열 * @param selectedContractIds 선택된 계약서 ID 배열 @@ -3033,6 +3152,137 @@ export async function updateLegalReviewStatusFromSSLVW( } } +/** + * CPVW 데이터로부터 준법문의 상태 업데이트 + * @param cpvwData 선택된 CPVW 데이터 배열 + * @param selectedContractIds 선택된 계약서 ID 배열 + * @returns 성공 여부 및 메시지 + */ +export async function updateComplianceReviewStatusFromCPVW( + cpvwData: Array<{ REG_NO?: string; reg_no?: string; PRGS_STAT_DSC?: string; prgs_stat_dsc?: string; [key: string]: any }>, + selectedContractIds: number[] +): Promise<{ success: boolean; message: string; updatedCount: number; errors: string[] }> { + try { + console.log(`[updateComplianceReviewStatusFromCPVW] CPVW 데이터로부터 준법문의 상태 업데이트 시작`) + + if (!cpvwData || cpvwData.length === 0) { + return { + success: false, + message: 'CPVW 데이터가 없습니다.', + updatedCount: 0, + errors: [] + } + } + + if (!selectedContractIds || selectedContractIds.length === 0) { + return { + success: false, + message: '선택된 계약서가 없습니다.', + updatedCount: 0, + errors: [] + } + } + + if (selectedContractIds.length !== 1) { + return { + success: false, + message: '한 개의 계약서만 선택해 주세요.', + updatedCount: 0, + errors: [] + } + } + + if (cpvwData.length !== 1) { + return { + success: false, + message: '준법문의 시스템 데이터도 한 건만 선택해 주세요.', + updatedCount: 0, + errors: [] + } + } + + const contractId = selectedContractIds[0] + const cpvwItem = cpvwData[0] + const regNo = String( + cpvwItem.REG_NO ?? + cpvwItem.reg_no ?? + cpvwItem.RegNo ?? + '' + ).trim() + const progressStatus = String( + cpvwItem.PRGS_STAT_DSC ?? + cpvwItem.prgs_stat_dsc ?? + cpvwItem.PrgsStatDsc ?? + '' + ).trim() + + if (!regNo) { + return { + success: false, + message: 'REG_NO 값을 찾을 수 없습니다.', + updatedCount: 0, + errors: [] + } + } + + if (!progressStatus) { + return { + success: false, + message: 'PRGS_STAT_DSC 값을 찾을 수 없습니다.', + updatedCount: 0, + errors: [] + } + } + + const contract = await db + .select({ + id: basicContract.id, + complianceReviewRegNo: basicContract.complianceReviewRegNo, + }) + .from(basicContract) + .where(eq(basicContract.id, contractId)) + .limit(1) + + if (!contract[0]) { + return { + success: false, + message: `계약서(${contractId})를 찾을 수 없습니다.`, + updatedCount: 0, + errors: [] + } + } + + if (contract[0].complianceReviewRegNo && contract[0].complianceReviewRegNo !== regNo) { + console.warn(`[updateComplianceReviewStatusFromCPVW] REG_NO가 변경됩니다: ${contract[0].complianceReviewRegNo} -> ${regNo}`) + } + + // 준법문의 상태 업데이트 + await persistComplianceReviewStatus({ + contractId, + regNo, + progressStatus, + }) + + console.log(`[updateComplianceReviewStatusFromCPVW] 완료: 계약서 ${contractId}, REG_NO ${regNo}, 상태 ${progressStatus}`) + + return { + success: true, + message: '준법문의 상태가 업데이트되었습니다.', + updatedCount: 1, + errors: [] + } + + } catch (error) { + console.error('[updateComplianceReviewStatusFromCPVW] 오류:', error) + return { + success: false, + message: '준법문의 상태 업데이트 중 오류가 발생했습니다.', + updatedCount: 0, + errors: [error instanceof Error ? error.message : '알 수 없는 오류'] + } + } +} + export async function refreshLegalReviewStatusFromOracle(contractId: number): Promise<{ success: boolean message: string @@ -3274,12 +3524,9 @@ export async function processBuyerSignatureAction( } } - if (contractData.legalReviewRequestedAt && !contractData.legalReviewCompletedAt) { - return { - success: false, - message: "법무검토가 완료되지 않았습니다." - } - } + // ⚠️ 법무검토 완료 여부는 SSLVW 상태/시간에 의존하므로 + // 여기서는 legalReviewCompletedAt 기반으로 최종승인을 막지 않습니다. + // (법무 상태는 UI에서 참고 정보로만 사용) // 파일 저장 로직 (기존 파일 덮어쓰기) const saveResult = await saveBuffer({ @@ -3373,9 +3620,9 @@ export async function prepareFinalApprovalAction( if (contract.completedAt !== null || !contract.signedFilePath) { return false } - if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) { - return false - } + // ⚠️ 법무검토 완료 여부는 SSLVW 상태/시간에 의존하므로 + // 여기서는 legalReviewCompletedAt 기반으로 필터링하지 않습니다. + // (법무 상태는 UI에서 참고 정보로만 사용) return true }) @@ -3949,6 +4196,8 @@ export async function saveGtcDocumentAction({ buyerSignedAt: null, legalReviewRequestedAt: null, legalReviewCompletedAt: null, + complianceReviewRequestedAt: null, + complianceReviewCompletedAt: null, updatedAt: new Date() }) .where(eq(basicContract.id, documentId)) diff --git a/lib/basic-contract/sslvw-service.ts b/lib/basic-contract/sslvw-service.ts index 38ecb67d..08b43f82 100644 --- a/lib/basic-contract/sslvw-service.ts +++ b/lib/basic-contract/sslvw-service.ts @@ -10,18 +10,89 @@ export interface SSLVWPurInqReq { // 테스트 환경용 폴백 데이터 const FALLBACK_TEST_DATA: SSLVWPurInqReq[] = [ { - id: 1, - request_number: 'REQ001', - status: 'PENDING', - created_date: new Date('2025-01-01'), - description: '테스트 요청 1' + REG_NO: '1030', + INQ_TP: 'OC', + INQ_TP_DSC: '해외계약', + TIT: 'Contrack of Sale', + REQ_DGR: '2', + REQR_NM: '김원식', + REQ_DT: '20130829', + REVIEW_TERM_DT: '20130902', + RVWR_NM: '김미정', + CNFMR_NM: '안한진', + APPR_NM: '염정훈', + PRGS_STAT: 'E', + PRGS_STAT_DSC: '검토중이라고', + REGR_DPTCD: 'D602058000', + REGR_DEPTNM: '구매1팀(사외계약)' }, { - id: 2, - request_number: 'REQ002', - status: 'APPROVED', - created_date: new Date('2025-01-02'), - description: '테스트 요청 2' + REG_NO: '1076', + INQ_TP: 'IC', + INQ_TP_DSC: '국내계약', + TIT: 'CAISSON PIPE 복관 계약서 검토 요청件', + REQ_DGR: '1', + REQR_NM: '서권환', + REQ_DT: '20130821', + REVIEW_TERM_DT: '20130826', + RVWR_NM: '이택준', + CNFMR_NM: '이택준', + APPR_NM: '전상용', + PRGS_STAT: 'E', + PRGS_STAT_DSC: '완료', + REGR_DPTCD: 'D602058000', + REGR_DEPTNM: '구매1팀(사외계약)' + }, + { + REG_NO: '1100', + INQ_TP: 'IC', + INQ_TP_DSC: '국내계약', + TIT: '(7102) HVAC 작업계약', + REQ_DGR: '1', + REQR_NM: '신동동', + REQ_DT: '20130826', + REVIEW_TERM_DT: '20130829', + RVWR_NM: '이두리', + CNFMR_NM: '이두리', + APPR_NM: '전상용', + PRGS_STAT: 'E', + PRGS_STAT_DSC: '완료', + REGR_DPTCD: 'D602058000', + REGR_DEPTNM: '구매1팀(사외계약)' + }, + { + REG_NO: '1105', + INQ_TP: 'IC', + INQ_TP_DSC: '국내계약', + TIT: 'Plate 가공계약서 검토 요청건', + REQ_DGR: '1', + REQR_NM: '서권환', + REQ_DT: '20130826', + REVIEW_TERM_DT: '20130829', + RVWR_NM: '백영국', + CNFMR_NM: '백영국', + APPR_NM: '전상용', + PRGS_STAT: 'E', + PRGS_STAT_DSC: '완료', + REGR_DPTCD: 'D602058000', + REGR_DEPTNM: '구매1팀(사외계약)' + }, + { + REG_NO: '1106', + INQ_TP: 'IC', + INQ_TP_DSC: '국내계약', + TIT: 'SHELL FLNG, V-BRACE 제작 계약서 검토件', + REQ_DGR: '1', + REQR_NM: '성기승', + REQ_DT: '20130826', + REVIEW_TERM_DT: '20130830', + RVWR_NM: '이두리', + CNFMR_NM: '이두리', + APPR_NM: '전상용', + PRGS_STAT: 'E', + PRGS_STAT_DSC: '완료', + REGR_DPTCD: 'D602058000', + REGR_DEPTNM: '구매1팀(사외계약)' } ] @@ -89,6 +160,7 @@ export async function getSSLVWPurInqReqByRegNo(regNo: string): Promise<{ success: boolean data?: SSLVWPurInqReq error?: string + isUsingFallback?: boolean }> { if (!regNo) { return { @@ -112,6 +184,21 @@ export async function getSSLVWPurInqReqByRegNo(regNo: string): Promise<{ const cleanedResult = normalizeOracleRows(rows) if (cleanedResult.length === 0) { + // 데이터가 없을 때 폴백 테스트 데이터에서 찾기 + console.log(`[getSSLVWPurInqReqByRegNo] 데이터 없음, 폴백 테스트 데이터에서 검색: REG_NO=${regNo}`) + const fallbackData = FALLBACK_TEST_DATA.find(item => + String(item.REG_NO) === String(regNo) + ) + + if (fallbackData) { + console.log(`[getSSLVWPurInqReqByRegNo] 폴백 테스트 데이터에서 찾음: REG_NO=${regNo}`) + return { + success: true, + data: fallbackData, + isUsingFallback: true + } + } + return { success: false, error: '해당 REG_NO에 대한 데이터가 없습니다.' @@ -120,10 +207,27 @@ export async function getSSLVWPurInqReqByRegNo(regNo: string): Promise<{ return { success: true, - data: cleanedResult[0] + data: cleanedResult[0], + isUsingFallback: false } } catch (error) { console.error('[getSSLVWPurInqReqByRegNo] 오류:', error) + console.log(`[getSSLVWPurInqReqByRegNo] 폴백 테스트 데이터에서 검색: REG_NO=${regNo}`) + + // 오류 발생 시 폴백 테스트 데이터에서 찾기 + const fallbackData = FALLBACK_TEST_DATA.find(item => + String(item.REG_NO) === String(regNo) + ) + + if (fallbackData) { + console.log(`[getSSLVWPurInqReqByRegNo] 폴백 테스트 데이터에서 찾음: REG_NO=${regNo}`) + return { + success: true, + data: fallbackData, + isUsingFallback: true + } + } + return { success: false, error: error instanceof Error ? error.message : 'REG_NO 조회 중 오류가 발생했습니다.' diff --git a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx index 575582cf..8f945bbd 100644 --- a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx +++ b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx @@ -18,9 +18,10 @@ import { DialogTitle, } from "@/components/ui/dialog" import { Badge } from "@/components/ui/badge" -import { prepareFinalApprovalAction, quickFinalApprovalAction, resendContractsAction, updateLegalReviewStatusFromSSLVW } from "../service" +import { prepareFinalApprovalAction, quickFinalApprovalAction, resendContractsAction, updateLegalReviewStatusFromSSLVW, updateComplianceReviewStatusFromCPVW, requestComplianceInquiryAction } from "../service" import { BasicContractSignDialog } from "../vendor-table/basic-contract-sign-dialog" import { SSLVWPurInqReqDialog } from "@/components/common/legal/sslvw-pur-inq-req-dialog" +import { CPVWWabQustListViewDialog } from "@/components/common/legal/cpvw-wab-qust-list-view-dialog" import { prepareRedFlagResolutionApproval, requestRedFlagResolution } from "@/lib/compliance/red-flag-resolution" import { useRouter } from "next/navigation" import { useSession } from "next-auth/react" @@ -35,6 +36,7 @@ interface RedFlagResolutionState { interface BasicContractDetailTableToolbarActionsProps { table: Table<BasicContractView> gtcData?: Record<number, { gtcDocumentId: number | null; hasComments: boolean }> + agreementCommentData?: Record<number, { hasComments: boolean; commentCount: number }> redFlagData?: Record<number, boolean> redFlagResolutionData?: Record<number, RedFlagResolutionState> isComplianceTemplate?: boolean @@ -43,6 +45,7 @@ interface BasicContractDetailTableToolbarActionsProps { export function BasicContractDetailTableToolbarActions({ table, gtcData = {}, + agreementCommentData = {}, redFlagData = {}, redFlagResolutionData = {}, isComplianceTemplate = false @@ -81,24 +84,26 @@ export function BasicContractDetailTableToolbarActions({ if (contract.completedAt !== null || !contract.signedFilePath) { return false; } - if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) { - return false; - } + // ⚠️ 법무/준법문의 완료 여부는 SSLVW/CPVW 상태 및 완료 시간에 의존하므로, + // 여기서는 legalReviewCompletedAt / complianceReviewCompletedAt 기반으로 + // 최종 승인 버튼을 막지 않습니다. (상태/시간은 UI 참고용으로만 사용) return true; }); - // 법무검토 요청 가능 여부 - // 1. 협의 완료됨 (negotiationCompletedAt 있음) OR - // 2. 협의 없음 (코멘트 없음, hasComments: false) + // 법무검토 요청 가능 여부 (준법서약 템플릿이 아닐 때만) + // 1. 협력업체 서명 완료 (vendorSignedAt 있음) + // 2. 협의 완료됨 (negotiationCompletedAt 있음) OR + // 3. 협의 없음 (코멘트 없음, hasComments: false) // 협의 중 (negotiationCompletedAt 없고 코멘트 있음)은 불가 - const canRequestLegalReview = hasSelectedRows && selectedRows.some(row => { + const canRequestLegalReview = !isComplianceTemplate && hasSelectedRows && selectedRows.some(row => { const contract = row.original; - // 이미 법무검토 요청된 계약서는 제외 - if (contract.legalReviewRequestedAt) { - return false; - } - // 이미 최종승인 완료된 계약서는 제외 - if (contract.completedAt) { + + // 필수 조건 확인: 최종승인 미완료, 법무검토 미요청, 협력업체 서명 완료 + if ( + contract.legalReviewRequestedAt || + contract.completedAt || + !contract.vendorSignedAt + ) { return false; } @@ -123,6 +128,48 @@ export function BasicContractDetailTableToolbarActions({ return false; }); + // 준법문의 버튼 활성화 가능 여부 + // 1. 협력업체 서명 완료 (vendorSignedAt 있음) + // 2. 협의 완료됨 (negotiationCompletedAt 있음) OR 협의 없음 (코멘트 없음) + // 3. 레드플래그 해소됨 (redFlagResolutionData에서 resolved 상태) + // 4. 이미 준법문의 요청되지 않음 (complianceReviewRequestedAt 없음) + const canRequestComplianceInquiry = hasSelectedRows && selectedRows.some(row => { + const contract = row.original; + + // 필수 조건 확인: 준법서약 템플릿, 최종승인 미완료, 협력업체 서명 완료, 준법문의 미요청 + if ( + !isComplianceTemplate || + contract.completedAt || + !contract.vendorSignedAt || + contract.complianceReviewRequestedAt + ) { + return false; + } + + // 협의 완료 확인 + // 협의 완료된 경우 → 가능 + if (contract.negotiationCompletedAt) { + // 협의 완료됨, 레드플래그만 확인하면 됨 + } else { + // 협의 완료되지 않은 경우: 코멘트가 없으면 협의 없음으로 간주하여 가능 + const commentData = agreementCommentData[contract.id]; + if (commentData && commentData.hasComments) { + // 코멘트가 있으면 협의 중이므로 불가 + return false; + } + // 코멘트가 없으면 협의 없음으로 간주하여 가능 + } + + // 레드플래그 해소 확인 + const resolution = redFlagResolutionData[contract.id]; + // 레드플래그가 있는 경우, 해소되어야 함 + if (redFlagData[contract.id] === true && !resolution?.resolved) { + return false; + } + + return true; + }); + // 필터링된 계약서들 계산 const resendContracts = selectedRows.map(row => row.original) @@ -394,6 +441,47 @@ export function BasicContractDetailTableToolbarActions({ } } + // CPVW 데이터 선택 확인 핸들러 + const handleCPVWConfirm = async (selectedCPVWData: any[]) => { + if (!selectedCPVWData || selectedCPVWData.length === 0) { + toast.error("선택된 데이터가 없습니다.") + return + } + + if (selectedRows.length !== 1) { + toast.error("계약서 한 건을 선택해주세요.") + return + } + + try { + setLoading(true) + + // 선택된 계약서 ID들 추출 + const selectedContractIds = selectedRows.map(row => row.original.id) + + // 서버 액션 호출 + const result = await updateComplianceReviewStatusFromCPVW(selectedCPVWData, selectedContractIds) + + if (result.success) { + toast.success(result.message) + router.refresh() + table.toggleAllPageRowsSelected(false) + } else { + toast.error(result.message) + } + + if (result.errors && result.errors.length > 0) { + toast.warning(`일부 처리 실패: ${result.errors.join(', ')}`) + } + + } catch (error) { + console.error('CPVW 확인 처리 실패:', error) + toast.error('준법문의 상태 업데이트 중 오류가 발생했습니다.') + } finally { + setLoading(false) + } + } + // 빠른 승인 (서명 없이) const confirmQuickApproval = async () => { setLoading(true) @@ -541,9 +629,26 @@ export function BasicContractDetailTableToolbarActions({ const complianceInquiryUrl = 'http://60.101.207.55/Inquiry/Write/InquiryWrite.aspx' // 법무검토 요청 / 준법문의 - const handleRequestLegalReview = () => { + const handleRequestLegalReview = async () => { if (isComplianceTemplate) { - window.open(complianceInquiryUrl, '_blank', 'noopener,noreferrer') + // 준법문의: 요청일 기록 후 외부 URL 열기 + const selectedContractIds = selectedRows.map(row => row.original.id) + try { + setLoading(true) + const result = await requestComplianceInquiryAction(selectedContractIds) + if (result.success) { + toast.success(result.message) + router.refresh() + window.open(complianceInquiryUrl, '_blank', 'noopener,noreferrer') + } else { + toast.error(result.message) + } + } catch (error) { + console.error('준법문의 요청 처리 실패:', error) + toast.error('준법문의 요청 중 오류가 발생했습니다.') + } finally { + setLoading(false) + } return } setLegalReviewDialog(true) @@ -617,31 +722,72 @@ export function BasicContractDetailTableToolbarActions({ </span> </Button> - {/* 법무검토 버튼 (SSLVW 데이터 조회) */} - <SSLVWPurInqReqDialog - onConfirm={handleSSLVWConfirm} - requireSingleSelection - triggerDisabled={selectedRows.length !== 1 || loading} - triggerTitle={ - selectedRows.length !== 1 - ? "계약서 한 건을 선택해주세요" - : undefined - } - /> + {/* 법무검토 버튼 (SSLVW 데이터 조회) - 준법서약 템플릿이 아닐 때만 표시 */} + {!isComplianceTemplate && ( + <SSLVWPurInqReqDialog + onConfirm={handleSSLVWConfirm} + requireSingleSelection + triggerDisabled={selectedRows.length !== 1 || loading} + triggerTitle={ + selectedRows.length !== 1 + ? "계약서 한 건을 선택해주세요" + : undefined + } + /> + )} + + {/* 준법문의 요청 데이터 조회 버튼 (준법서약 템플릿만) */} + {isComplianceTemplate && ( + <CPVWWabQustListViewDialog + onConfirm={handleCPVWConfirm} + requireSingleSelection + triggerDisabled={selectedRows.length !== 1 || loading} + triggerTitle={ + selectedRows.length !== 1 + ? "계약서 한 건을 선택해주세요" + : undefined + } + /> + )} {/* 법무검토 요청 / 준법문의 버튼 */} - <Button - variant="outline" - size="sm" - onClick={handleRequestLegalReview} - className="gap-2" - title={isComplianceTemplate ? "준법문의 링크로 이동" : "법무검토 요청 링크 선택"} - > - <FileText className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline"> - {isComplianceTemplate ? "준법문의" : "법무검토 요청"} - </span> - </Button> + {isComplianceTemplate ? ( + <Button + variant="outline" + size="sm" + onClick={handleRequestLegalReview} + className="gap-2" + disabled={!canRequestComplianceInquiry || loading} + title={ + !canRequestComplianceInquiry + ? "협력업체 서명 완료, 협의 완료, 레드플래그 해소가 필요합니다" + : "준법문의 링크로 이동" + } + > + <FileText className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline"> + 준법문의 + </span> + </Button> + ) : ( + <Button + variant="outline" + size="sm" + onClick={handleRequestLegalReview} + className="gap-2" + disabled={!canRequestLegalReview || loading} + title={ + !canRequestLegalReview + ? "협력업체 서명 완료 및 협의 완료가 필요합니다" + : "법무검토 요청 링크 선택" + } + > + <FileText className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline"> + 법무검토 요청 + </span> + </Button> + )} {/* 최종승인 버튼 */} <Button diff --git a/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx b/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx index aab808b8..de6ba1a9 100644 --- a/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx +++ b/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx @@ -553,8 +553,8 @@ export function getDetailColumns({ minSize: 130, }, - // 법무검토 상태 - { + // 법무검토 상태 (준법서약 템플릿이 아닐 때만 표시) + ...(!isComplianceTemplate ? [{ accessorKey: "legalReviewStatus", header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title="법무검토 상태" /> @@ -571,7 +571,30 @@ export function getDetailColumns({ return <div className="text-sm text-gray-400">-</div> }, minSize: 140, + }] : []), + + // 준법문의 상태 (준법서약 템플릿일 때만 표시) + ...(isComplianceTemplate ? [{ + accessorKey: "complianceReviewStatus", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="준법문의 상태" /> + ), + cell: ({ row }) => { + const status = row.getValue("complianceReviewStatus") as string | null + + // PRGS_STAT_DSC 연동값 우선 표시 + if (status) { + return <div className="text-sm text-gray-800">{status}</div> + } + + // 동기화된 값이 없으면 빈 값 처리 + return <div className="text-sm text-gray-400">-</div> + }, + minSize: 140, }, + // Red Flag 컬럼들 (준법서약 템플릿일 때만 표시) + redFlagColumn, + redFlagResolutionColumn] : []), // 계약완료일 { @@ -659,17 +682,5 @@ export function getDetailColumns({ actionsColumn, ] - // 준법서약 템플릿인 경우 Red Flag 컬럼과 해제 컬럼을 법무검토 상태 뒤에 추가 - if (isComplianceTemplate) { - const legalReviewStatusIndex = baseColumns.findIndex((col) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (col as any).accessorKey === 'legalReviewStatus' - }) - - if (legalReviewStatusIndex !== -1) { - baseColumns.splice(legalReviewStatusIndex + 1, 0, redFlagColumn, redFlagResolutionColumn) - } - } - return baseColumns }
\ No newline at end of file diff --git a/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx b/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx index cface6b3..c6fe1cdd 100644 --- a/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx +++ b/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx @@ -241,6 +241,7 @@ type RedFlagResolutionState = { <BasicContractDetailTableToolbarActions table={table} gtcData={gtcData} + agreementCommentData={agreementCommentData} redFlagData={redFlagData} redFlagResolutionData={redFlagResolutionData} isComplianceTemplate={isComplianceTemplate} diff --git a/lib/bidding/actions.ts b/lib/bidding/actions.ts index 4e7da36c..cc246ee7 100644 --- a/lib/bidding/actions.ts +++ b/lib/bidding/actions.ts @@ -125,6 +125,11 @@ export async function transmitToContract(biddingId: number, userId: number) { const contractNumber = await generateContractNumber(safeUserId, biddingData.contractType) console.log('Generated contractNumber:', contractNumber) + // 연동제 여부 변환 (boolean -> Y/N) + const interlockingSystem = biddingCondition?.isPriceAdjustmentApplicable + ? 'Y' + : (biddingCondition?.isPriceAdjustmentApplicable === false ? 'N' : null) + // general-contract 생성 (발주비율 계산된 최종 금액 사용) const contractResult = await db.insert(generalContracts).values({ contractNumber, @@ -141,10 +146,13 @@ export async function transmitToContract(biddingId: number, userId: number) { currency: biddingData.currency || 'KRW', // 계약 조건 정보 추가 paymentTerm: biddingCondition?.paymentTerms || null, + paymentDelivery: biddingCondition?.paymentTerms || null, // 지급조건 (납품 지급조건) taxType: biddingCondition?.taxConditions || 'V0', deliveryTerm: biddingCondition?.incoterms || 'FOB', shippingLocation: biddingCondition?.shippingPort || null, dischargeLocation: biddingCondition?.destinationPort || null, + contractDeliveryDate: biddingCondition?.contractDeliveryDate || null, // 계약납기일 + interlockingSystem: interlockingSystem, // 연동제 여부 registeredById: userId, lastUpdatedById: userId, }).returning({ id: generalContracts.id }) diff --git a/lib/bidding/approval-actions.ts b/lib/bidding/approval-actions.ts index 3d07d49c..49b9f847 100644 --- a/lib/bidding/approval-actions.ts +++ b/lib/bidding/approval-actions.ts @@ -81,6 +81,7 @@ export async function prepareBiddingApprovalData(data: { projectName: biddings.projectName, itemName: biddings.itemName, biddingType: biddings.biddingType, + awardCount: biddings.awardCount, bidPicName: biddings.bidPicName, supplyPicName: biddings.supplyPicName, submissionStartDate: biddings.submissionStartDate, @@ -166,6 +167,7 @@ export async function prepareBiddingApprovalData(data: { ...bidding, projectName: bidding.projectName || undefined, itemName: bidding.itemName || undefined, + awardCount: bidding.awardCount || undefined, bidPicName: bidding.bidPicName || undefined, supplyPicName: bidding.supplyPicName || undefined, targetPrice: bidding.targetPrice ? Number(bidding.targetPrice) : undefined, diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index f52ecb1e..a0aa3378 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -3,7 +3,7 @@ import db from '@/db/db' import { biddings, prItemsForBidding, biddingDocuments, biddingCompanies, vendors, companyPrItemBids, companyConditionResponses, vendorSelectionResults, priceAdjustmentForms, users, vendorContacts } from '@/db/schema' import { specificationMeetings, biddingCompaniesContacts } from '@/db/schema/bidding' -import { eq, and, sql, desc, ne, asc } from 'drizzle-orm' +import { eq, and, sql, desc, ne, asc, inArray } from 'drizzle-orm' import { revalidatePath, revalidateTag } from 'next/cache' import { unstable_cache } from "@/lib/unstable-cache"; import { sendEmail } from '@/lib/mail/sendEmail' @@ -2596,101 +2596,72 @@ export async function getBiddingDocumentsForPartners(biddingId: number) { // 입찰가 비교 분석 함수들 // ================================================= -// 벤더별 입찰가 정보 조회 (캐시 적용) +// 벤더별 입찰가 정보 조회 (최적화 및 간소화됨) export async function getVendorPricesForBidding(biddingId: number) { - return unstable_cache( - async () => { - try { - // 각 회사의 입찰가 정보를 조회 - 본입찰 참여 업체들 - const vendorPrices = await db - .select({ - companyId: biddingCompanies.companyId, - companyName: vendors.vendorName, - biddingCompanyId: biddingCompanies.id, - currency: sql<string>`'KRW'`, // 기본값 KRW - finalQuoteAmount: biddingCompanies.finalQuoteAmount, - isBiddingParticipated: biddingCompanies.isBiddingParticipated, - }) - .from(biddingCompanies) - .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) - .where(and( - eq(biddingCompanies.biddingId, biddingId), - eq(biddingCompanies.isBiddingParticipated, true), // 본입찰 참여 업체만 - sql`${biddingCompanies.finalQuoteAmount} IS NOT NULL` // 입찰가를 제출한 업체만 - )) + try { + // 1. 본입찰 참여 업체들 조회 + const participatingVendors = await db + .select({ + companyId: biddingCompanies.companyId, + companyName: vendors.vendorName, + biddingCompanyId: biddingCompanies.id, + currency: sql<string>`'KRW'`, // 기본값 KRW + finalQuoteAmount: biddingCompanies.finalQuoteAmount, + isBiddingParticipated: biddingCompanies.isBiddingParticipated, + }) + .from(biddingCompanies) + .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.isBiddingParticipated, true) // 본입찰 참여 업체만 + )) - console.log(`Found ${vendorPrices.length} vendors for bidding ${biddingId}`) + if (participatingVendors.length === 0) { + return [] + } - const result: any[] = [] + const biddingCompanyIds = participatingVendors.map(v => v.biddingCompanyId) - for (const vendor of vendorPrices) { - try { - // 해당 회사의 품목별 입찰가 조회 (본입찰 데이터) - const itemPrices = await db - .select({ - prItemId: companyPrItemBids.prItemId, - itemName: prItemsForBidding.itemInfo, // itemInfo 사용 - itemNumber: prItemsForBidding.itemNumber, // itemNumber도 포함 - quantity: prItemsForBidding.quantity, - quantityUnit: prItemsForBidding.quantityUnit, - weight: prItemsForBidding.totalWeight, // totalWeight 사용 - weightUnit: prItemsForBidding.weightUnit, - unitPrice: companyPrItemBids.bidUnitPrice, - amount: companyPrItemBids.bidAmount, - proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate, - }) - .from(companyPrItemBids) - .leftJoin(prItemsForBidding, eq(companyPrItemBids.prItemId, prItemsForBidding.id)) - .where(and( - eq(companyPrItemBids.biddingCompanyId, vendor.biddingCompanyId), - eq(companyPrItemBids.isPreQuote, false) // 본입찰 데이터만 - )) - .orderBy(prItemsForBidding.id) - - console.log(`Vendor ${vendor.companyName}: Found ${itemPrices.length} item prices`) - - // 총 금액은 biddingCompanies.finalQuoteAmount 사용 - const totalAmount = parseFloat(vendor.finalQuoteAmount || '0') - - result.push({ - companyId: vendor.companyId, - companyName: vendor.companyName || `Vendor ${vendor.companyId}`, - biddingCompanyId: vendor.biddingCompanyId, - totalAmount, - currency: vendor.currency, - itemPrices: itemPrices.map(item => ({ - prItemId: item.prItemId, - itemName: item.itemName || item.itemNumber || `Item ${item.prItemId}`, - quantity: parseFloat(item.quantity || '0'), - quantityUnit: item.quantityUnit || 'ea', - weight: item.weight ? parseFloat(item.weight) : null, - weightUnit: item.weightUnit, - unitPrice: parseFloat(item.unitPrice || '0'), - amount: parseFloat(item.amount || '0'), - proposedDeliveryDate: item.proposedDeliveryDate ? - (typeof item.proposedDeliveryDate === 'string' - ? item.proposedDeliveryDate - : item.proposedDeliveryDate.toISOString().split('T')[0]) - : null, - })) - }) - } catch (vendorError) { - console.error(`Error processing vendor ${vendor.companyId}:`, vendorError) - // 벤더 처리 중 에러가 발생해도 다른 벤더들은 계속 처리 - } - } + // 2. 해당 업체들의 입찰 품목 조회 (한 번의 쿼리로 최적화) + // 필요한 필드만 조회: prItemId, bidUnitPrice, bidAmount + const allItemBids = await db + .select({ + biddingCompanyId: companyPrItemBids.biddingCompanyId, + prItemId: companyPrItemBids.prItemId, + bidUnitPrice: companyPrItemBids.bidUnitPrice, + bidAmount: companyPrItemBids.bidAmount, + }) + .from(companyPrItemBids) + .where(and( + inArray(companyPrItemBids.biddingCompanyId, biddingCompanyIds), + eq(companyPrItemBids.isPreQuote, false) // 본입찰 데이터만 + )) - return result - } catch (error) { - console.error('Failed to get vendor prices for bidding:', error) - return [] + // 3. 업체별로 데이터 매핑 + const result = participatingVendors.map(vendor => { + const vendorItems = allItemBids.filter(item => item.biddingCompanyId === vendor.biddingCompanyId) + + const totalAmount = parseFloat(vendor.finalQuoteAmount || '0') + + return { + companyId: vendor.companyId, + companyName: vendor.companyName || `Vendor ${vendor.companyId}`, + biddingCompanyId: vendor.biddingCompanyId, + totalAmount, + currency: vendor.currency, + itemPrices: vendorItems.map(item => ({ + prItemId: item.prItemId, + unitPrice: parseFloat(item.bidUnitPrice || '0'), + amount: parseFloat(item.bidAmount || '0'), + })) } - }, - [`bidding-vendor-prices-${biddingId}`], - { - tags: [`bidding-${biddingId}`, 'quotation-vendors', 'pr-items'] - } - )() + }) + + return result + } catch (error) { + console.error('Failed to get vendor prices for bidding:', error) + return [] + } } // 사양설명회 참여 여부 업데이트 diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx index fffac0c1..a6f64964 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx @@ -27,6 +27,7 @@ interface BiddingDetailVendorTableContentProps { onOpenSelectionReasonDialog: () => void onViewItemDetails?: (vendor: QuotationVendor) => void onViewQuotationHistory?: (vendor: QuotationVendor) => void + readOnly?: boolean } const filterFields: DataTableFilterField<QuotationVendor>[] = [ @@ -86,7 +87,8 @@ export function BiddingDetailVendorTableContent({ vendors, onRefresh, onViewItemDetails, - onViewQuotationHistory + onViewQuotationHistory, + readOnly = false }: BiddingDetailVendorTableContentProps) { const { data: session } = useSession() const { toast } = useToast() @@ -269,6 +271,7 @@ export function BiddingDetailVendorTableContent({ onSuccess={onRefresh} winnerVendor={vendors.find(v => v.awardRatio === 100)} singleSelectedVendor={singleSelectedVendor} + readOnly={readOnly} /> </DataTableAdvancedToolbar> </DataTable> 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 8df29289..7e571a23 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx @@ -25,6 +25,7 @@ interface BiddingDetailVendorToolbarActionsProps { onSuccess: () => void winnerVendor?: QuotationVendor | null // 100% 낙찰된 벤더 singleSelectedVendor?: QuotationVendor | null // single select된 벤더 + readOnly?: boolean } export function BiddingDetailVendorToolbarActions({ @@ -35,7 +36,8 @@ export function BiddingDetailVendorToolbarActions({ onOpenAwardRatioDialog, onSuccess, winnerVendor, - singleSelectedVendor + singleSelectedVendor, + readOnly = false }: BiddingDetailVendorToolbarActionsProps) { const router = useRouter() const { toast } = useToast() @@ -82,53 +84,6 @@ export function BiddingDetailVendorToolbarActions({ setIsBiddingInvitationDialogOpen(true) } - // const handleBiddingInvitationSend = async (data: any) => { - // try { - // // 1. 기본계약 발송 - // const contractResult = await sendBiddingBasicContracts( - // biddingId, - // data.vendors, - // data.generatedPdfs, - // data.message - // ) - - // if (!contractResult.success) { - // toast({ - // title: '기본계약 발송 실패', - // description: contractResult.error, - // variant: 'destructive', - // }) - // return - // } - - // // 2. 입찰 등록 진행 - // const registerResult = await registerBidding(bidding.id, userId) - - // if (registerResult.success) { - // toast({ - // title: '본입찰 초대 완료', - // description: '기본계약 발송 및 본입찰 초대가 완료되었습니다.', - // }) - // setIsBiddingInvitationDialogOpen(false) - // router.refresh() - // onSuccess() - // } else { - // toast({ - // title: '오류', - // description: registerResult.error, - // variant: 'destructive', - // }) - // } - // } catch (error) { - // console.error('본입찰 초대 실패:', error) - // toast({ - // title: '오류', - // description: '본입찰 초대에 실패했습니다.', - // variant: 'destructive', - // }) - // } - // } - // 선정된 업체들 조회 (서버 액션 함수 사용) const getSelectedVendors = async () => { try { @@ -165,27 +120,6 @@ export function BiddingDetailVendorToolbarActions({ }) } - const handleRoundIncrease = () => { - startTransition(async () => { - const result = await increaseRoundOrRebid(bidding.id, userId, 'round_increase') - - if (result.success) { - toast({ - title: "성공", - description: result.message, - }) - router.push(`/evcp/bid`) - onSuccess() - } else { - toast({ - title: "오류", - description: result.error || "차수증가 중 오류가 발생했습니다.", - variant: 'destructive', - }) - } - }) - } - const handleCancelAward = () => { if (!winnerVendor) return @@ -233,69 +167,74 @@ export function BiddingDetailVendorToolbarActions({ return ( <> <div className="flex items-center gap-2"> - {/* 상태별 액션 버튼 */} - {/* 차수증가: 입찰공고 또는 입찰평가중 상태에서만 */} - {(bidding.status === 'evaluation_of_bidding' || bidding.status === 'bidding_opened') && ( - <Button - variant="outline" - size="sm" - onClick={() => setIsRoundIncreaseDialogOpen(true)} - disabled={isPending} - > - <RotateCw className="mr-2 h-4 w-4" /> - 차수증가 - </Button> - )} - - {/* 발주비율 산정: single select 시에만 활성화 */} - {(bidding.status === 'evaluation_of_bidding') && ( - <Button - variant="outline" - size="sm" - onClick={onOpenAwardRatioDialog} - disabled={!singleSelectedVendor || isPending || singleSelectedVendor.isBiddingParticipated !== true} - > - <DollarSign className="mr-2 h-4 w-4" /> - 발주비율 산정 - </Button> - )} - - {/* 유찰/낙찰: 입찰공고 또는 입찰평가중 상태에서만 */} - {(bidding.status === 'bidding_opened' || bidding.status === 'evaluation_of_bidding') && ( + {/* 상태별 액션 버튼 - 읽기 전용이 아닐 때만 표시 */} + {!readOnly && ( <> - <Button - variant="destructive" - size="sm" - onClick={handleMarkAsDisposal} - disabled={isPending} - > - <XCircle className="mr-2 h-4 w-4" /> - 유찰 - </Button> - <Button - variant="default" - size="sm" - onClick={onOpenAwardDialog} - disabled={isPending} - > - <Trophy className="mr-2 h-4 w-4" /> - 낙찰 - </Button> + {/* 차수증가: 입찰공고 또는 입찰평가중 상태에서만 */} + {(bidding.status === 'evaluation_of_bidding' || bidding.status === 'bidding_opened') && ( + <Button + variant="outline" + size="sm" + onClick={() => setIsRoundIncreaseDialogOpen(true)} + disabled={isPending} + > + <RotateCw className="mr-2 h-4 w-4" /> + 차수증가 + </Button> + )} + + {/* 발주비율 산정: single select 시에만 활성화 */} + {(bidding.status === 'evaluation_of_bidding') && ( + <Button + variant="outline" + size="sm" + onClick={onOpenAwardRatioDialog} + disabled={!singleSelectedVendor || isPending || singleSelectedVendor.isBiddingParticipated !== true} + > + <DollarSign className="mr-2 h-4 w-4" /> + 발주비율 산정 + </Button> + )} + + {/* 유찰/낙찰: 입찰공고 또는 입찰평가중 상태에서만 */} + {(bidding.status === 'bidding_opened' || bidding.status === 'evaluation_of_bidding') && ( + <> + <Button + variant="destructive" + size="sm" + onClick={handleMarkAsDisposal} + disabled={isPending} + > + <XCircle className="mr-2 h-4 w-4" /> + 유찰 + </Button> + <Button + variant="default" + size="sm" + onClick={onOpenAwardDialog} + disabled={isPending} + > + <Trophy className="mr-2 h-4 w-4" /> + 낙찰 + </Button> + </> + )} + + {/* 발주비율 취소: 100% 낙찰된 벤더가 있는 경우 */} + {winnerVendor && ( + <Button + variant="outline" + size="sm" + onClick={() => setIsCancelAwardDialogOpen(true)} + disabled={isPending} + > + <RotateCcw className="mr-2 h-4 w-4" /> + 발주비율 취소 + </Button> + )} </> )} - {/* 발주비율 취소: 100% 낙찰된 벤더가 있는 경우 */} - {winnerVendor && ( - <Button - variant="outline" - size="sm" - onClick={() => setIsCancelAwardDialogOpen(true)} - disabled={isPending} - > - <RotateCcw className="mr-2 h-4 w-4" /> - 발주비율 취소 - </Button> - )} {/* 구분선 */} {(bidding.status === 'bidding_generated' || bidding.status === 'bidding_disposal') && ( diff --git a/lib/bidding/handlers.ts b/lib/bidding/handlers.ts index 11955a39..c64d9527 100644 --- a/lib/bidding/handlers.ts +++ b/lib/bidding/handlers.ts @@ -127,6 +127,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: { biddingNumber: string; projectName?: string; itemName?: string; + awardCount: string; biddingType: string; bidPicName?: string; supplyPicName?: string; @@ -181,7 +182,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: { const { bidding, biddingItems, vendors, message, specificationMeeting, requestedAt } = payload; // 제목 - const title = bidding.title || '입찰'; + const title = bidding.title || ''; // 입찰명 const biddingTitle = bidding.title || ''; @@ -190,7 +191,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: { const biddingNumber = bidding.biddingNumber || ''; // 낙찰업체수 - const winnerCount = '1'; // 기본값, 실제로는 bidding 설정에서 가져와야 함 + const awardCount = bidding.awardCount || ''; // 계약구분 const contractType = bidding.biddingType || ''; @@ -199,7 +200,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: { const prNumber = ''; // 예산 - const budget = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : ''; + const budget = bidding.budget ? bidding.budget.toLocaleString() : ''; // 내정가 const targetPrice = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : ''; @@ -272,7 +273,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: { 제목: title, 입찰명: biddingTitle, 입찰번호: biddingNumber, - 낙찰업체수: winnerCount, + 낙찰업체수: awardCount, 계약구분: contractType, 'P/R번호': prNumber, 예산: budget, @@ -637,6 +638,7 @@ export async function mapBiddingAwardToTemplateVariables(payload: { biddingType: biddings.biddingType, bidPicName: biddings.bidPicName, supplyPicName: biddings.supplyPicName, + budget: biddings.budget, targetPrice: biddings.targetPrice, awardCount: biddings.awardCount, }) @@ -684,7 +686,7 @@ export async function mapBiddingAwardToTemplateVariables(payload: { const biddingNumber = bidding.biddingNumber || ''; const winnerCount = (bidding.awardCount === 'single' ? 1 : bidding.awardCount === 'multiple' ? 2 : 1).toString(); const contractType = bidding.biddingType || ''; - const budget = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : ''; + const budget = bidding.budget ? bidding.budget.toLocaleString() : ''; const targetPrice = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : ''; const biddingManager = bidding.bidPicName || bidding.supplyPicName || ''; const biddingOverview = bidding.itemName || ''; diff --git a/lib/bidding/list/biddings-table-toolbar-actions.tsx b/lib/bidding/list/biddings-table-toolbar-actions.tsx index 33368218..b0007c8c 100644 --- a/lib/bidding/list/biddings-table-toolbar-actions.tsx +++ b/lib/bidding/list/biddings-table-toolbar-actions.tsx @@ -7,7 +7,7 @@ import { } from "lucide-react" import { toast } from "sonner" import { useSession } from "next-auth/react" -import { exportTableToExcel } from "@/lib/export" +import { exportBiddingsToExcel } from "./export-biddings-to-excel" import { Button } from "@/components/ui/button" import { DropdownMenu, @@ -92,6 +92,23 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio return selectedBiddings.length === 1 && selectedBiddings[0].status === 'bidding_generated' }, [selectedBiddings]) + // Excel 내보내기 핸들러 + const handleExport = React.useCallback(async () => { + try { + setIsExporting(true) + await exportBiddingsToExcel(table, { + filename: "입찰목록", + onlySelected: false, + }) + toast.success("Excel 파일이 다운로드되었습니다.") + } catch (error) { + console.error("Excel export error:", error) + toast.error("Excel 내보내기 중 오류가 발생했습니다.") + } finally { + setIsExporting(false) + } + }, [table]) + return ( <> <div className="flex items-center gap-2"> @@ -100,6 +117,17 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio // 성공 시 테이블 새로고침 등 추가 작업 // window.location.reload() }} /> + {/* Excel 내보내기 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleExport} + disabled={isExporting} + className="gap-2" + > + <FileSpreadsheet className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">{isExporting ? "내보내는 중..." : "Excel 내보내기"}</span> + </Button> {/* 전송하기 (업체선정 완료된 입찰만) */} <Button variant="default" @@ -112,20 +140,16 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio <span className="hidden sm:inline">전송하기</span> </Button> {/* 삭제 버튼 */} - - <Button - variant="destructive" - size="sm" - onClick={() => setIsDeleteDialogOpen(true)} - disabled={!canDelete} - className="gap-2" - > - <Trash className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">삭제</span> - </Button> - - - + <Button + variant="destructive" + size="sm" + onClick={() => setIsDeleteDialogOpen(true)} + disabled={!canDelete} + className="gap-2" + > + <Trash className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">삭제</span> + </Button> </div> {/* 전송 다이얼로그 */} diff --git a/lib/bidding/list/export-biddings-to-excel.ts b/lib/bidding/list/export-biddings-to-excel.ts new file mode 100644 index 00000000..8b13e38d --- /dev/null +++ b/lib/bidding/list/export-biddings-to-excel.ts @@ -0,0 +1,212 @@ +import { type Table } from "@tanstack/react-table" +import ExcelJS from "exceljs" +import { BiddingListItem } from "@/db/schema" +import { + biddingStatusLabels, + contractTypeLabels, + biddingTypeLabels, +} from "@/db/schema" +import { formatDate } from "@/lib/utils" + +// BiddingListItem 확장 타입 (manager 정보 포함) +type BiddingListItemWithManagerCode = BiddingListItem & { + bidPicName?: string | null + supplyPicName?: string | null +} + +/** + * 입찰 목록을 Excel로 내보내기 + * - 계약구분, 진행상태, 입찰유형은 라벨(명칭)로 변환 + * - 입찰서 제출기간은 submissionStartDate, submissionEndDate 기준 + * - 등록일시는 년, 월, 일 형식 + */ +export async function exportBiddingsToExcel( + table: Table<BiddingListItemWithManagerCode>, + { + filename = "입찰목록", + onlySelected = false, + }: { + filename?: string + onlySelected?: boolean + } = {} +): Promise<void> { + // 테이블에서 실제 사용 중인 leaf columns 가져오기 + const allColumns = table.getAllLeafColumns() + + // select, actions 컬럼 제외 + const columns = allColumns.filter( + (col) => !["select", "actions"].includes(col.id) + ) + + // 헤더 행 생성 (excelHeader 사용) + const headerRow = columns.map((col) => { + const excelHeader = (col.columnDef.meta as any)?.excelHeader + return typeof excelHeader === "string" ? excelHeader : col.id + }) + + // 데이터 행 생성 + const rowModel = onlySelected + ? table.getFilteredSelectedRowModel() + : table.getRowModel() + + const dataRows = rowModel.rows.map((row) => { + const original = row.original + return columns.map((col) => { + const colId = col.id + let value: any + + // 특별 처리 필요한 컬럼들 + switch (colId) { + case "contractType": + // 계약구분: 라벨로 변환 + value = contractTypeLabels[original.contractType as keyof typeof contractTypeLabels] || original.contractType + break + + case "status": + // 진행상태: 라벨로 변환 + value = biddingStatusLabels[original.status as keyof typeof biddingStatusLabels] || original.status + break + + case "biddingType": + // 입찰유형: 라벨로 변환 + value = biddingTypeLabels[original.biddingType as keyof typeof biddingTypeLabels] || original.biddingType + break + + case "submissionPeriod": + // 입찰서 제출기간: submissionStartDate, submissionEndDate 기준 + const startDate = original.submissionStartDate + const endDate = original.submissionEndDate + + if (!startDate || !endDate) { + value = "-" + } else { + 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', ' ') + } + + value = `${formatKst(startObj)} ~ ${formatKst(endObj)}` + } + break + + case "updatedAt": + // 등록일시: 년, 월, 일 형식만 + if (original.updatedAt) { + value = formatDate(original.updatedAt, "KR") + } else { + value = "-" + } + break + + case "biddingRegistrationDate": + // 입찰등록일: 년, 월, 일 형식만 + if (original.biddingRegistrationDate) { + value = formatDate(original.biddingRegistrationDate, "KR") + } else { + value = "-" + } + break + + case "projectName": + // 프로젝트: 코드와 이름 조합 + const code = original.projectCode + const name = original.projectName + value = code && name ? `${code} (${name})` : (code || name || "-") + break + + case "hasSpecificationMeeting": + // 사양설명회: Yes/No + value = original.hasSpecificationMeeting ? "Yes" : "No" + break + + default: + // 기본값: row.getValue 사용 + value = row.getValue(colId) + + // null/undefined 처리 + if (value == null) { + value = "" + } + + // 객체인 경우 JSON 문자열로 변환 + if (typeof value === "object") { + value = JSON.stringify(value) + } + break + } + + return value + }) + }) + + // 최종 sheetData + const sheetData = [headerRow, ...dataRows] + + // ExcelJS로 파일 생성 및 다운로드 + await createAndDownloadExcel(sheetData, columns.length, filename) +} + +/** + * Excel 파일 생성 및 다운로드 + */ +async function createAndDownloadExcel( + sheetData: any[][], + columnCount: number, + filename: string +): Promise<void> { + // ExcelJS 워크북/시트 생성 + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("Sheet1") + + // 칼럼별 최대 길이 추적 + const maxColumnLengths = Array(columnCount).fill(0) + sheetData.forEach((row) => { + row.forEach((cellValue, colIdx) => { + const cellText = cellValue?.toString() ?? "" + if (cellText.length > maxColumnLengths[colIdx]) { + maxColumnLengths[colIdx] = cellText.length + } + }) + }) + + // 시트에 데이터 추가 + 헤더 스타일 + sheetData.forEach((arr, idx) => { + const row = worksheet.addRow(arr) + + // 헤더 스타일 적용 (첫 번째 행) + if (idx === 0) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + } + }) + } + }) + + // 칼럼 너비 자동 조정 + maxColumnLengths.forEach((len, idx) => { + // 최소 너비 10, +2 여백 + worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10) + }) + + // 최종 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `${filename}.xlsx` + link.click() + URL.revokeObjectURL(url) +} + diff --git a/lib/bidding/manage/export-bidding-items-to-excel.ts b/lib/bidding/manage/export-bidding-items-to-excel.ts new file mode 100644 index 00000000..814648a7 --- /dev/null +++ b/lib/bidding/manage/export-bidding-items-to-excel.ts @@ -0,0 +1,161 @@ +import ExcelJS from "exceljs" +import { PRItemInfo } from "@/components/bidding/manage/bidding-items-editor" +import { getProjectCodesByIds } from "./project-utils" + +/** + * 입찰품목 목록을 Excel로 내보내기 + */ +export async function exportBiddingItemsToExcel( + items: PRItemInfo[], + { + filename = "입찰품목목록", + }: { + filename?: string + } = {} +): Promise<void> { + // 프로젝트 ID 목록 수집 + const projectIds = items + .map((item) => item.projectId) + .filter((id): id is number => id != null && id > 0) + + // 프로젝트 코드 맵 조회 + const projectCodeMap = await getProjectCodesByIds(projectIds) + + // 헤더 정의 + const headers = [ + "프로젝트코드", + "프로젝트명", + "자재그룹코드", + "자재그룹명", + "자재코드", + "자재명", + "수량", + "수량단위", + "중량", + "중량단위", + "납품요청일", + "가격단위", + "구매단위", + "자재순중량", + "내정단가", + "내정금액", + "내정통화", + "예산금액", + "예산통화", + "실적금액", + "실적통화", + "WBS코드", + "WBS명", + "코스트센터코드", + "코스트센터명", + "GL계정코드", + "GL계정명", + "PR번호", + ] + + // 데이터 행 생성 + const dataRows = items.map((item) => { + // 프로젝트 코드 조회 + const projectCode = item.projectId + ? projectCodeMap.get(item.projectId) || "" + : "" + + return [ + projectCode, + item.projectInfo || "", + item.materialGroupNumber || "", + item.materialGroupInfo || "", + item.materialNumber || "", + item.materialInfo || "", + item.quantity || "", + item.quantityUnit || "", + item.totalWeight || "", + item.weightUnit || "", + item.requestedDeliveryDate || "", + item.priceUnit || "", + item.purchaseUnit || "", + item.materialWeight || "", + item.targetUnitPrice || "", + item.targetAmount || "", + item.targetCurrency || "KRW", + item.budgetAmount || "", + item.budgetCurrency || "KRW", + item.actualAmount || "", + item.actualCurrency || "KRW", + item.wbsCode || "", + item.wbsName || "", + item.costCenterCode || "", + item.costCenterName || "", + item.glAccountCode || "", + item.glAccountName || "", + item.prNumber || "", + ] + }) + + // 최종 sheetData + const sheetData = [headers, ...dataRows] + + // ExcelJS로 파일 생성 및 다운로드 + await createAndDownloadExcel(sheetData, headers.length, filename) +} + +/** + * Excel 파일 생성 및 다운로드 + */ +async function createAndDownloadExcel( + sheetData: any[][], + columnCount: number, + filename: string +): Promise<void> { + // ExcelJS 워크북/시트 생성 + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("Sheet1") + + // 칼럼별 최대 길이 추적 + const maxColumnLengths = Array(columnCount).fill(0) + sheetData.forEach((row) => { + row.forEach((cellValue, colIdx) => { + const cellText = cellValue?.toString() ?? "" + if (cellText.length > maxColumnLengths[colIdx]) { + maxColumnLengths[colIdx] = cellText.length + } + }) + }) + + // 시트에 데이터 추가 + 헤더 스타일 + sheetData.forEach((arr, idx) => { + const row = worksheet.addRow(arr) + + // 헤더 스타일 적용 (첫 번째 행) + if (idx === 0) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + } + }) + } + }) + + // 칼럼 너비 자동 조정 + maxColumnLengths.forEach((len, idx) => { + // 최소 너비 10, +2 여백 + worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10) + }) + + // 최종 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `${filename}.xlsx` + link.click() + URL.revokeObjectURL(url) +} + diff --git a/lib/bidding/manage/import-bidding-items-from-excel.ts b/lib/bidding/manage/import-bidding-items-from-excel.ts new file mode 100644 index 00000000..2e0dfe33 --- /dev/null +++ b/lib/bidding/manage/import-bidding-items-from-excel.ts @@ -0,0 +1,271 @@ +import ExcelJS from "exceljs" +import { PRItemInfo } from "@/components/bidding/manage/bidding-items-editor" +import { getProjectIdByCodeAndName } from "./project-utils" + +export interface ImportBiddingItemsResult { + success: boolean + items: PRItemInfo[] + errors: string[] +} + +/** + * Excel 파일에서 입찰품목 데이터 파싱 + */ +export async function importBiddingItemsFromExcel( + file: File +): Promise<ImportBiddingItemsResult> { + const errors: string[] = [] + const items: PRItemInfo[] = [] + + try { + const workbook = new ExcelJS.Workbook() + const arrayBuffer = await file.arrayBuffer() + await workbook.xlsx.load(arrayBuffer) + + const worksheet = workbook.worksheets[0] + if (!worksheet) { + return { + success: false, + items: [], + errors: ["Excel 파일에 시트가 없습니다."], + } + } + + // 헤더 행 읽기 (첫 번째 행) + const headerRow = worksheet.getRow(1) + const headerValues = headerRow.values as ExcelJS.CellValue[] + + // 헤더 매핑 생성 + const headerMap: Record<string, number> = {} + const expectedHeaders = [ + "프로젝트코드", + "프로젝트명", + "자재그룹코드", + "자재그룹명", + "자재코드", + "자재명", + "수량", + "수량단위", + "중량", + "중량단위", + "납품요청일", + "가격단위", + "구매단위", + "자재순중량", + "내정단가", + "내정금액", + "내정통화", + "예산금액", + "예산통화", + "실적금액", + "실적통화", + "WBS코드", + "WBS명", + "코스트센터코드", + "코스트센터명", + "GL계정코드", + "GL계정명", + "PR번호", + ] + + // 헤더 인덱스 매핑 + for (let i = 1; i < headerValues.length; i++) { + const headerValue = String(headerValues[i] || "").trim() + if (headerValue && expectedHeaders.includes(headerValue)) { + headerMap[headerValue] = i + } + } + + // 필수 헤더 확인 + const requiredHeaders = ["자재그룹코드", "자재그룹명"] + const missingHeaders = requiredHeaders.filter( + (h) => !headerMap[h] + ) + if (missingHeaders.length > 0) { + errors.push( + `필수 컬럼이 없습니다: ${missingHeaders.join(", ")}` + ) + } + + // 데이터 행 읽기 (2번째 행부터) + for (let rowIndex = 2; rowIndex <= worksheet.rowCount; rowIndex++) { + const row = worksheet.getRow(rowIndex) + const rowValues = row.values as ExcelJS.CellValue[] + + // 빈 행 건너뛰기 + if (rowValues.every((val) => !val || String(val).trim() === "")) { + continue + } + + // 셀 값 추출 헬퍼 + const getCellValue = (headerName: string): string => { + const colIndex = headerMap[headerName] + if (!colIndex) return "" + const value = rowValues[colIndex] + if (value == null) return "" + + // ExcelJS 객체 처리 + if (typeof value === "object" && "text" in value) { + return String((value as any).text || "") + } + + // 날짜 처리 + if (value instanceof Date) { + return value.toISOString().split("T")[0] + } + + return String(value).trim() + } + + // 필수값 검증 + const materialGroupNumber = getCellValue("자재그룹코드") + const materialGroupInfo = getCellValue("자재그룹명") + + if (!materialGroupNumber || !materialGroupInfo) { + errors.push( + `${rowIndex}번 행: 자재그룹코드와 자재그룹명은 필수입니다.` + ) + continue + } + + // 수량 또는 중량 검증 + const quantity = getCellValue("수량") + const totalWeight = getCellValue("중량") + const quantityUnit = getCellValue("수량단위") + const weightUnit = getCellValue("중량단위") + + if (!quantity && !totalWeight) { + errors.push( + `${rowIndex}번 행: 수량 또는 중량 중 하나는 필수입니다.` + ) + continue + } + + if (quantity && !quantityUnit) { + errors.push( + `${rowIndex}번 행: 수량이 있으면 수량단위가 필수입니다.` + ) + continue + } + + if (totalWeight && !weightUnit) { + errors.push( + `${rowIndex}번 행: 중량이 있으면 중량단위가 필수입니다.` + ) + continue + } + + // 납품요청일 검증 + const requestedDeliveryDate = getCellValue("납품요청일") + if (!requestedDeliveryDate) { + errors.push( + `${rowIndex}번 행: 납품요청일은 필수입니다.` + ) + continue + } + + // 날짜 형식 검증 + const dateRegex = /^\d{4}-\d{2}-\d{2}$/ + if (requestedDeliveryDate && !dateRegex.test(requestedDeliveryDate)) { + errors.push( + `${rowIndex}번 행: 납품요청일 형식이 올바르지 않습니다. (YYYY-MM-DD 형식)` + ) + continue + } + + // 내정단가 검증 (필수) + const targetUnitPrice = getCellValue("내정단가") + if (!targetUnitPrice || parseFloat(targetUnitPrice.replace(/,/g, "")) <= 0) { + errors.push( + `${rowIndex}번 행: 내정단가는 필수이며 0보다 커야 합니다.` + ) + continue + } + + // 숫자 값 정리 (콤마 제거) + const cleanNumber = (value: string): string => { + return value.replace(/,/g, "").trim() + } + + // 프로젝트 ID 조회 (프로젝트코드와 프로젝트명으로) + const projectCode = getCellValue("프로젝트코드") + const projectName = getCellValue("프로젝트명") + let projectId: number | null = null + + if (projectCode && projectName) { + projectId = await getProjectIdByCodeAndName(projectCode, projectName) + if (!projectId) { + errors.push( + `${rowIndex}번 행: 프로젝트코드 "${projectCode}"와 프로젝트명 "${projectName}"에 해당하는 프로젝트를 찾을 수 없습니다.` + ) + // 프로젝트를 찾지 못해도 계속 진행 (경고만 표시) + } + } + + // PRItemInfo 객체 생성 + const item: PRItemInfo = { + id: -(rowIndex - 1), // 임시 ID (음수) + prNumber: getCellValue("PR번호") || null, + projectId: projectId, + projectInfo: projectName || null, + shi: null, + quantity: quantity ? cleanNumber(quantity) : null, + quantityUnit: quantityUnit || null, + totalWeight: totalWeight ? cleanNumber(totalWeight) : null, + weightUnit: weightUnit || null, + materialDescription: null, + hasSpecDocument: false, + requestedDeliveryDate: requestedDeliveryDate || null, + isRepresentative: false, + annualUnitPrice: null, + currency: "KRW", + materialGroupNumber: materialGroupNumber || null, + materialGroupInfo: materialGroupInfo || null, + materialNumber: getCellValue("자재코드") || null, + materialInfo: getCellValue("자재명") || null, + priceUnit: getCellValue("가격단위") || "1", + purchaseUnit: getCellValue("구매단위") || "EA", + materialWeight: getCellValue("자재순중량") || null, + wbsCode: getCellValue("WBS코드") || null, + wbsName: getCellValue("WBS명") || null, + costCenterCode: getCellValue("코스트센터코드") || null, + costCenterName: getCellValue("코스트센터명") || null, + glAccountCode: getCellValue("GL계정코드") || null, + glAccountName: getCellValue("GL계정명") || null, + targetUnitPrice: cleanNumber(targetUnitPrice) || null, + targetAmount: getCellValue("내정금액") + ? cleanNumber(getCellValue("내정금액")) + : null, + targetCurrency: getCellValue("내정통화") || "KRW", + budgetAmount: getCellValue("예산금액") + ? cleanNumber(getCellValue("예산금액")) + : null, + budgetCurrency: getCellValue("예산통화") || "KRW", + actualAmount: getCellValue("실적금액") + ? cleanNumber(getCellValue("실적금액")) + : null, + actualCurrency: getCellValue("실적통화") || "KRW", + } + + items.push(item) + } + + return { + success: errors.length === 0, + items, + errors, + } + } catch (error) { + console.error("Excel import error:", error) + return { + success: false, + items: [], + errors: [ + error instanceof Error + ? error.message + : "Excel 파일 파싱 중 오류가 발생했습니다.", + ], + } + } +} + diff --git a/lib/bidding/manage/project-utils.ts b/lib/bidding/manage/project-utils.ts new file mode 100644 index 00000000..92744695 --- /dev/null +++ b/lib/bidding/manage/project-utils.ts @@ -0,0 +1,87 @@ +'use server' + +import db from '@/db/db' +import { projects } from '@/db/schema' +import { eq, and, inArray } from 'drizzle-orm' + +/** + * 프로젝트 ID로 프로젝트 코드 조회 + */ +export async function getProjectCodeById(projectId: number): Promise<string | null> { + try { + const result = await db + .select({ code: projects.code }) + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1) + + return result[0]?.code || null + } catch (error) { + console.error('Failed to get project code by id:', error) + return null + } +} + +/** + * 프로젝트 코드와 이름으로 프로젝트 ID 조회 + */ +export async function getProjectIdByCodeAndName( + projectCode: string, + projectName: string +): Promise<number | null> { + try { + if (!projectCode || !projectName) { + return null + } + + const result = await db + .select({ id: projects.id }) + .from(projects) + .where( + and( + eq(projects.code, projectCode.trim()), + eq(projects.name, projectName.trim()) + ) + ) + .limit(1) + + return result[0]?.id || null + } catch (error) { + console.error('Failed to get project id by code and name:', error) + return null + } +} + +/** + * 여러 프로젝트 ID로 프로젝트 코드 맵 조회 (성능 최적화) + */ +export async function getProjectCodesByIds( + projectIds: number[] +): Promise<Map<number, string>> { + try { + if (projectIds.length === 0) { + return new Map() + } + + const uniqueIds = [...new Set(projectIds.filter(id => id != null))] + if (uniqueIds.length === 0) { + return new Map() + } + + const result = await db + .select({ id: projects.id, code: projects.code }) + .from(projects) + .where(inArray(projects.id, uniqueIds)) + + const map = new Map<number, string>() + result.forEach((project) => { + map.set(project.id, project.code) + }) + + return map + } catch (error) { + console.error('Failed to get project codes by ids:', error) + return new Map() + } +} + diff --git a/lib/bidding/selection/actions.ts b/lib/bidding/selection/actions.ts index f19fbe6d..91550960 100644 --- a/lib/bidding/selection/actions.ts +++ b/lib/bidding/selection/actions.ts @@ -131,6 +131,75 @@ export async function saveSelectionResult(data: SaveSelectionResultData) { } } +// 선정결과 조회 +export async function getSelectionResult(biddingId: number) { + try { + // 선정결과 조회 (selectedCompanyId가 null인 레코드) + const allResults = await db + .select() + .from(vendorSelectionResults) + .where(eq(vendorSelectionResults.biddingId, biddingId)) + + // @ts-ignore + const existingResult = allResults.filter((result: any) => result.selectedCompanyId === null).slice(0, 1) + + if (existingResult.length === 0) { + return { + success: true, + data: { + summary: '', + attachments: [] + } + } + } + + const result = existingResult[0] + + // 첨부파일 조회 + const documents = await db + .select({ + id: biddingDocuments.id, + fileName: biddingDocuments.fileName, + originalFileName: biddingDocuments.originalFileName, + fileSize: biddingDocuments.fileSize, + mimeType: biddingDocuments.mimeType, + filePath: biddingDocuments.filePath, + uploadedAt: biddingDocuments.uploadedAt + }) + .from(biddingDocuments) + .where(and( + eq(biddingDocuments.biddingId, biddingId), + eq(biddingDocuments.documentType, 'selection_result') + )) + + return { + success: true, + data: { + summary: result.evaluationSummary || '', + attachments: documents.map(doc => ({ + id: doc.id, + fileName: doc.fileName || doc.originalFileName || '', + originalFileName: doc.originalFileName || '', + fileSize: doc.fileSize || 0, + mimeType: doc.mimeType || '', + filePath: doc.filePath || '', + uploadedAt: doc.uploadedAt + })) + } + } + } catch (error) { + console.error('Failed to get selection result:', error) + return { + success: false, + error: '선정결과 조회 중 오류가 발생했습니다.', + data: { + summary: '', + attachments: [] + } + } + } +} + // 견적 히스토리 조회 export async function getQuotationHistory(biddingId: number, vendorId: number) { try { diff --git a/lib/bidding/selection/bidding-info-card.tsx b/lib/bidding/selection/bidding-info-card.tsx index 8864e7db..b363f538 100644 --- a/lib/bidding/selection/bidding-info-card.tsx +++ b/lib/bidding/selection/bidding-info-card.tsx @@ -56,7 +56,7 @@ export function BiddingInfoCard({ bidding }: BiddingInfoCardProps) { 입찰유형 </label> <div className="text-sm font-medium"> - {bidding.isPublic ? '공개입찰' : '비공개입찰'} + {bidding.biddingType} </div> </div> diff --git a/lib/bidding/selection/bidding-item-table.tsx b/lib/bidding/selection/bidding-item-table.tsx new file mode 100644 index 00000000..c101f7e7 --- /dev/null +++ b/lib/bidding/selection/bidding-item-table.tsx @@ -0,0 +1,192 @@ +'use client' + +import * as React from 'react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + getPRItemsForBidding, + getVendorPricesForBidding +} from '@/lib/bidding/detail/service' +import { formatNumber } from '@/lib/utils' +import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' + +interface BiddingItemTableProps { + biddingId: number +} + +export function BiddingItemTable({ biddingId }: BiddingItemTableProps) { + const [data, setData] = React.useState<{ + prItems: any[] + vendorPrices: any[] + }>({ prItems: [], vendorPrices: [] }) + const [loading, setLoading] = React.useState(true) + + React.useEffect(() => { + 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 }) + } catch (error) { + console.error('Failed to load bidding items:', error) + } finally { + setLoading(false) + } + } + + loadData() + }, [biddingId]) + + if (loading) { + return ( + <Card> + <CardHeader> + <CardTitle>응찰품목</CardTitle> + </CardHeader> + <CardContent> + <div className="flex items-center justify-center py-8"> + <div className="text-sm text-muted-foreground">로딩 중...</div> + </div> + </CardContent> + </Card> + ) + } + + 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 ( + <Card> + <CardHeader> + <CardTitle>응찰품목</CardTitle> + </CardHeader> + <CardContent> + <ScrollArea className="w-full whitespace-nowrap rounded-md border"> + <div className="w-max min-w-full"> + <table className="w-full caption-bottom text-sm"> + <thead className="[&_tr]:border-b"> + {/* Header Row 1: Base Info + Vendor Groups */} + <tr className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted"> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>자재번호</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>자재내역</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>자재내역상세</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>구매단위</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>수량</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>단위</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>총중량</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>중량단위</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>내정단가</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>내정액</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>통화</th> + + {vendorPrices.map((vendor) => ( + <th key={vendor.companyId} colSpan={4} className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r bg-muted/20"> + {vendor.companyName} + </th> + ))} + </tr> + {/* Header Row 2: Vendor Sub-columns */} + <tr className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted"> + {vendorPrices.map((vendor) => ( + <React.Fragment key={vendor.companyId}> + <th className="h-10 px-2 text-center align-middle font-medium text-muted-foreground border-r bg-muted/10">단가</th> + <th className="h-10 px-2 text-center align-middle font-medium text-muted-foreground border-r bg-muted/10">총액</th> + <th className="h-10 px-2 text-center align-middle font-medium text-muted-foreground border-r bg-muted/10">통화</th> + <th className="h-10 px-2 text-center align-middle font-medium text-muted-foreground border-r bg-muted/10">내정액(%)</th> + </React.Fragment> + ))} + </tr> + </thead> + <tbody className="[&_tr:last-child]:border-0"> + {/* Summary Row */} + <tr className="border-b transition-colors hover:bg-muted/50 bg-muted/30 font-semibold"> + <td className="p-4 align-middle text-center border-r" colSpan={4}>합계</td> + <td className="p-4 align-middle text-right border-r">{formatNumber(totalQuantity)}</td> + <td className="p-4 align-middle text-center border-r">-</td> + <td className="p-4 align-middle text-right border-r">{formatNumber(totalWeight)}</td> + <td className="p-4 align-middle text-center border-r">-</td> + <td className="p-4 align-middle text-center border-r">-</td> + <td className="p-4 align-middle text-right border-r">{formatNumber(totalTargetAmount)}</td> + <td className="p-4 align-middle text-center border-r">KRW</td> + + {vendorPrices.map((vendor) => { + const vTotal = vendorTotals.find(t => t.companyId === vendor.companyId)?.totalAmount || 0 + const ratio = totalTargetAmount > 0 ? (vTotal / totalTargetAmount) * 100 : 0 + return ( + <React.Fragment key={vendor.companyId}> + <td className="p-4 align-middle text-center border-r">-</td> + <td className="p-4 align-middle text-right border-r">{formatNumber(vTotal)}</td> + <td className="p-4 align-middle text-center border-r">{vendor.currency}</td> + <td className="p-4 align-middle text-right border-r">{formatNumber(ratio, 0)}%</td> + </React.Fragment> + ) + })} + </tr> + + {/* Data Rows */} + {prItems.map((item) => ( + <tr key={item.id} className="border-b transition-colors hover:bg-muted/50"> + <td className="p-4 align-middle border-r">{item.materialNumber}</td> + <td className="p-4 align-middle border-r min-w-[150px]">{item.materialInfo}</td> + <td className="p-4 align-middle border-r min-w-[150px]">{item.specification}</td> + <td className="p-4 align-middle text-center border-r">{item.purchaseUnit}</td> + <td className="p-4 align-middle text-right border-r">{formatNumber(item.quantity)}</td> + <td className="p-4 align-middle text-center border-r">{item.quantityUnit}</td> + <td className="p-4 align-middle text-right border-r">{formatNumber(item.totalWeight)}</td> + <td className="p-4 align-middle text-center border-r">{item.weightUnit}</td> + <td className="p-4 align-middle text-right border-r">{formatNumber(item.targetUnitPrice)}</td> + <td className="p-4 align-middle text-right border-r">{formatNumber(item.targetAmount)}</td> + <td className="p-4 align-middle text-center border-r">{item.currency}</td> + + {vendorPrices.map((vendor) => { + const bidItem = vendor.itemPrices.find((p: any) => p.prItemId === item.id) + const bidAmount = bidItem ? bidItem.amount : 0 + const targetAmt = Number(item.targetAmount || 0) + const ratio = targetAmt > 0 && bidAmount > 0 ? (bidAmount / targetAmt) * 100 : 0 + + return ( + <React.Fragment key={vendor.companyId}> + <td className="p-4 align-middle text-right border-r bg-muted/5"> + {bidItem ? formatNumber(bidItem.unitPrice) : '-'} + </td> + <td className="p-4 align-middle text-right border-r bg-muted/5"> + {bidItem ? formatNumber(bidItem.amount) : '-'} + </td> + <td className="p-4 align-middle text-center border-r bg-muted/5"> + {vendor.currency} + </td> + <td className="p-4 align-middle text-right border-r bg-muted/5"> + {bidItem && ratio > 0 ? `${formatNumber(ratio, 0)}%` : '-'} + </td> + </React.Fragment> + ) + })} + </tr> + ))} + </tbody> + </table> + </div> + <ScrollBar orientation="horizontal" /> + </ScrollArea> + </CardContent> + </Card> + ) +} + diff --git a/lib/bidding/selection/bidding-selection-detail-content.tsx b/lib/bidding/selection/bidding-selection-detail-content.tsx index 45d5d402..887498dc 100644 --- a/lib/bidding/selection/bidding-selection-detail-content.tsx +++ b/lib/bidding/selection/bidding-selection-detail-content.tsx @@ -5,6 +5,7 @@ import { Bidding } from '@/db/schema' import { BiddingInfoCard } from './bidding-info-card' import { SelectionResultForm } from './selection-result-form' import { VendorSelectionTable } from './vendor-selection-table' +import { BiddingItemTable } from './bidding-item-table' interface BiddingSelectionDetailContentProps { biddingId: number @@ -17,6 +18,9 @@ export function BiddingSelectionDetailContent({ }: BiddingSelectionDetailContentProps) { const [refreshKey, setRefreshKey] = React.useState(0) + // 입찰평가중 상태가 아니면 읽기 전용 + const isReadOnly = bidding.status !== 'evaluation_of_bidding' + const handleRefresh = React.useCallback(() => { setRefreshKey(prev => prev + 1) }, []) @@ -27,7 +31,7 @@ export function BiddingSelectionDetailContent({ <BiddingInfoCard bidding={bidding} /> {/* 선정결과 폼 */} - <SelectionResultForm biddingId={biddingId} onSuccess={handleRefresh} /> + <SelectionResultForm biddingId={biddingId} onSuccess={handleRefresh} readOnly={isReadOnly} /> {/* 업체선정 테이블 */} <VendorSelectionTable @@ -35,7 +39,12 @@ export function BiddingSelectionDetailContent({ biddingId={biddingId} bidding={bidding} onRefresh={handleRefresh} + readOnly={isReadOnly} /> + + {/* 응찰품목 테이블 */} + <BiddingItemTable biddingId={biddingId} /> + </div> ) } diff --git a/lib/bidding/selection/biddings-selection-table.tsx b/lib/bidding/selection/biddings-selection-table.tsx index c3990e7b..41225531 100644 --- a/lib/bidding/selection/biddings-selection-table.tsx +++ b/lib/bidding/selection/biddings-selection-table.tsx @@ -84,13 +84,13 @@ export function BiddingsSelectionTable({ promises }: BiddingsSelectionTableProps switch (rowAction.type) {
case "view":
// 상세 페이지로 이동
- // 입찰평가중일때만 상세보기 가능
- if (rowAction.row.original.status === 'evaluation_of_bidding') {
+ // 입찰평가중, 업체선정, 차수증가, 재입찰 상태일 때 상세보기 가능
+ if (['evaluation_of_bidding', 'vendor_selected', 'round_increase', 'rebidding'].includes(rowAction.row.original.status)) {
router.push(`/evcp/bid-selection/${rowAction.row.original.id}/detail`)
} else {
toast({
title: '접근 제한',
- description: '입찰평가중이 아닙니다.',
+ description: '상세보기가 불가능한 상태입니다.',
variant: 'destructive',
})
}
diff --git a/lib/bidding/selection/selection-result-form.tsx b/lib/bidding/selection/selection-result-form.tsx index 54687cc9..af6b8d43 100644 --- a/lib/bidding/selection/selection-result-form.tsx +++ b/lib/bidding/selection/selection-result-form.tsx @@ -9,8 +9,8 @@ import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' import { useToast } from '@/hooks/use-toast' -import { saveSelectionResult } from './actions' -import { Loader2, Save, FileText } from 'lucide-react' +import { saveSelectionResult, getSelectionResult } from './actions' +import { Loader2, Save, FileText, Download, X } from 'lucide-react' import { Dropzone, DropzoneZone, DropzoneUploadIcon, DropzoneTitle, DropzoneDescription, DropzoneInput } from '@/components/ui/dropzone' const selectionResultSchema = z.object({ @@ -22,12 +22,25 @@ type SelectionResultFormData = z.infer<typeof selectionResultSchema> interface SelectionResultFormProps { biddingId: number onSuccess: () => void + readOnly?: boolean } -export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFormProps) { +interface AttachmentInfo { + id: number + fileName: string + originalFileName: string + fileSize: number + mimeType: string + filePath: string + uploadedAt: Date | null +} + +export function SelectionResultForm({ biddingId, onSuccess, readOnly = false }: SelectionResultFormProps) { const { toast } = useToast() const [isSubmitting, setIsSubmitting] = React.useState(false) + const [isLoading, setIsLoading] = React.useState(true) const [attachmentFiles, setAttachmentFiles] = React.useState<File[]>([]) + const [existingAttachments, setExistingAttachments] = React.useState<AttachmentInfo[]>([]) const form = useForm<SelectionResultFormData>({ resolver: zodResolver(selectionResultSchema), @@ -36,10 +49,53 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor }, }) + // 기존 선정결과 로드 + React.useEffect(() => { + const loadSelectionResult = async () => { + setIsLoading(true) + try { + const result = await getSelectionResult(biddingId) + if (result.success && result.data) { + form.reset({ + summary: result.data.summary || '', + }) + if (result.data.attachments) { + setExistingAttachments(result.data.attachments) + } + } + } catch (error) { + console.error('Failed to load selection result:', error) + toast({ + title: '로드 실패', + description: '선정결과를 불러오는데 실패했습니다.', + variant: 'destructive', + }) + } finally { + setIsLoading(false) + } + } + + loadSelectionResult() + }, [biddingId, form, toast]) + const removeAttachmentFile = (index: number) => { setAttachmentFiles(prev => prev.filter((_, i) => i !== index)) } + const removeExistingAttachment = (id: number) => { + setExistingAttachments(prev => prev.filter(att => att.id !== id)) + } + + const downloadAttachment = (filePath: string, fileName: string) => { + // 파일 다운로드 (filePath가 절대 경로인 경우) + if (filePath.startsWith('http') || filePath.startsWith('/')) { + window.open(filePath, '_blank') + } else { + // 상대 경로인 경우 + window.open(`/api/files/${filePath}`, '_blank') + } + } + const onSubmit = async (data: SelectionResultFormData) => { setIsSubmitting(true) try { @@ -74,6 +130,22 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor } } + if (isLoading) { + return ( + <Card> + <CardHeader> + <CardTitle>선정결과</CardTitle> + </CardHeader> + <CardContent> + <div className="flex items-center justify-center py-8"> + <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> + <span className="ml-2 text-sm text-muted-foreground">로딩 중...</span> + </div> + </CardContent> + </Card> + ) + } + return ( <Card> <CardHeader> @@ -94,6 +166,7 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor placeholder="선정결과에 대한 요약을 입력해주세요..." className="min-h-[120px]" {...field} + disabled={readOnly} /> </FormControl> <FormMessage /> @@ -104,35 +177,83 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor {/* 첨부파일 */} <div className="space-y-4"> <FormLabel>첨부파일</FormLabel> - <Dropzone - maxSize={10 * 1024 * 1024} // 10MB - onDropAccepted={(files) => { - const newFiles = Array.from(files) - setAttachmentFiles(prev => [...prev, ...newFiles]) - }} - onDropRejected={() => { - toast({ - title: "파일 업로드 거부", - description: "파일 크기 및 형식을 확인해주세요.", - variant: "destructive", - }) - }} - > - <DropzoneZone> - <DropzoneUploadIcon className="mx-auto h-12 w-12 text-muted-foreground" /> - <DropzoneTitle className="text-lg font-medium"> - 파일을 드래그하거나 클릭하여 업로드 - </DropzoneTitle> - <DropzoneDescription className="text-sm text-muted-foreground"> - PDF, Word, Excel, 이미지 파일 (최대 10MB) - </DropzoneDescription> - </DropzoneZone> - <DropzoneInput /> - </Dropzone> + + {/* 기존 첨부파일 */} + {existingAttachments.length > 0 && ( + <div className="space-y-2"> + <h4 className="text-sm font-medium">기존 첨부파일</h4> + <div className="space-y-2"> + {existingAttachments.map((attachment) => ( + <div + key={attachment.id} + className="flex items-center justify-between p-3 bg-muted rounded-lg" + > + <div className="flex items-center gap-3"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <div> + <p className="text-sm font-medium">{attachment.originalFileName || attachment.fileName}</p> + <p className="text-xs text-muted-foreground"> + {(attachment.fileSize / 1024 / 1024).toFixed(2)} MB + </p> + </div> + </div> + <div className="flex items-center gap-2"> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => downloadAttachment(attachment.filePath, attachment.originalFileName || attachment.fileName)} + > + <Download className="h-4 w-4" /> + </Button> + {!readOnly && ( + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => removeExistingAttachment(attachment.id)} + > + <X className="h-4 w-4" /> + </Button> + )} + </div> + </div> + ))} + </div> + </div> + )} + + {!readOnly && ( + <Dropzone + maxSize={10 * 1024 * 1024} // 10MB + onDropAccepted={(files) => { + const newFiles = Array.from(files) + setAttachmentFiles(prev => [...prev, ...newFiles]) + }} + onDropRejected={() => { + toast({ + title: "파일 업로드 거부", + description: "파일 크기 및 형식을 확인해주세요.", + variant: "destructive", + }) + }} + > + <DropzoneZone> + <DropzoneUploadIcon className="mx-auto h-12 w-12 text-muted-foreground" /> + <DropzoneTitle className="text-lg font-medium"> + 파일을 드래그하거나 클릭하여 업로드 + </DropzoneTitle> + <DropzoneDescription className="text-sm text-muted-foreground"> + PDF, Word, Excel, 이미지 파일 (최대 10MB) + </DropzoneDescription> + </DropzoneZone> + <DropzoneInput /> + </Dropzone> + )} {attachmentFiles.length > 0 && ( <div className="space-y-2"> - <h4 className="text-sm font-medium">업로드된 파일</h4> + <h4 className="text-sm font-medium">새로 추가할 파일</h4> <div className="space-y-2"> {attachmentFiles.map((file, index) => ( <div @@ -148,14 +269,16 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor </p> </div> </div> - <Button - type="button" - variant="ghost" - size="sm" - onClick={() => removeAttachmentFile(index)} - > - 제거 - </Button> + {!readOnly && ( + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => removeAttachmentFile(index)} + > + 제거 + </Button> + )} </div> ))} </div> @@ -164,13 +287,15 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor </div> {/* 저장 버튼 */} - <div className="flex justify-end"> - <Button type="submit" disabled={isSubmitting}> - {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - <Save className="mr-2 h-4 w-4" /> - 저장 - </Button> - </div> + {!readOnly && ( + <div className="flex justify-end"> + <Button type="submit" disabled={isSubmitting}> + {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + <Save className="mr-2 h-4 w-4" /> + 저장 + </Button> + </div> + )} </form> </Form> </CardContent> diff --git a/lib/bidding/selection/vendor-selection-table.tsx b/lib/bidding/selection/vendor-selection-table.tsx index 8570b5b6..40f13ec1 100644 --- a/lib/bidding/selection/vendor-selection-table.tsx +++ b/lib/bidding/selection/vendor-selection-table.tsx @@ -10,9 +10,10 @@ interface VendorSelectionTableProps { biddingId: number bidding: Bidding onRefresh: () => void + readOnly?: boolean } -export function VendorSelectionTable({ biddingId, bidding, onRefresh }: VendorSelectionTableProps) { +export function VendorSelectionTable({ biddingId, bidding, onRefresh, readOnly = false }: VendorSelectionTableProps) { const [vendors, setVendors] = React.useState<any[]>([]) const [loading, setLoading] = React.useState(true) @@ -59,6 +60,7 @@ export function VendorSelectionTable({ biddingId, bidding, onRefresh }: VendorSe vendors={vendors} onRefresh={onRefresh} onOpenSelectionReasonDialog={() => {}} + readOnly={readOnly} /> </CardContent> </Card> diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts index a658ee6a..27dae87d 100644 --- a/lib/bidding/service.ts +++ b/lib/bidding/service.ts @@ -18,6 +18,7 @@ import { vendorContacts, vendors } from '@/db/schema' +import { companyConditionResponses } from '@/db/schema/bidding' import { eq, desc, @@ -2196,7 +2197,7 @@ export async function updateBiddingProjectInfo(biddingId: number) { } // 입찰의 PR 아이템 금액 합산하여 bidding 업데이트 -async function updateBiddingAmounts(biddingId: number) { +export async function updateBiddingAmounts(biddingId: number) { try { // 해당 bidding의 모든 PR 아이템들의 금액 합계 계산 const amounts = await db @@ -2214,9 +2215,9 @@ async function updateBiddingAmounts(biddingId: number) { await db .update(biddings) .set({ - targetPrice: totalTargetAmount, - budget: totalBudgetAmount, - finalBidPrice: totalActualAmount, + targetPrice: String(totalTargetAmount), + budget: String(totalBudgetAmount), + finalBidPrice: String(totalActualAmount), updatedAt: new Date() }) .where(eq(biddings.id, biddingId)) @@ -2511,6 +2512,119 @@ export async function deleteBiddingCompanyContact(contactId: number) { } } +// 입찰담당자별 입찰 업체 조회 +export async function getBiddingCompaniesByBidPicId(bidPicId: number) { + try { + const companies = await db + .select({ + biddingId: biddings.id, + biddingNumber: biddings.biddingNumber, + biddingTitle: biddings.title, + companyId: biddingCompanies.companyId, + vendorCode: vendors.vendorCode, + vendorName: vendors.vendorName, + updatedAt: biddings.updatedAt, + }) + .from(biddings) + .innerJoin(biddingCompanies, eq(biddings.id, biddingCompanies.biddingId)) + .innerJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) + .where(eq(biddings.bidPicId, bidPicId)) + .orderBy(desc(biddings.updatedAt)) + + return { + success: true, + data: companies + } + } catch (error) { + console.error('Failed to get bidding companies by bidPicId:', error) + return { + success: false, + error: '입찰 업체 조회에 실패했습니다.', + data: [] + } + } +} + +// 입찰 업체를 현재 입찰에 추가 (담당자 정보 포함) +export async function addBiddingCompanyFromOtherBidding( + targetBiddingId: number, + sourceBiddingId: number, + companyId: number, + contacts?: Array<{ + contactName: string + contactEmail: string + contactNumber?: string + }> +) { + try { + return await db.transaction(async (tx) => { + // 중복 체크 + const existingCompany = await tx + .select() + .from(biddingCompanies) + .where( + and( + eq(biddingCompanies.biddingId, targetBiddingId), + eq(biddingCompanies.companyId, companyId) + ) + ) + .limit(1) + + if (existingCompany.length > 0) { + return { + success: false, + error: '이미 등록된 업체입니다.' + } + } + + // 1. biddingCompanies 레코드 생성 + const [biddingCompanyResult] = await tx + .insert(biddingCompanies) + .values({ + biddingId: targetBiddingId, + companyId: companyId, + invitationStatus: 'pending', + invitedAt: new Date(), + }) + .returning({ id: biddingCompanies.id }) + + if (!biddingCompanyResult) { + throw new Error('업체 추가에 실패했습니다.') + } + + // 2. 담당자 정보 추가 + if (contacts && contacts.length > 0) { + await tx.insert(biddingCompaniesContacts).values( + contacts.map(contact => ({ + biddingId: targetBiddingId, + vendorId: companyId, + contactName: contact.contactName, + contactEmail: contact.contactEmail, + contactNumber: contact.contactNumber || null, + })) + ) + } + + // 3. company_condition_responses 레코드 생성 + await tx.insert(companyConditionResponses).values({ + biddingCompanyId: biddingCompanyResult.id, + }) + + return { + success: true, + message: '업체가 성공적으로 추가되었습니다.', + data: { id: biddingCompanyResult.id } + } + }) + } catch (error) { + console.error('Failed to add bidding company from other bidding:', error) + return { + success: false, + error: error instanceof Error ? error.message : '업체 추가에 실패했습니다.' + } + } +} + export async function updateBiddingConditions( biddingId: number, updates: { @@ -3145,9 +3259,9 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u } } - revalidatePath('/bidding') - revalidatePath(`/bidding/${biddingId}`) // 기존 입찰 페이지도 갱신 - revalidatePath(`/bidding/${newBidding.id}`) + revalidatePath('/bid-receive') + revalidatePath(`/bid-receive/${biddingId}`) // 기존 입찰 페이지도 갱신 + revalidatePath(`/bid-receive/${newBidding.id}`) return { success: true, @@ -3436,9 +3550,10 @@ export async function getBiddingsForSelection(input: GetBiddingsSchema) { // 'bidding_opened', 'bidding_closed', 'evaluation_of_bidding', 'vendor_selected' 상태만 조회 basicConditions.push( or( - eq(biddings.status, 'bidding_closed'), eq(biddings.status, 'evaluation_of_bidding'), - eq(biddings.status, 'vendor_selected') + eq(biddings.status, 'vendor_selected'), + eq(biddings.status, 'round_increase'), + eq(biddings.status, 'rebidding'), )! ) diff --git a/lib/bidding/vendor/components/pr-items-pricing-table.tsx b/lib/bidding/vendor/components/pr-items-pricing-table.tsx index 7dd8384e..5afb2b67 100644 --- a/lib/bidding/vendor/components/pr-items-pricing-table.tsx +++ b/lib/bidding/vendor/components/pr-items-pricing-table.tsx @@ -382,18 +382,14 @@ export function PrItemsPricingTable({ </span> ) : ( <Input - type="number" - inputMode="decimal" - min={0} - pattern="^(0|[1-9][0-9]*)(\.[0-9]+)?$" - value={quotation.bidUnitPrice === 0 ? '' : quotation.bidUnitPrice} + type="text" + inputMode="numeric" + value={quotation.bidUnitPrice === 0 ? '' : quotation.bidUnitPrice.toLocaleString()} onChange={(e) => { - let value = e.target.value - if (/^0[0-9]+/.test(value)) { - value = value.replace(/^0+/, '') - if (value === '') value = '0' - } - const numericValue = parseFloat(value) + // 콤마 제거 및 숫자만 허용 + const value = e.target.value.replace(/,/g, '').replace(/[^0-9]/g, '') + const numericValue = Number(value) + updateQuotation( item.id, 'bidUnitPrice', diff --git a/lib/bidding/vendor/export-partners-biddings-to-excel.ts b/lib/bidding/vendor/export-partners-biddings-to-excel.ts new file mode 100644 index 00000000..9e99eeec --- /dev/null +++ b/lib/bidding/vendor/export-partners-biddings-to-excel.ts @@ -0,0 +1,278 @@ +import { type Table } from "@tanstack/react-table" +import ExcelJS from "exceljs" +import { PartnersBiddingListItem } from '../detail/service' +import { + biddingStatusLabels, + contractTypeLabels, +} from "@/db/schema" +import { formatDate } from "@/lib/utils" + +/** + * Partners 입찰 목록을 Excel로 내보내기 + * - 계약구분, 진행상태는 라벨(명칭)로 변환 + * - 입찰기간은 submissionStartDate, submissionEndDate 기준 + * - 날짜는 적절한 형식으로 변환 + */ +export async function exportPartnersBiddingsToExcel( + table: Table<PartnersBiddingListItem>, + { + filename = "협력업체입찰목록", + onlySelected = false, + }: { + filename?: string + onlySelected?: boolean + } = {} +): Promise<void> { + // 테이블에서 실제 사용 중인 leaf columns 가져오기 + const allColumns = table.getAllLeafColumns() + + // select, actions, attachments 컬럼 제외 + const columns = allColumns.filter( + (col) => !["select", "actions", "attachments"].includes(col.id) + ) + + // 헤더 매핑 (컬럼 id -> Excel 헤더명) + const headerMap: Record<string, string> = { + biddingNumber: "입찰 No.", + status: "입찰상태", + isUrgent: "긴급여부", + title: "입찰명", + isAttendingMeeting: "사양설명회", + isBiddingParticipated: "입찰 참여의사", + biddingSubmissionStatus: "입찰 제출여부", + contractType: "계약구분", + submissionStartDate: "입찰기간", + contractStartDate: "계약기간", + bidPicName: "입찰담당자", + supplyPicName: "조달담당자", + updatedAt: "최종수정일", + } + + // 헤더 행 생성 + const headerRow = columns.map((col) => { + return headerMap[col.id] || col.id + }) + + // 데이터 행 생성 + const rowModel = onlySelected + ? table.getFilteredSelectedRowModel() + : table.getRowModel() + + const dataRows = rowModel.rows.map((row) => { + const original = row.original + return columns.map((col) => { + const colId = col.id + let value: any + + // 특별 처리 필요한 컬럼들 + switch (colId) { + case "contractType": + // 계약구분: 라벨로 변환 + value = contractTypeLabels[original.contractType as keyof typeof contractTypeLabels] || original.contractType + break + + case "status": + // 입찰상태: 라벨로 변환 + value = biddingStatusLabels[original.status as keyof typeof biddingStatusLabels] || original.status + break + + case "isUrgent": + // 긴급여부: Yes/No + value = original.isUrgent ? "긴급" : "일반" + break + + case "isAttendingMeeting": + // 사양설명회: 참석/불참/미결정 + if (original.isAttendingMeeting === null) { + value = "해당없음" + } else { + value = original.isAttendingMeeting ? "참석" : "불참" + } + break + + case "isBiddingParticipated": + // 입찰 참여의사: 참여/불참/미결정 + if (original.isBiddingParticipated === null) { + value = "미결정" + } else { + value = original.isBiddingParticipated ? "참여" : "불참" + } + break + + case "biddingSubmissionStatus": + // 입찰 제출여부: 최종제출/제출/미제출 + const finalQuoteAmount = original.finalQuoteAmount + const isFinalSubmission = original.isFinalSubmission + + if (!finalQuoteAmount) { + value = "미제출" + } else if (isFinalSubmission) { + value = "최종제출" + } else { + value = "제출" + } + break + + case "submissionStartDate": + // 입찰기간: submissionStartDate, submissionEndDate 기준 + const startDate = original.submissionStartDate + const endDate = original.submissionEndDate + + if (!startDate || !endDate) { + value = "-" + } else { + 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', ' ') + } + + value = `${formatKst(startObj)} ~ ${formatKst(endObj)}` + } + break + + // case "preQuoteDeadline": + // // 사전견적 마감일: 날짜 형식 + // if (!original.preQuoteDeadline) { + // value = "-" + // } else { + // const deadline = new Date(original.preQuoteDeadline) + // value = deadline.toISOString().slice(0, 16).replace('T', ' ') + // } + // break + + case "contractStartDate": + // 계약기간: contractStartDate, contractEndDate 기준 + const contractStart = original.contractStartDate + const contractEnd = original.contractEndDate + + if (!contractStart || !contractEnd) { + value = "-" + } else { + const startObj = new Date(contractStart) + const endObj = new Date(contractEnd) + value = `${formatDate(startObj, "KR")} ~ ${formatDate(endObj, "KR")}` + } + break + + case "bidPicName": + // 입찰담당자: bidPicName + value = original.bidPicName || "-" + break + + case "supplyPicName": + // 조달담당자: supplyPicName + value = original.supplyPicName || "-" + break + + case "updatedAt": + // 최종수정일: 날짜 시간 형식 + if (original.updatedAt) { + const updated = new Date(original.updatedAt) + value = updated.toISOString().slice(0, 16).replace('T', ' ') + } else { + value = "-" + } + break + + case "biddingNumber": + // 입찰번호: 원입찰번호 포함 + const biddingNumber = original.biddingNumber + const originalBiddingNumber = original.originalBiddingNumber + if (originalBiddingNumber) { + value = `${biddingNumber} (원: ${originalBiddingNumber})` + } else { + value = biddingNumber + } + break + + default: + // 기본값: row.getValue 사용 + value = row.getValue(colId) + + // null/undefined 처리 + if (value == null) { + value = "" + } + + // 객체인 경우 JSON 문자열로 변환 + if (typeof value === "object") { + value = JSON.stringify(value) + } + break + } + + return value + }) + }) + + // 최종 sheetData + const sheetData = [headerRow, ...dataRows] + + // ExcelJS로 파일 생성 및 다운로드 + await createAndDownloadExcel(sheetData, columns.length, filename) +} + +/** + * Excel 파일 생성 및 다운로드 + */ +async function createAndDownloadExcel( + sheetData: any[][], + columnCount: number, + filename: string +): Promise<void> { + // ExcelJS 워크북/시트 생성 + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("Sheet1") + + // 칼럼별 최대 길이 추적 + const maxColumnLengths = Array(columnCount).fill(0) + sheetData.forEach((row) => { + row.forEach((cellValue, colIdx) => { + const cellText = cellValue?.toString() ?? "" + if (cellText.length > maxColumnLengths[colIdx]) { + maxColumnLengths[colIdx] = cellText.length + } + }) + }) + + // 시트에 데이터 추가 + 헤더 스타일 + sheetData.forEach((arr, idx) => { + const row = worksheet.addRow(arr) + + // 헤더 스타일 적용 (첫 번째 행) + if (idx === 0) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + } + }) + } + }) + + // 칼럼 너비 자동 조정 + maxColumnLengths.forEach((len, idx) => { + // 최소 너비 10, +2 여백 + worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10) + }) + + // 최종 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `${filename}.xlsx` + link.click() + URL.revokeObjectURL(url) +} + diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx index a122e87b..6276d1b7 100644 --- a/lib/bidding/vendor/partners-bidding-list-columns.tsx +++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx @@ -285,7 +285,7 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL cell: ({ row }) => { const isAttending = row.original.isAttendingMeeting if (isAttending === null) { - return <div className="text-muted-foreground text-center">-</div> + return <div className="text-muted-foreground text-center">해당없음</div> } return isAttending ? ( <CheckCircle className="h-5 w-5 text-green-600 mx-auto" /> @@ -366,31 +366,31 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL }), // 사전견적 마감일 - columnHelper.accessor('preQuoteDeadline', { - header: '사전견적 마감일', - cell: ({ row }) => { - const deadline = row.original.preQuoteDeadline - if (!deadline) { - return <div className="text-muted-foreground">-</div> - } + // columnHelper.accessor('preQuoteDeadline', { + // header: '사전견적 마감일', + // cell: ({ row }) => { + // const deadline = row.original.preQuoteDeadline + // if (!deadline) { + // return <div className="text-muted-foreground">-</div> + // } - const now = new Date() - const deadlineDate = new Date(deadline) - const isExpired = deadlineDate < now + // const now = new Date() + // const deadlineDate = new Date(deadline) + // const isExpired = deadlineDate < now - return ( - <div className={`text-sm flex items-center gap-1 ${isExpired ? 'text-red-600' : ''}`}> - <Calendar className="w-4 h-4" /> - <span>{format(new Date(deadline), "yyyy-MM-dd HH:mm")}</span> - {isExpired && ( - <Badge variant="destructive" className="text-xs"> - 마감 - </Badge> - )} - </div> - ) - }, - }), + // return ( + // <div className={`text-sm flex items-center gap-1 ${isExpired ? 'text-red-600' : ''}`}> + // <Calendar className="w-4 h-4" /> + // <span>{format(new Date(deadline), "yyyy-MM-dd HH:mm")}</span> + // {isExpired && ( + // <Badge variant="destructive" className="text-xs"> + // 마감 + // </Badge> + // )} + // </div> + // ) + // }, + // }), // 계약기간 columnHelper.accessor('contractStartDate', { diff --git a/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx b/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx index 87b1367e..9a2f026c 100644 --- a/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx +++ b/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx @@ -2,10 +2,12 @@ import * as React from "react" import { type Table } from "@tanstack/react-table" -import { Users} from "lucide-react" +import { Users, FileSpreadsheet } from "lucide-react" +import { toast } from "sonner" import { Button } from "@/components/ui/button" import { PartnersBiddingListItem } from '../detail/service' +import { exportPartnersBiddingsToExcel } from './export-partners-biddings-to-excel' interface PartnersBiddingToolbarActionsProps { table: Table<PartnersBiddingListItem> @@ -20,6 +22,8 @@ export function PartnersBiddingToolbarActions({ const selectedRows = table.getFilteredSelectedRowModel().rows const selectedBidding = selectedRows.length === 1 ? selectedRows[0].original : null + const [isExporting, setIsExporting] = React.useState(false) + const handleSpecificationMeetingClick = () => { if (selectedBidding && setRowAction) { setRowAction({ @@ -29,8 +33,36 @@ export function PartnersBiddingToolbarActions({ } } + // Excel 내보내기 핸들러 + const handleExport = React.useCallback(async () => { + try { + setIsExporting(true) + await exportPartnersBiddingsToExcel(table, { + filename: "협력업체입찰목록", + onlySelected: false, + }) + toast.success("Excel 파일이 다운로드되었습니다.") + } catch (error) { + console.error("Excel export error:", error) + toast.error("Excel 내보내기 중 오류가 발생했습니다.") + } finally { + setIsExporting(false) + } + }, [table]) + return ( <div className="flex items-center gap-2"> + {/* Excel 내보내기 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleExport} + disabled={isExporting} + className="gap-2" + > + <FileSpreadsheet className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">{isExporting ? "내보내는 중..." : "Excel 내보내기"}</span> + </Button> <Button variant="outline" size="sm" 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) { <div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="paymentDelivery" className="text-xs">지급조건 *</Label>
- <Select value={formData.paymentDelivery} onValueChange={(value) => setFormData(prev => ({ ...prev, paymentDelivery: value }))}>
- <SelectTrigger className={`h-8 text-xs ${errors.paymentDelivery ? 'border-red-500' : ''}`}>
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {paymentTermsOptions.map((term) => (
- <SelectItem key={term.code} value={term.code} className="text-xs">
- {term.code}
- </SelectItem>
- ))}
- <SelectItem value="납품완료일로부터 60일 이내 지급" className="text-xs">60일 이내</SelectItem>
- <SelectItem value="추가조건" className="text-xs">추가조건</SelectItem>
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.paymentDelivery && "text-muted-foreground",
+ errors.paymentDelivery && "border-red-500"
+ )}
+ >
+ {formData.paymentDelivery
+ ? (() => {
+ // 1. paymentTermsOptions에서 찾기
+ const foundOption = paymentTermsOptions.find((option) => option.code === formData.paymentDelivery)
+ if (foundOption) {
+ return `${foundOption.code} ${foundOption.description ? `(${foundOption.description})` : ''}`
+ }
+ // 2. 특수 케이스 처리
+ if (formData.paymentDelivery === '납품완료일로부터 60일 이내 지급') {
+ return '60일 이내'
+ }
+ if (formData.paymentDelivery === '추가조건') {
+ return '추가조건'
+ }
+ // 3. 그 외의 경우 원본 값 표시 (로드된 값이지만 옵션에 없는 경우)
+ return formData.paymentDelivery
+ })()
+ : "지급조건 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="지급조건 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {paymentTermsOptions.map((option) => (
+ <CommandItem
+ key={option.code}
+ value={`${option.code} ${option.description || ''}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, paymentDelivery: option.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ option.code === formData.paymentDelivery
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {option.code} {option.description && `(${option.description})`}
+ </CommandItem>
+ ))}
+ <CommandItem
+ value="납품완료일로부터 60일 이내 지급"
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, paymentDelivery: '납품완료일로부터 60일 이내 지급' }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ formData.paymentDelivery === '납품완료일로부터 60일 이내 지급'
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ 60일 이내
+ </CommandItem>
+ <CommandItem
+ value="추가조건"
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, paymentDelivery: '추가조건' }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ formData.paymentDelivery === '추가조건'
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ 추가조건
+ </CommandItem>
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
{formData.paymentDelivery === '추가조건' && (
<Input
type="text"
@@ -1152,53 +1273,59 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) { </div>
</div>
- {/* 지불조건 -> 세금조건 (지불조건 삭제됨) */}
+ {/*세금조건*/}
<div className="space-y-2">
<Label className="text-sm font-medium">세금조건</Label>
<div className="space-y-2">
- {/* 지불조건 필드 삭제됨
- <div className="space-y-1">
- <Label htmlFor="paymentTerm" className="text-xs">지불조건 *</Label>
- <Select
- value={formData.paymentTerm}
- onValueChange={(value) => setFormData(prev => ({ ...prev, paymentTerm: value }))}
- >
- <SelectTrigger className={`h-8 text-xs ${errors.paymentTerm ? 'border-red-500' : ''}`}>
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {paymentTermsOptions.length > 0 ? (
- paymentTermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code} className="text-xs">
- {option.code}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled className="text-xs">
- 로딩중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
- */}
<div className="space-y-1">
<Label htmlFor="taxType" className="text-xs">세금조건 *</Label>
- <Select
- value={formData.taxType}
- onValueChange={(value) => setFormData(prev => ({ ...prev, taxType: value }))}
- >
- <SelectTrigger className={`h-8 text-xs ${errors.taxType ? 'border-red-500' : ''}`}>
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {TAX_CONDITIONS.map((condition) => (
- <SelectItem key={condition.code} value={condition.code} className="text-xs">
- {condition.name}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.taxType && "text-muted-foreground",
+ errors.taxType && "border-red-500"
+ )}
+ >
+ {formData.taxType
+ ? TAX_CONDITIONS.find((condition) => condition.code === formData.taxType)?.name
+ : "세금조건 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="세금조건 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {TAX_CONDITIONS.map((condition) => (
+ <CommandItem
+ key={condition.code}
+ value={`${condition.code} ${condition.name}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, taxType: condition.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ condition.code === formData.taxType
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {condition.name}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
</div>
</div>
@@ -1266,79 +1393,178 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) { {/* 인도조건 */}
<div className="space-y-2">
<Label htmlFor="deliveryTerm" className="text-xs">인도조건</Label>
- <Select
- value={formData.deliveryTerm}
- onValueChange={(value) => setFormData(prev => ({ ...prev, deliveryTerm: value }))}
- >
- <SelectTrigger className="h-8 text-xs">
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {incotermsOptions.length > 0 ? (
- incotermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code} className="text-xs">
- {option.code}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled className="text-xs">
- 로딩중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.deliveryTerm && "text-muted-foreground"
+ )}
+ >
+ {formData.deliveryTerm
+ ? incotermsOptions.find((option) => option.code === formData.deliveryTerm)
+ ? `${incotermsOptions.find((option) => option.code === formData.deliveryTerm)?.code} ${incotermsOptions.find((option) => option.code === formData.deliveryTerm)?.description ? `(${incotermsOptions.find((option) => option.code === formData.deliveryTerm)?.description})` : ''}`
+ : formData.deliveryTerm
+ : "인코텀즈 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="인코텀즈 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {incotermsOptions.length > 0 ? (
+ incotermsOptions.map((option) => (
+ <CommandItem
+ key={option.code}
+ value={`${option.code} ${option.description || ''}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, deliveryTerm: option.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ option.code === formData.deliveryTerm
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {option.code} {option.description && `(${option.description})`}
+ </CommandItem>
+ ))
+ ) : (
+ <CommandItem value="loading" disabled>
+ 로딩중...
+ </CommandItem>
+ )}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
{/* 선적지 */}
<div className="space-y-2">
<Label htmlFor="shippingLocation" className="text-xs">선적지</Label>
- <Select
- value={formData.shippingLocation}
- onValueChange={(value) => setFormData(prev => ({ ...prev, shippingLocation: value }))}
- >
- <SelectTrigger className="h-8 text-xs">
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {shippingPlaces.length > 0 ? (
- shippingPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code} className="text-xs">
- {place.code}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled className="text-xs">
- 로딩중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.shippingLocation && "text-muted-foreground"
+ )}
+ >
+ {formData.shippingLocation
+ ? shippingPlaces.find((place) => place.code === formData.shippingLocation)
+ ? `${shippingPlaces.find((place) => place.code === formData.shippingLocation)?.code} ${shippingPlaces.find((place) => place.code === formData.shippingLocation)?.description ? `(${shippingPlaces.find((place) => place.code === formData.shippingLocation)?.description})` : ''}`
+ : formData.shippingLocation
+ : "선적지 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="선적지 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {shippingPlaces.length > 0 ? (
+ shippingPlaces.map((place) => (
+ <CommandItem
+ key={place.code}
+ value={`${place.code} ${place.description || ''}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, shippingLocation: place.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ place.code === formData.shippingLocation
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {place.code} {place.description && `(${place.description})`}
+ </CommandItem>
+ ))
+ ) : (
+ <CommandItem value="loading" disabled>
+ 로딩중...
+ </CommandItem>
+ )}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
{/* 하역지 */}
<div className="space-y-2">
<Label htmlFor="dischargeLocation" className="text-xs">하역지</Label>
- <Select
- value={formData.dischargeLocation}
- onValueChange={(value) => setFormData(prev => ({ ...prev, dischargeLocation: value }))}
- >
- <SelectTrigger className="h-8 text-xs">
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {destinationPlaces.length > 0 ? (
- destinationPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code} className="text-xs">
- {place.code}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled className="text-xs">
- 로딩중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.dischargeLocation && "text-muted-foreground"
+ )}
+ >
+ {formData.dischargeLocation
+ ? destinationPlaces.find((place) => place.code === formData.dischargeLocation)
+ ? `${destinationPlaces.find((place) => place.code === formData.dischargeLocation)?.code} ${destinationPlaces.find((place) => place.code === formData.dischargeLocation)?.description ? `(${destinationPlaces.find((place) => place.code === formData.dischargeLocation)?.description})` : ''}`
+ : formData.dischargeLocation
+ : "하역지 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="하역지 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {destinationPlaces.length > 0 ? (
+ destinationPlaces.map((place) => (
+ <CommandItem
+ key={place.code}
+ value={`${place.code} ${place.description || ''}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, dischargeLocation: place.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ place.code === formData.dischargeLocation
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {place.code} {place.description && `(${place.description})`}
+ </CommandItem>
+ ))
+ ) : (
+ <CommandItem value="loading" disabled>
+ 로딩중...
+ </CommandItem>
+ )}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
{/* 계약납기일 */}
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({ <Plus className="w-4 h-4" /> 행 추가 </Button> + <ProcurementItemSelectorDialogSingle + triggerLabel="1회성 품목 추가" + triggerVariant="outline" + triggerSize="sm" + selectedProcurementItem={null} + onProcurementItemSelect={handleOneTimeItemSelect} + title="1회성 품목 선택" + description="추가할 1회성 품목을 선택해주세요." + showConfirmButtons={false} + disabled={!isEnabled || readOnly} + /> <Dialog open={showBatchInputDialog} onOpenChange={setShowBatchInputDialog}> <DialogTrigger asChild> <Button diff --git a/lib/general-contracts/service.ts b/lib/general-contracts/service.ts index 3f3dc8de..72b6449f 100644 --- a/lib/general-contracts/service.ts +++ b/lib/general-contracts/service.ts @@ -504,7 +504,7 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u linkedBidNumber,
notes,
paymentBeforeDelivery, // JSON 필드
- paymentDelivery: convertToNumberOrNull(paymentDelivery),
+ paymentDelivery,
paymentAfterDelivery, // JSON 필드
paymentTerm,
taxType,
@@ -525,7 +525,7 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u lastUpdatedAt: new Date(),
lastUpdatedById: userId,
}
-
+ console.log(updateData.paymentDelivery,"updateData.paymentDelivery")
// DB에 업데이트 실행
const [updatedContract] = await db
.update(generalContracts)
@@ -533,14 +533,9 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u .where(eq(generalContracts.id, id))
.returning()
- // 계약명 I/F 로직 (39번 화면으로의 I/F)
- // TODO: 39번 화면의 정확한 API 엔드포인트나 함수명 확인 필요
- // if (data.name) {
- // await syncContractNameToScreen39(id, data.name as string)
- // }
revalidatePath('/general-contracts')
- revalidatePath(`/general-contracts/detail/${id}`)
+ revalidatePath(`/general-contracts/${id}`)
return updatedContract
} catch (error) {
console.error('Error updating contract basic info:', error)
diff --git a/lib/procurement-items/service.ts b/lib/procurement-items/service.ts index b62eb8df..c91959a9 100644 --- a/lib/procurement-items/service.ts +++ b/lib/procurement-items/service.ts @@ -255,8 +255,19 @@ export async function searchProcurementItems(query: string): Promise<{ itemCode: unstable_noStore()
try {
+ // 검색어가 없으면 상위 50개 반환
if (!query || query.trim().length < 1) {
- return []
+ const results = await db
+ .select({
+ itemCode: procurementItems.itemCode,
+ itemName: procurementItems.itemName,
+ })
+ .from(procurementItems)
+ .where(eq(procurementItems.isActive, 'Y'))
+ .limit(50)
+ .orderBy(asc(procurementItems.itemCode))
+
+ return results
}
const searchQuery = `%${query.trim()}%`
@@ -277,7 +288,7 @@ export async function searchProcurementItems(query: string): Promise<{ itemCode: eq(procurementItems.isActive, 'Y')
)
)
- .limit(20)
+ .limit(50)
.orderBy(asc(procurementItems.itemCode))
return results
diff --git a/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts b/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts index 9bdd238d..00873d83 100644 --- a/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts +++ b/lib/soap/ecc/mapper/bidding-and-pr-mapper.ts @@ -32,6 +32,7 @@ import { parseSAPDateToString, findSpecificationByMATNR, } from './common-mapper-utils'; +import { updateBiddingAmounts } from '@/lib/bidding/service'; // Note: POS 파일은 온디맨드 방식으로 다운로드됩니다. // 자동 동기화 관련 import는 제거되었습니다. @@ -497,6 +498,20 @@ export async function mapAndSaveECCBiddingData( }; }); + // 새로 생성된 Bidding들에 대해 금액 집계 업데이트 (PR 아이템의 금액 정보를 Bidding 헤더에 반영) + if (result.insertedBiddings && result.insertedBiddings.length > 0) { + debugLog('Bidding 금액 집계 업데이트 시작', { count: result.insertedBiddings.length }); + await Promise.all( + result.insertedBiddings.map(async (bidding) => { + try { + await updateBiddingAmounts(bidding.id); + } catch (err) { + debugError(`Bidding ${bidding.biddingNumber} 금액 업데이트 실패`, err); + } + }) + ); + } + debugSuccess('ECC Bidding 데이터 일괄 처리 완료', { processedCount: result.processedCount, }); diff --git a/types/table.d.ts b/types/table.d.ts index d4053cf1..9fc96687 100644 --- a/types/table.d.ts +++ b/types/table.d.ts @@ -54,7 +54,7 @@ export type Filter<TData> = Prettify< export interface DataTableRowAction<TData> { row: Row<TData> - type:"add_stage"|"specification_meeting"|"clone"|"viewVariables"|"variableSettings"|"addSubClause"|"createRevision"|"duplicate"|"dispose"|"restore"|"download_report"|"submit" |"general_evaluation"| "general_evaluation"|"esg_evaluation" |"schedule"| "view"| "upload" | "addInfo"| "view-series"|"log"| "tbeResult" | "requestInfo"| "esign-detail"| "responseDetail"|"signature"|"update" | "delete" | "user" | "pemission" | "invite" | "items" | "attachment" |"comments" | "open" | "select" | "files" | "vendor-submission" + type:"add_stage"|"specification_meeting"|"clone"|"viewVariables"|"variableSettings"|"addSubClause"|"createRevision"|"duplicate"|"dispose"|"restore"|"download_report"|"submit" |"general_evaluation"| "general_evaluation"|"esg_evaluation" |"schedule"| "view"| "upload" | "addInfo"| "view-series"|"log"| "tbeResult" | "requestInfo"| "esign-detail"| "responseDetail"|"signature"|"update" | "delete" | "user" | "pemission" | "invite" | "items" | "attachment" |"comments" | "open" | "select" | "files" | "vendor-submission" | "resend" } export interface QueryBuilderOpts { |
