diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-01 06:26:44 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-01 06:26:44 +0000 |
| commit | d689608ca2a54cab2cd12a12f0b6007a1be39ab2 (patch) | |
| tree | bb93e630c18b3028322f7f7aee87547e893f5df7 | |
| parent | 7021eca8f53e398f55f775c6dc431bca9670fabe (diff) | |
(대표님, 최겸) 구매 견적 첨부파일 type 오류 수정, 문서확정, short list 기능 수정
| -rw-r--r-- | app/api/partners/rfq-last/[id]/response/route.ts | 104 | ||||
| -rw-r--r-- | db/schema/rfqVendor.ts | 2 | ||||
| -rw-r--r-- | lib/rfq-last/attachment/vendor-response-table.tsx | 296 | ||||
| -rw-r--r-- | lib/rfq-last/service.ts | 188 | ||||
| -rw-r--r-- | lib/rfq-last/vendor-response/editor/attachments-upload.tsx | 32 | ||||
| -rw-r--r-- | lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx | 38 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/rfq-vendor-table.tsx | 46 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/send-rfq-dialog.tsx | 8 | ||||
| -rw-r--r-- | lib/tbe-last/service.ts | 8 |
9 files changed, 528 insertions, 194 deletions
diff --git a/app/api/partners/rfq-last/[id]/response/route.ts b/app/api/partners/rfq-last/[id]/response/route.ts index 21a4e7a4..bfbeb246 100644 --- a/app/api/partners/rfq-last/[id]/response/route.ts +++ b/app/api/partners/rfq-last/[id]/response/route.ts @@ -9,7 +9,7 @@ import { rfqLastVendorAttachments, rfqLastVendorResponseHistory } from "@/db/schema" -import { eq, and } from "drizzle-orm" +import { eq, and, inArray } from "drizzle-orm" import { writeFile, mkdir } from "fs/promises" import { createWriteStream } from "fs" import { pipeline } from "stream/promises" @@ -48,15 +48,15 @@ export async function POST( const data = JSON.parse(formData.get('data') as string) const files = formData.getAll('attachments') as File[] - // 업로드 디렉토리 생성 + // 업로드 디렉토리 생성 (벤더 응답용) const isDev = process.env.NODE_ENV === 'development' const uploadDir = isDev ? path.join(process.cwd(), 'public', 'uploads', 'rfq', rfqId.toString()) : path.join(process.env.NAS_PATH || '/nas', 'uploads', 'rfq', rfqId.toString()) await mkdir(uploadDir, { recursive: true }) - - // 트랜잭션 시작 (DB 작업만) + + // 트랜잭션 시작 const result = await db.transaction(async (tx) => { // 1. 벤더 응답 생성 const [vendorResponse] = await tx.insert(rfqLastVendorResponses).values({ @@ -135,31 +135,39 @@ export async function POST( itemRemark: item.itemRemark, deviationReason: item.deviationReason, })) - + await tx.insert(rfqLastVendorQuotationItems).values(quotationItemsData) } - - // 3. 이력 기록 + + // 이력 기록 await tx.insert(rfqLastVendorResponseHistory).values({ - vendorResponseId: vendorResponse.id, - action: "생성", - previousStatus: null, + vendorResponseId: vendorResponseId, + action: isNewResponse ? "생성" : (data.status === "제출완료" ? "제출" : "수정"), + previousStatus: existingResponse?.status || null, newStatus: data.status || "작성중", changeDetails: data, performedBy: session.user.id, }) - - return vendorResponse + + return { id: vendorResponseId, isNew: isNewResponse } }) - // 4. 파일 저장 (트랜잭션 밖에서 처리) + // 파일 저장 (트랜잭션 밖에서 처리) const fileRecords = [] - + if (files.length > 0) { + console.log(`저장할 파일 수: ${files.length}`) + console.log('파일 메타데이터:', data.fileMetadata) + for (let i = 0; i < files.length; i++) { const file = files[i] - const metadata = data.fileMetadata?.[i] // 인덱스로 메타데이터 매칭 - + // 파일 메타데이터에서 attachmentType 정보 가져옴 + const metadata = data.fileMetadata && data.fileMetadata[i] + const attachmentType = metadata?.attachmentType || "기타" + const description = metadata?.description || "" + + console.log(`파일 ${i + 1} 처리: ${file.name}, 파일 객체 타입: ${attachmentType}`) + try { const filename = `${uuidv4()}_${file.name.replace(/[^a-zA-Z0-9.-]/g, '_')}` const filepath = path.join(uploadDir, filename) @@ -171,24 +179,28 @@ export async function POST( const buffer = Buffer.from(await file.arrayBuffer()) await writeFile(filepath, buffer) } - + fileRecords.push({ vendorResponseId: result.id, - attachmentType: metadata?.attachmentType || "기타", // 메타데이터에서 가져옴 + attachmentType: attachmentType, // 파일 객체에서 직접 가져옴 fileName: filename, originalFileName: file.name, filePath: `/uploads/rfq/${rfqId}/${filename}`, fileSize: file.size, - fileType: file.type, - description: metadata?.description || "", // 메타데이터에서 가져옴 + fileType: file.type || path.extname(file.name).slice(1), + description: description, uploadedBy: session.user.id, }) + + console.log(`파일 저장 완료: ${filename}, 타입: ${attachmentType}`) } catch (fileError) { - console.error(`Failed to save file ${file.name}:`, fileError) + console.error(`파일 저장 실패 ${file.name}:`, fileError) } } + // DB에 파일 정보 저장 if (fileRecords.length > 0) { + console.log('DB에 저장할 파일 레코드:', fileRecords) await db.insert(rfqLastVendorAttachments).values(fileRecords) } } @@ -224,14 +236,14 @@ export async function PUT( const data = JSON.parse(formData.get('data') as string) const files = formData.getAll('attachments') as File[] - // 업로드 디렉토리 생성 + // 업로드 디렉토리 생성 (벤더 응답용) const isDev = process.env.NODE_ENV === 'development' - const uploadDir = isDev - ? path.join(process.cwd(), 'public', 'uploads', 'rfq', rfqId.toString()) - : path.join(process.env.NAS_PATH || '/nas', 'uploads', 'rfq', rfqId.toString()) - + const uploadDir = isDev + ? path.join(process.cwd(), 'public', 'uploads', 'rfq-last-vendor-responses') + : path.join(process.env.NAS_PATH || '/nas', 'uploads', 'rfq-last-vendor-responses') + await mkdir(uploadDir, { recursive: true }) - + // 트랜잭션 시작 const result = await db.transaction(async (tx) => { // 1. 기존 응답 찾기 @@ -333,11 +345,22 @@ export async function PUT( return { id: responseId } }) - // 5. 새 첨부파일 추가 (트랜잭션 밖에서) + // 파일 저장 (트랜잭션 밖에서) const fileRecords = [] - + if (files.length > 0) { - for (const file of files) { + console.log(`업데이트 저장할 파일 수: ${files.length}`) + console.log('파일 메타데이터:', data.fileMetadata) + + for (let i = 0; i < files.length; i++) { + const file = files[i] + // 파일 메타데이터에서 attachmentType 정보 가져옴 + const metadata = data.fileMetadata && data.fileMetadata[i] + const attachmentType = metadata?.attachmentType || "기타" + const description = metadata?.description || "" + + console.log(`업데이트 파일 ${i + 1} 처리: ${file.name}, 파일 객체 타입: ${attachmentType}`) + try { const filename = `${uuidv4()}_${file.name.replace(/[^a-zA-Z0-9.-]/g, '_')}` const filepath = path.join(uploadDir, filename) @@ -349,35 +372,38 @@ export async function PUT( const buffer = Buffer.from(await file.arrayBuffer()) await writeFile(filepath, buffer) } - + fileRecords.push({ vendorResponseId: result.id, - attachmentType: (file as any).attachmentType || "기타", + attachmentType: attachmentType, // 파일 객체에서 직접 가져옴 fileName: filename, originalFileName: file.name, filePath: `/uploads/rfq/${rfqId}/${filename}`, fileSize: file.size, - fileType: file.type, - description: (file as any).description, + fileType: file.type || path.extname(file.name).slice(1), + description: description, uploadedBy: session.user.id, }) + + console.log(`업데이트 파일 저장 완료: ${filename}, 타입: ${attachmentType}`) } catch (fileError) { - console.error(`Failed to save file ${file.name}:`, fileError) + console.error(`업데이트 파일 저장 실패 ${file.name}:`, fileError) } } - + if (fileRecords.length > 0) { + console.log('업데이트 DB에 저장할 파일 레코드:', fileRecords) await db.insert(rfqLastVendorAttachments).values(fileRecords) } } - return NextResponse.json({ - success: true, + return NextResponse.json({ + success: true, data: result, message: data.status === "제출완료" ? "견적서가 성공적으로 제출되었습니다." : "견적서가 수정되었습니다.", filesUploaded: fileRecords.length }) - + } catch (error) { console.error("Error updating vendor response:", error) return NextResponse.json( diff --git a/db/schema/rfqVendor.ts b/db/schema/rfqVendor.ts index 0ddf109b..dea196b1 100644 --- a/db/schema/rfqVendor.ts +++ b/db/schema/rfqVendor.ts @@ -29,6 +29,8 @@ export const rfqLastVendorResponses = pgTable( responseVersion: integer("response_version").notNull().default(1), isLatest: boolean("is_latest").notNull().default(true), + isDocumentConfirmed: boolean("is_document_confirmed").default(false), + // 참여 여부 관련 필드 (새로 추가) participationStatus: varchar("participation_status", { length: 20 }) diff --git a/lib/rfq-last/attachment/vendor-response-table.tsx b/lib/rfq-last/attachment/vendor-response-table.tsx index 076fb153..47a23d18 100644 --- a/lib/rfq-last/attachment/vendor-response-table.tsx +++ b/lib/rfq-last/attachment/vendor-response-table.tsx @@ -17,7 +17,9 @@ import { FileCode, Building2, Calendar, - AlertCircle, X + AlertCircle, + X, + CheckCircle2 } from "lucide-react"; import { format, formatDistanceToNow, isValid, isBefore, isAfter } from "date-fns"; import { ko } from "date-fns/locale"; @@ -43,7 +45,7 @@ import type { DataTableRowAction, } from "@/types/table"; import { cn } from "@/lib/utils"; -import { getRfqVendorAttachments, updateAttachmentTypes } from "@/lib/rfq-last/service"; +import { confirmVendorDocuments, getRfqVendorAttachments, updateAttachmentTypes } from "@/lib/rfq-last/service"; import { downloadFile } from "@/lib/file-download"; import { toast } from "sonner"; import { @@ -61,7 +63,16 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; - +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; // 타입 정의 interface VendorAttachment { @@ -153,53 +164,81 @@ export function VendorResponseTable({ const [data, setData] = React.useState<VendorAttachment[]>(initialData); const [isRefreshing, setIsRefreshing] = React.useState(false); const [selectedRows, setSelectedRows] = React.useState<VendorAttachment[]>([]); - - - const [isUpdating, setIsUpdating] = React.useState(false); const [showTypeDialog, setShowTypeDialog] = React.useState(false); const [selectedType, setSelectedType] = React.useState<"구매" | "설계" | "">(""); - const [selectedVendor, setSelectedVendor] = React.useState<string | null>(null); + const [showConfirmDialog, setShowConfirmDialog] = React.useState(false); + const [isConfirming, setIsConfirming] = React.useState(false); + const [confirmedVendors, setConfirmedVendors] = React.useState<Set<number>>(new Set()); const filteredData = React.useMemo(() => { if (!selectedVendor) return data; return data.filter(item => item.vendorName === selectedVendor); }, [data, selectedVendor]); + // 현재 선택된 벤더의 ID 가져오기 + const selectedVendorId = React.useMemo(() => { + if (!selectedVendor) return null; + const vendorItem = data.find(item => item.vendorName === selectedVendor); + return vendorItem?.vendorId || null; + }, [selectedVendor, data]); - - // 데이터 새로고침 - const handleRefresh = React.useCallback(async () => { - setIsRefreshing(true); - try { - const result = await getRfqVendorAttachments(rfqId); - if (result.vendorSuccess && result.vendorData) { - setData(result.vendorData); - toast.success("데이터를 새로고침했습니다."); - } else { - toast.error("데이터를 불러오는데 실패했습니다."); - } - } catch (error) { - console.error("Refresh error:", error); - toast.error("새로고침 중 오류가 발생했습니다."); - } finally { - setIsRefreshing(false); + // 데이터 새로고침 + const handleRefresh = React.useCallback(async () => { + setIsRefreshing(true); + try { + const result = await getRfqVendorAttachments(rfqId); + if (result.vendorSuccess && result.vendorData) { + setData(result.vendorData); + toast.success("데이터를 새로고침했습니다."); + } else { + toast.error("데이터를 불러오는데 실패했습니다."); } - }, [rfqId]); + } catch (error) { + console.error("Refresh error:", error); + toast.error("새로고침 중 오류가 발생했습니다."); + } finally { + setIsRefreshing(false); + } + }, [rfqId]); + + const toggleVendorFilter = (vendor: string) => { + if (selectedVendor === vendor) { + setSelectedVendor(null); // 이미 선택된 벤더를 다시 클릭하면 필터 해제 + } else { + setSelectedVendor(vendor); + // 필터 변경 시 선택 초기화 (옵션) + setSelectedRows([]); + } + }; + + // 문서 확정 처리 + const handleConfirmDocuments = React.useCallback(async () => { + if (!selectedVendorId || !selectedVendor) return; - const toggleVendorFilter = (vendor: string) => { - if (selectedVendor === vendor) { - setSelectedVendor(null); // 이미 선택된 벤더를 다시 클릭하면 필터 해제 + setIsConfirming(true); + try { + const result = await confirmVendorDocuments(rfqId, selectedVendorId); + + if (result.success) { + toast.success(result.message); + setConfirmedVendors(prev => new Set(prev).add(selectedVendorId)); + setShowConfirmDialog(false); + // 데이터 새로고침 + await handleRefresh(); } else { - setSelectedVendor(vendor); - // 필터 변경 시 선택 초기화 (옵션) - setSelectedRows([]); + toast.error(result.message); } - }; + } catch (error) { + toast.error("문서 확정 중 오류가 발생했습니다."); + } finally { + setIsConfirming(false); + } + }, [selectedVendorId, selectedVendor, rfqId, handleRefresh]); - // 문서 유형 일괄 변경 - const handleBulkTypeChange = React.useCallback(async () => { + // 문서 유형 일괄 변경 + const handleBulkTypeChange = React.useCallback(async () => { if (!selectedType || selectedRows.length === 0) return; setIsUpdating(true); @@ -225,8 +264,6 @@ export function VendorResponseTable({ } }, [selectedType, selectedRows, handleRefresh]); - - // 액션 처리 const handleAction = React.useCallback(async (action: DataTableRowAction<VendorAttachment>) => { const attachment = action.row.original; @@ -352,56 +389,6 @@ export function VendorResponseTable({ }, size: 300, }, - // { - // accessorKey: "description", - // header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="설명" />, - // cell: ({ row }) => ( - // <div className="max-w-[200px] truncate" title={row.original.description || ""}> - // {row.original.description || "-"} - // </div> - // ), - // size: 200, - // }, - // { - // accessorKey: "validTo", - // header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="유효기간" />, - // cell: ({ row }) => { - // const { validFrom, validTo } = row.original; - // const validity = checkValidity(validTo); - - // if (!validTo) return <span className="text-muted-foreground">-</span>; - - // return ( - // <TooltipProvider> - // <Tooltip> - // <TooltipTrigger asChild> - // <div className="flex items-center gap-2"> - // {validity === "expired" && ( - // <AlertCircle className="h-4 w-4 text-red-500" /> - // )} - // {validity === "expiring-soon" && ( - // <AlertCircle className="h-4 w-4 text-yellow-500" /> - // )} - // <span className={cn( - // "text-sm", - // validity === "expired" && "text-red-500", - // validity === "expiring-soon" && "text-yellow-500" - // )}> - // {format(new Date(validTo), "yyyy-MM-dd")} - // </span> - // </div> - // </TooltipTrigger> - // <TooltipContent> - // <p>유효기간: {validFrom ? format(new Date(validFrom), "yyyy-MM-dd") : "?"} ~ {format(new Date(validTo), "yyyy-MM-dd")}</p> - // {validity === "expired" && <p className="text-red-500">만료됨</p>} - // {validity === "expiring-soon" && <p className="text-yellow-500">곧 만료 예정</p>} - // </TooltipContent> - // </Tooltip> - // </TooltipProvider> - // ); - // }, - // size: 120, - // }, { accessorKey: "responseStatus", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="응답 상태" />, @@ -496,11 +483,6 @@ export function VendorResponseTable({ options: [ { label: "구매", value: "구매" }, { label: "설계", value: "설계" }, - // { label: "인증서", value: "인증서" }, - // { label: "카탈로그", value: "카탈로그" }, - // { label: "도면", value: "도면" }, - // { label: "테스트성적서", value: "테스트성적서" }, - // { label: "기타", value: "기타" }, ] }, { id: "documentNo", label: "문서번호", type: "text" }, @@ -518,8 +500,6 @@ export function VendorResponseTable({ { label: "취소", value: "취소" }, ] }, - // { id: "validFrom", label: "유효시작일", type: "date" }, - // { id: "validTo", label: "유효종료일", type: "date" }, { id: "uploadedAt", label: "업로드일", type: "date" }, ]; @@ -579,17 +559,42 @@ export function VendorResponseTable({ <span className="text-sm font-medium text-muted-foreground"> 벤더별 필터 </span> - {selectedVendor && ( - <Button - variant="ghost" - size="sm" - onClick={() => setSelectedVendor(null)} - className="h-7 px-2 text-xs" - > - <X className="h-3 w-3 mr-1" /> - 필터 초기화 - </Button> - )} + <div className="flex items-center gap-2"> + {/* 선택된 벤더의 문서 확정 버튼 */} + {selectedVendor && selectedVendorId && ( + <Button + variant="default" + size="sm" + onClick={() => setShowConfirmDialog(true)} + className="h-7" + disabled={confirmedVendors.has(selectedVendorId)} + > + {confirmedVendors.has(selectedVendorId) ? ( + <> + <CheckCircle2 className="h-3 w-3 mr-1" /> + 확정완료 + </> + ) : ( + <> + <CheckCircle2 className="h-3 w-3 mr-1" /> + {selectedVendor} 문서 확정 + </> + )} + </Button> + )} + + {selectedVendor && ( + <Button + variant="ghost" + size="sm" + onClick={() => setSelectedVendor(null)} + className="h-7 px-2 text-xs" + > + <X className="h-3 w-3 mr-1" /> + 필터 초기화 + </Button> + )} + </div> </div> {/* 벤더 버튼들 */} @@ -607,20 +612,29 @@ export function VendorResponseTable({ </Button> {/* 각 벤더별 버튼 */} - {Array.from(vendorCounts.entries()).map(([vendor, count]) => ( - <Button - key={vendor} - variant={selectedVendor === vendor ? "default" : "outline"} - size="sm" - onClick={() => toggleVendorFilter(vendor)} - className="h-7" - > - <Building2 className="h-3 w-3 mr-1" /> - <span className="text-xs"> - {vendor} ({count}) - </span> - </Button> - ))} + {Array.from(vendorCounts.entries()).map(([vendor, count]) => { + const vendorItem = data.find(item => item.vendorName === vendor); + const vendorId = vendorItem?.vendorId; + const isConfirmed = vendorId ? confirmedVendors.has(vendorId) : false; + + return ( + <Button + key={vendor} + variant={selectedVendor === vendor ? "default" : "outline"} + size="sm" + onClick={() => toggleVendorFilter(vendor)} + className="h-7 relative" + > + {isConfirmed && ( + <CheckCircle2 className="h-3 w-3 mr-1 text-green-600" /> + )} + <Building2 className="h-3 w-3 mr-1" /> + <span className="text-xs"> + {vendor} ({count}) + </span> + </Button> + ); + })} </div> {/* 현재 필터 상태 표시 */} @@ -636,7 +650,7 @@ export function VendorResponseTable({ <ClientDataTable columns={columns} - data={filteredData} // 필터링된 데이터 사용 + data={filteredData} advancedFilterFields={advancedFilterFields} autoSizeColumns={true} compact={true} @@ -650,8 +664,8 @@ export function VendorResponseTable({ {additionalActions} </ClientDataTable> - {/* 문서 유형 변경 다이얼로그 */} - <Dialog open={showTypeDialog} onOpenChange={setShowTypeDialog}> + {/* 문서 유형 변경 다이얼로그 */} + <Dialog open={showTypeDialog} onOpenChange={setShowTypeDialog}> <DialogContent className="sm:max-w-[425px]"> <DialogHeader> <DialogTitle>문서 유형 변경</DialogTitle> @@ -724,6 +738,46 @@ export function VendorResponseTable({ </DialogFooter> </DialogContent> </Dialog> + + {/* 문서 확정 확인 다이얼로그 */} + <AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>문서 확정 확인</AlertDialogTitle> + <AlertDialogDescription className="space-y-2"> + <p> + <span className="font-semibold">{selectedVendor}</span> 벤더의 모든 문서를 확정하시겠습니까? + </p> + <p className="text-sm text-muted-foreground"> + 이 작업은 해당 벤더의 모든 응답 문서를 확정 처리합니다. + </p> + <p className="text-sm text-yellow-600"> + ⚠️ 확정 후에는 되돌릴 수 없습니다. + </p> + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel disabled={isConfirming}>취소</AlertDialogCancel> + <AlertDialogAction + onClick={handleConfirmDocuments} + disabled={isConfirming} + className="bg-blue-600 hover:bg-blue-700" + > + {isConfirming ? ( + <> + <RefreshCw className="mr-2 h-4 w-4 animate-spin" /> + 확정 중... + </> + ) : ( + <> + <CheckCircle2 className="mr-2 h-4 w-4" /> + 확정 + </> + )} + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> </div> ); }
\ No newline at end of file diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index 09d707d7..78d2479a 100644 --- a/lib/rfq-last/service.ts +++ b/lib/rfq-last/service.ts @@ -3,7 +3,7 @@ import { revalidatePath, unstable_cache, unstable_noStore } from "next/cache"; import db from "@/db/db"; -import { avlVendorInfo, paymentTerms, incoterms, rfqLastVendorQuotationItems, rfqLastVendorAttachments, rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView, vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView, vendorContacts, projects, basicContract, basicContractTemplates, rfqLastTbeSessions, rfqLastTbeDocumentReviews, templateDetailView } from "@/db/schema"; +import { avlVendorInfo, paymentTerms, incoterms, rfqLastVendorQuotationItems, rfqLastVendorAttachments, rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView, vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView, vendorContacts, projects, basicContract, basicContractTemplates, rfqLastTbeSessions, rfqLastTbeDocumentReviews, templateDetailView, RfqStatus } from "@/db/schema"; import { sql, and, desc, asc, like, ilike, or, eq, SQL, count, gte, lte, isNotNull, ne, inArray } from "drizzle-orm"; import { filterColumns } from "@/lib/filter-columns"; import { GetRfqLastAttachmentsSchema, GetRfqsSchema } from "./validations"; @@ -1527,6 +1527,9 @@ export async function getRfqVendorResponses(rfqId: number) { submittedBy: rfqLastVendorResponses.submittedBy, submittedByName: users.name, + isDocumentConfirmed: rfqLastVendorResponses.isDocumentConfirmed, + + // 금액 정보 totalAmount: rfqLastVendorResponses.totalAmount, currency: rfqLastVendorResponses.currency, @@ -1709,6 +1712,7 @@ export async function getRfqVendorResponses(rfqId: number) { responseVersion: response.responseVersion, isLatest: response.isLatest, status: response.status, + isDocumentConfirmed: response.isDocumentConfirmed, // 벤더 정보 vendor: { @@ -2980,7 +2984,7 @@ export async function sendRfqToVendors({ // 6. RFQ 상태 업데이트 if (results.length > 0) { - await updateRfqStatus(rfqId, currentUser.id); + await updateRfqStatus(rfqId, Number(currentUser.id)); } return { @@ -4812,13 +4816,24 @@ export async function updateShortList( .update(rfqLastTbeSessions) .set({ status: "준비중", - updatedBy: session.user.id, + updatedBy: Number(session.user.id), updatedAt: new Date() }) .where(eq(rfqLastTbeSessions.id, existingSession[0].id)); } }) ); + + // 2-3. RFQ 상태를 "Short List 확정"으로 업데이트 + await tx + .update(rfqsLast) + .set({ + status: "Short List 확정" as RfqStatus, + updatedBy: Number(session.user.id), + updatedAt: new Date() + }) + .where(eq(rfqsLast.id, rfqId)); + } else { // shortList가 false인 경우, 해당 벤더들의 활성 TBE 세션을 취소 상태로 변경 await Promise.all( @@ -4839,21 +4854,60 @@ export async function updateShortList( ) ) ); + + // shortList를 해제하는 경우의 상태 처리 + // 모든 벤더의 shortList가 false인지 확인 + const remainingShortlisted = await tx + .select() + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.isLatest, true), + eq(rfqLastDetails.shortList, true) + ) + ) + .limit(1); + + // 남은 shortList 벤더가 없으면 RFQ 상태를 이전 상태로 되돌림 + // if (remainingShortlisted.length === 0) { + // await tx + // .update(rfqsLast) + // .set({ + // status: "견적 접수" as RfqStatus, // 또는 적절한 이전 상태 + // updatedBy: Number(session.user.id), + // updatedAt: new Date() + // }) + // .where(eq(rfqsLast.id, rfqId)); + // } } return { success: true, updatedCount: updatedDetails.length, vendorIds, - tbeSessionsUpdated: shortListStatus + tbeSessionsUpdated: shortListStatus, + rfqStatusUpdated: true }; } + // 벤더가 없는 경우 (모든 shortList를 false로만 설정) + // RFQ 상태를 이전 상태로 되돌림 + await tx + .update(rfqsLast) + .set({ + status: "견적 접수" as RfqStatus, // 또는 적절한 이전 상태 + updatedBy: Number(session.user.id), + updatedAt: new Date() + }) + .where(eq(rfqsLast.id, rfqId)); + return { success: true, updatedCount: 0, vendorIds: [], - tbeSessionsUpdated: false + tbeSessionsUpdated: false, + rfqStatusUpdated: true }; }); @@ -5209,3 +5263,127 @@ export async function addAvlVendorsToRfq({ }; } } + + + +interface ConfirmVendorDocumentsResult { + success: boolean; + message: string; + updatedCount?: number; +} + +/** + * 특정 벤더의 모든 문서를 확정 처리 + * @param rfqId RFQ ID + * @param vendorId 벤더 ID + * @returns 처리 결과 + */ +export async function confirmVendorDocuments( + rfqId: number, + vendorId: number +): Promise<ConfirmVendorDocumentsResult> { + try { + // 데이터 유효성 검증 + if (!rfqId || !vendorId) { + return { + success: false, + message: "RFQ ID와 벤더 ID가 필요합니다.", + }; + } + + // 트랜잭션으로 두 테이블 동시 업데이트 + const result = await db.transaction(async (tx) => { + // 1. rfqLastVendorResponses 테이블 업데이트 + const vendorResponseResult = await tx + .update(rfqLastVendorResponses) + .set({ + isDocumentConfirmed: true, + updatedAt: new Date(), + }) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + eq(rfqLastVendorResponses.vendorId, vendorId) + ) + ) + .returning({ id: rfqLastVendorResponses.id }); + + // 업데이트된 레코드 수 확인 + const updatedCount = vendorResponseResult.length; + + if (updatedCount === 0) { + throw new Error("해당 조건에 맞는 문서를 찾을 수 없습니다."); + } + + // 2. rfqsLast 테이블 status 업데이트 + const rfqUpdateResult = await tx + .update(rfqsLast) + .set({ + status: "견적요청문서 확정" as RfqStatus, + updatedAt: new Date(), + }) + .where(eq(rfqsLast.id, rfqId)) + .returning({ id: rfqsLast.id }); + + if (rfqUpdateResult.length === 0) { + throw new Error("RFQ 상태 업데이트에 실패했습니다."); + } + + return updatedCount; + }); + + // 캐시 무효화 (필요한 경우) + revalidatePath(`/rfq-last/${rfqId}`); + + return { + success: true, + message: `문서가 확정되었습니다.`, + updatedCount: result, + }; + } catch (error) { + console.log("문서 확정 중 오류 발생:", error); + + return { + success: false, + message: error instanceof Error + ? `문서 확정 실패: ${error.message}` + : "문서 확정 중 알 수 없는 오류가 발생했습니다.", + }; + } +} + +/** + * 특정 벤더의 문서 확정 상태 조회 + * @param rfqId RFQ ID + * @param vendorId 벤더 ID + * @returns 확정 상태 + */ +export async function getVendorDocumentConfirmStatus( + rfqId: number, + vendorId: number +): Promise<{ isConfirmed: boolean; count: number }> { + try { + const results = await db + .select({ + isDocumentConfirmed: rfqLastVendorResponses.isDocumentConfirmed, + }) + .from(rfqLastVendorResponses) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + eq(rfqLastVendorResponses.vendorId, vendorId) + ) + ); + + const confirmedCount = results.filter(r => r.isDocumentConfirmed).length; + const totalCount = results.length; + + return { + isConfirmed: totalCount > 0 && confirmedCount === totalCount, + count: totalCount, + }; + } catch (error) { + console.error("문서 확정 상태 조회 중 오류:", error); + return { isConfirmed: false, count: 0 }; + } +}
\ No newline at end of file diff --git a/lib/rfq-last/vendor-response/editor/attachments-upload.tsx b/lib/rfq-last/vendor-response/editor/attachments-upload.tsx index ea7bb9c9..b85407ff 100644 --- a/lib/rfq-last/vendor-response/editor/attachments-upload.tsx +++ b/lib/rfq-last/vendor-response/editor/attachments-upload.tsx @@ -97,10 +97,10 @@ export default function AttachmentsUpload({ // 파일 추가 const handleFileAdd = (files: FileList | null, type: "구매" | "설계") => { if (!files) return - + const newFiles: FileWithType[] = [] const errors: string[] = [] - + Array.from(files).forEach(file => { const error = validateFile(file) if (error) { @@ -110,17 +110,31 @@ export default function AttachmentsUpload({ attachmentType: type, description: "" }) + // 디버그 로그 추가 + console.log(`파일 추가됨: ${file.name}, 타입: ${type}`) newFiles.push(fileWithType) } }) - + if (errors.length > 0) { setUploadErrors(errors) setTimeout(() => setUploadErrors([]), 5000) } - + if (newFiles.length > 0) { - onAttachmentsChange([...attachments, ...newFiles]) + const updatedFiles = [...attachments, ...newFiles] + onAttachmentsChange(updatedFiles) + + // 추가된 파일들의 타입 확인 로그 + console.log('업데이트된 파일 목록:', updatedFiles.map(f => ({ + name: f.name, + attachmentType: f.attachmentType + }))) + + // 각 파일의 타입이 제대로 설정되었는지 검증 + newFiles.forEach(file => { + console.log(`새 파일 타입 검증: ${file.name} = ${file.attachmentType}`) + }) } } @@ -175,7 +189,15 @@ export default function AttachmentsUpload({ // 파일 타입 변경 const handleTypeChange = (index: number, newType: "구매" | "설계") => { const newFiles = [...attachments] + const oldType = newFiles[index].attachmentType newFiles[index].attachmentType = newType + + console.log(`파일 타입 변경: ${newFiles[index].name} (${oldType} -> ${newType})`) + console.log('변경 후 파일 목록:', newFiles.map(f => ({ + name: f.name, + attachmentType: f.attachmentType + }))) + onAttachmentsChange(newFiles) } diff --git a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx index fec9a2b9..795431f6 100644 --- a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx +++ b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx @@ -249,28 +249,34 @@ export default function VendorResponseEditor({ } const onSubmit = async (data: VendorResponseFormData, isSubmit: boolean = false) => { - console.log('onSubmit called with:', { data, isSubmit }) // 디버깅용 - + console.log('onSubmit called with:', { data, isSubmit, attachmentsCount: attachments.length }) // 디버깅용 + setLoading(true) setUploadProgress(0) try { const formData = new FormData() - const fileMetadata = attachments.map((file: FileWithType) => ({ - attachmentType: file.attachmentType || "기타", - description: file.description || "" - })) + // 첨부파일 메타데이터 생성 시 타입 확인 + const fileMetadata = attachments.map((file: FileWithType) => { + const metadata = { + attachmentType: file.attachmentType || "기타", + description: file.description || "" + }; + console.log(`파일 메타데이터 생성: ${file.name} -> 타입: ${metadata.attachmentType}`); + return metadata; + }); + - // 삭제된 첨부파일 ID 목록 - const deletedAttachmentIds = deletedAttachments.map(file => file.id) // 디버그: 첨부파일 attachmentType 확인 - console.log('Attachments with types:', attachments.map(f => ({ + console.log('최종 첨부파일 목록:', attachments.map(f => ({ name: f.name, attachmentType: f.attachmentType, size: f.size }))) + + console.log('파일 메타데이터:', fileMetadata) // 기본 데이터 추가 @@ -285,15 +291,17 @@ export default function VendorResponseEditor({ totalAmount: data.quotationItems.reduce((sum, item) => sum + item.totalPrice, 0), updatedBy: userId, fileMetadata, - deletedAttachmentIds } console.log('Submitting data:', submitData) // 디버깅용 formData.append('data', JSON.stringify(submitData)) - // 첨부파일 추가 + // 첨부파일 추가 (메타데이터를 통해 타입 정보 전달) attachments.forEach((file, index) => { + const metadata = fileMetadata[index]; + console.log(`첨부파일 추가: ${file.name}, 타입: ${metadata?.attachmentType}`); + formData.append(`attachments`, file) }) @@ -341,6 +349,14 @@ export default function VendorResponseEditor({ await uploadPromise + // 임시저장 성공 시 첨부파일 목록 초기화 (중복 저장 방지) + if (!isSubmit) { + console.log('임시저장 완료 - 첨부파일 목록 초기화'); + setAttachments([]); + setExistingAttachments([]); + setDeletedAttachments([]); + } + toast.success(isSubmit ? "견적서가 제출되었습니다." : "견적서가 저장되었습니다.") if (isSubmit) { router.push('/partners/rfq-last') diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx index 17433773..b8a5184f 100644 --- a/lib/rfq-last/vendor/rfq-vendor-table.tsx +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -309,7 +309,17 @@ export function RfqVendorTable({ try { setIsUpdatingShortList(true); - const vendorIds = selectedRows + // response가 있는 벤더들만 필터링 + const vendorsWithResponse = selectedRows.filter(vendor => + vendor.response && vendor.response.vendor&& vendor.response.isDocumentConfirmed + ); + + if (vendorsWithResponse.length === 0) { + toast.warning("응답이 있는 벤더를 선택해주세요."); + return; + } + + const vendorIds = vendorsWithResponse .map(vendor => vendor.vendorId) .filter(id => id != null); @@ -474,19 +484,36 @@ export function RfqVendorTable({ }); // 기본계약 생성 결과 표시 + let message = ""; if (result.contractResults && result.contractResults.length > 0) { const totalContracts = result.contractResults.reduce((acc, r) => acc + r.totalCreated, 0); - toast.success(`${data.vendors.length}개 업체에 RFQ를 발송하고 ${totalContracts}개의 기본계약을 생성했습니다.`); + message = `${data.vendors.length}개 업체에 RFQ를 발송하고 ${totalContracts}개의 기본계약을 생성했습니다.`; } else { - toast.success(`${data.vendors.length}개 업체에 RFQ를 발송했습니다.`); + message = `${data.vendors.length}개 업체에 RFQ를 발송했습니다.`; } - // 페이지 새로고침 - router.refresh(); + // 성공 결과를 반환 + return { + success: true, + message: message, + totalSent: result.totalSent || data.vendors.length, + totalFailed: result.totalFailed || 0, + totalContracts: result.totalContracts || 0, + totalTbeSessions: result.totalTbeSessions || 0 + }; + } catch (error) { console.error("RFQ 발송 실패:", error); - toast.error("RFQ 발송에 실패했습니다."); - throw error; + + // 실패 결과를 반환 + return { + success: false, + message: error instanceof Error ? error.message : "RFQ 발송에 실패했습니다.", + totalSent: 0, + totalFailed: data.vendors.length, + totalContracts: 0, + totalTbeSessions: 0 + }; } }, [rfqId, rfqCode, router]); @@ -1513,6 +1540,7 @@ export function RfqVendorTable({ // 참여 의사가 있는 선택된 벤더 수 계산 const participatingCount = selectedRows.length; const shortListCount = selectedRows.filter(v => v.shortList).length; + const vendorsWithResponseCount = selectedRows.filter(v => v.response && v.response.vendor && v.response.isDocumentConfirmed).length; // 견적서가 있는 선택된 벤더 수 계산 const quotationCount = selectedRows.filter(row => @@ -1582,7 +1610,7 @@ export function RfqVendorTable({ variant="outline" size="sm" onClick={handleShortListConfirm} - disabled={isUpdatingShortList} + disabled={isUpdatingShortList || vendorsWithResponseCount===0} // className={ "border-green-500 text-green-600 hover:bg-green-50" } > {isUpdatingShortList ? ( @@ -1594,7 +1622,7 @@ export function RfqVendorTable({ <> <CheckSquare className="h-4 w-4 mr-2" /> Short List 확정 - {participatingCount > 0 && ` (${participatingCount})`} + {vendorsWithResponseCount > 0 && ` (${vendorsWithResponseCount})`} </> )} </Button> diff --git a/lib/rfq-last/vendor/send-rfq-dialog.tsx b/lib/rfq-last/vendor/send-rfq-dialog.tsx index e63086ad..42470ecc 100644 --- a/lib/rfq-last/vendor/send-rfq-dialog.tsx +++ b/lib/rfq-last/vendor/send-rfq-dialog.tsx @@ -812,8 +812,12 @@ export function SendRfqDialog({ hasToSendEmail: hasToSendEmail, }); + if (!sendResult) { + throw new Error("서버 응답이 없습니다."); + } + if (!sendResult.success) { - throw new Error(sendResult.message); + throw new Error(sendResult.message || "RFQ 발송에 실패했습니다."); } toast.success(sendResult.message); @@ -821,7 +825,7 @@ export function SendRfqDialog({ } catch (error) { console.error("RFQ 발송 실패:", error); - toast.error("RFQ 발송에 실패했습니다."); + toast.error(error instanceof Error ? error.message : "RFQ 발송에 실패했습니다."); } finally { setIsSending(false); setIsGeneratingPdfs(false); diff --git a/lib/tbe-last/service.ts b/lib/tbe-last/service.ts index b69ab71c..da0a5a4c 100644 --- a/lib/tbe-last/service.ts +++ b/lib/tbe-last/service.ts @@ -6,7 +6,7 @@ import db from "@/db/db"; import { and, desc, asc, eq, sql, or, isNull, isNotNull, ne, inArray } from "drizzle-orm"; import { tbeLastView, tbeDocumentsView } from "@/db/schema"; import { rfqPrItems } from "@/db/schema/rfqLast"; -import { rfqLastTbeDocumentReviews, rfqLastTbePdftronComments, rfqLastTbeVendorDocuments,rfqLastTbeSessions } from "@/db/schema"; +import {rfqLastDetails, rfqLastTbeDocumentReviews, rfqLastTbePdftronComments, rfqLastTbeVendorDocuments,rfqLastTbeSessions } from "@/db/schema"; import { filterColumns } from "@/lib/filter-columns"; import { GetTBELastSchema } from "./validations"; import { getServerSession } from "next-auth" @@ -49,7 +49,11 @@ export async function getAllTBELast(input: GetTBELastSchema) { } // 최종 WHERE - const finalWhere = and(advancedWhere, globalWhere, ne(tbeLastView.status,"생성중")); + const whereConditions = [advancedWhere, ne(tbeLastView.sessionStatus,"생성중")]; + if (globalWhere) { + whereConditions.push(globalWhere); + } + const finalWhere = and(...whereConditions); // 정렬 const orderBy = input.sort?.length |
